你会用Tomcat,但不一定懂

2022-09-01 11:38:42 浏览数 (1)

Tomcat是我们最常用的Web容器,但是很少有人知晓其工作原理。下面就总结一下极客时间的课程《深入拆解 Tomcat & Jetty》,本文仅总结部分知识,有兴趣可以找相应的课程学习一番。

手写一个Servlet

1、首先新建一个名为MyServlet.java的文件,敲入下面这些代码

代码语言:javascript复制
import java.io.IOException;
import java.io.PrintWriter;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class MyServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        System.out.println("MyServlet 在处理get()请求...");
        PrintWriter out = response.getWriter();
        response.setContentType("text/html;charset=utf-8");
        out.println("<strong>My Servlet!</strong><br>");
    }
    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        System.out.println("MyServlet 在处理post()请求...");
        PrintWriter out = response.getWriter();
        response.setContentType("text/html;charset=utf-8");
        out.println("<strong>My Servlet!</strong><br>");
    }
}

这个 Servlet 完成的功能很简单,分别在 doGet 和 doPost 方法体里返回一段简单的 HTML。

2、 将 Java 文件编译成 Class 文件

代码语言:javascript复制
javac -cp ./servlet-api.jar MyServlet.java

编译成功后,你会在当前目录下找到一个叫MyServlet.class的文件。

3、然后在web.xml中配置 Servlet,内容如下

代码语言:javascript复制

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
  http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
  version="4.0"
  metadata-complete="true">
    <description> Servlet Example. </description>
    <display-name> MyServlet Example </display-name>
    <request-character-encoding>UTF-8</request-character-encoding>
    <servlet>
      <servlet-name>myServlet</servlet-name>
      <servlet-class>MyServlet</servlet-class>
    </servlet>
    <servlet-mapping>
      <servlet-name>myServlet</servlet-name>
      <url-pattern>/myservlet</url-pattern>
    </servlet-mapping>
</web-app>

4、启动 Tomcat

在浏览器里访问这个 URL:http://localhost:8080/MyWebApp/myservlet,你会看到。

Servlet规范和容器

当客户请求某个资源时,HTTP 服务器会用一个 ServletRequest 对象把客户的请求信息封装起来,然后调用 Servlet 容器的 service 方法,Servlet 容器拿到请求后,根据请求的 URL 和 Servlet 的映射关系,找到相应的 Servlet,如果 Servlet 还没有被加载,就用反射机制创建这个 Servlet,并调用 Servlet 的 init 方法来完成初始化,接着调用 Servlet 的 service 方法来处理请求,把 ServletResponse 对象返回给 HTTP 服务器,HTTP 服务器会把响应发送给客户端。

Servlet 规范里定义了 ServletContext 这个接口来对应一个 Web 应用。Web 应用部署好后,Servlet 容器在启动时会加载 Web 应用,并为每个 Web 应用创建唯一的 ServletContext 对象。你可以把 ServletContext 看成是一个全局对象,一个 Web 应用可能有多个 Servlet,这些 Servlet 可以通过全局的 ServletContext 来共享数据,这些数据包括 Web 应用的初始化参数、Web 应用目录下的文件资源等。由于 ServletContext 持有所有 Servlet 实例,你还可以通过它来实现 Servlet 请求的转发。

Servlet 规范提供了两种扩展机制:Filter 和 Listener。Filter 是干预过程的,它是过程的一部分,是基于过程行为的。Listener 是基于状态的,任何行为改变同一个状态,触发的事件是一致的

Tomcat系统架构

Tomcat 要实现 2 个核心功能:

  • 处理 Socket 连接,负责网络字节流与 Request 和 Response 对象的转化。
  • 加载和管理 Servlet,以及具体处理 Request 请求。

因此 Tomcat 设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。

连接器

Tomcat 的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用 ProtocolHandler 接口来封装通信协议和 I/O 模型的差异,ProtocolHandler 内部又分为 Endpoint 和 Processor 模块,Endpoint 负责底层 Socket 通信,Processor 负责应用层协议解析。连接器通过适配器 Adapter 调用容器。

容器

Tomcat 设计了 4 种容器,分别是 Engine、Host、Context 和 Wrapper。这 4 种容器不是平行关系,而是父子关系。下面我画了一张图帮你理解它们的关系。

Context 表示一个 Web 应用程序;Wrapper 表示一个 Servlet,一个 Web 应用程序中可能会有多个 Servlet;Host 代表的是一个虚拟主机,或者说一个站点,可以给 Tomcat 配置多个虚拟主机地址,而一个虚拟主机下可以部署多个 Web 应用程序;Engine 表示引擎,用来管理多个虚拟站点,一个 Service 最多只能有一个 Engine。

通过 Tomcat 的server.xml配置文件来加深对 Tomcat 容器的理解

请求定位 Servlet 的过程

使用 Pipeline-Valve 管道(责任链模式),首先,根据协议和端口号选定 Service 和 Engine,然后,根据域名选定 Host,之后,根据 URL 路径找到 Context 组件,最后,根据 URL 路径找到 Wrapper(Servlet)。

通过对 Tomcat 整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。

Catalina

  • Catalina 完成两个功能
  • 解析 server.xml, 创建定义的各组件, 调用 server init 和 start 方法
  • 处理异常情况, 例如 ctrl c 关闭 Tomcat. 其会在 JVM 中注册"关闭钩子"
  • 关闭钩子, 在关闭 JVM 时做清理工作, 例如刷新缓存到磁盘
  • 关闭钩子是一个线程, JVM 停止前会执行器 run 方法, 该 run 方法调用 server stop 方法

Server组件

Server组件的具体实现类是StandardServer

  • 继承了 LifeCycleBase
  • 子组件是 Service, 需要管理其生命周期(调用其 LifeCycle 的方法), 用数组保存多个 Service 组件, 动态扩容数组来添加组件
  • 启动一个 socket Listen停止端口, Catalina 启动时, 调用 Server await 方法, 其创建 socket Listen 8005 端口, 并在死循环中等连接, 检查到 shutdown 命令, 调用 stop 方法
代码语言:javascript复制
@Override
public void addService(Service service) {
    service.setServer(this);
    synchronized (servicesLock) {
        //创建一个长度 1的新数组
        Service results[] = new Service[services.length   1];
        
        //将老的数据复制过去
        System.arraycopy(services, 0, results, 0, services.length);
        results[services.length] = service;
        services = results;
        //启动Service组件
        if (getState().isAvailable()) {
            try {
                service.start();
            } catch (LifecycleException e) {
                // Ignore
            }
        }
        //触发监听事件
        support.firePropertyChange("service", null, service);
    }
}

Service 组件

实现类 StandardService

  • 包含 Server, Connector, Engine 和 Mapper 组件的成员变量
  • 还包含 MapperListener 成员变量, 以支持热部署, 其Listen容器变化, 并更新 Mapper, 是观察者模式
  • 需注意各组件启动顺序, 根据其依赖关系确定
  • 先启动 Engine, 再启动 Mapper Listener, 最后启动连接器, 而停止顺序相反.
代码语言:javascript复制
public class StandardService extends LifecycleBase implements Service {
    //名字
    private String name = null;
    
    //Server实例
    private Server server = null;
    //连接器数组
    protected Connector connectors[] = new Connector[0];
    private final Object connectorsLock = new Object();
    //对应的Engine容器
    private Engine engine = null;
    
    //映射器及其监听器
    protected final Mapper mapper = new Mapper();
    protected final MapperListener mapperListener = new MapperListener(this);

作为“管理”角色的组件,最重要的是维护其他组件的生命周期。此外在启动各种组件时,要注意它们的依赖关系,也就是说,要注意启动的顺序。我们来看看 Service 启动方法:

代码语言:javascript复制
protected void startInternal() throws LifecycleException {
    //1. 触发启动监听器
    setState(LifecycleState.STARTING);
    //2. 先启动Engine,Engine会启动它子容器
    if (engine != null) {
        synchronized (engine) {
            engine.start();
        }
    }
    
    //3. 再启动Mapper监听器
    mapperListener.start();
    //4.最后启动连接器,连接器会启动它子组件,比如Endpoint
    synchronized (connectorsLock) {
        for (Connector connector: connectors) {
            if (connector.getState() != LifecycleState.FAILED) {
                connector.start();
            }
        }
    }
}

Engine 组件

Engine 组件, 实现类 StandEngine 继承 ContainerBase

  • ContainerBase 实现了维护子组件的逻辑, 用 HaspMap 保存子组件, 因此各层容器可重用逻辑
  • ContainerBase 用专门线程池启动子容器, 并负责子组件启动/停止, "增删改查"
  • 请求到达 Engine 之前, Mapper 通过 URL 定位了容器, 并存入 Request 中. Engine 从 Request 取出 Host 子容器, 并调用其 pipeline 的第一个 valve
代码语言:javascript复制

final class StandardEngineValve extends ValveBase {
    public final void invoke(Request request, Response response)
      throws IOException, ServletException {
  
      //拿到请求中的Host容器
      Host host = request.getHost();
      if (host == null) {
          return;
      }
  
      // 调用Host容器中的Pipeline中的第一个Valve
      host.getPipeline().getFirst().invoke(request, response);
  }
  
}

Host容器

Tomcat 的热加载和热部署:热加载的粒度比较小,主要是针对类文件的更新,通过创建新的类加载器来实现重新加载。而热部署是针对整个 Web 应用的,Tomcat 会将原来的 Context 对象整个销毁掉,再重新创建 Context 容器对象。

热加载和热部署的实现都离不开后台线程的周期性检查,Tomcat 在基类 ContainerBase 中统一实现了后台线程的处理逻辑,并在顶层容器 Engine 启动后台线程,这样子容器组件甚至各种通用组件都不需要自己去创建后台线程,这样的设计显得优雅整洁。

Context容器

Tomcat 正是通过 Context 组件来加载管理 Web 应用的。

Tomcat 的类加载器

双亲委托机制是为了保证一个 Java 类在 JVM 中是唯一的,假如你不小心写了一个与 JRE 核心类同名的类,比如 Object 类,双亲委托机制能保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

Tomcat 的自定义类加载器 WebAppClassLoader 打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。 具体实现就是重写 ClassLoader 的两个方法:findClass 和 loadClass。

findClass 方法:

先在 Web 应用本地目录下查找要加载的类。如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器 AppClassLoader。如何父加载器也没找到这个类,抛出 ClassNotFound 异常

代码语言:javascript复制
public Class<?> findClass(String name) throws ClassNotFoundException {
    ...
    
    Class<?> clazz = null;
    try {
            //1. 先在Web应用目录下查找类 
            clazz = findClassInternal(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    if (clazz == null) {
    try {
            //2. 如果在本地目录没有找到,交给父加载器去查找
            clazz = super.findClass(name);
    }  catch (RuntimeException e) {
           throw e;
       }
    
    //3. 如果父类也没找到,抛出ClassNotFoundException
    if (clazz == null) {
        throw new ClassNotFoundException(name);
     }
    return clazz;
}

loadClass 方法

Tomcat 的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,为了避免本地目录下的类覆盖 JRE 的核心类,先尝试用 JVM 扩展类加载器 ExtClassLoader 去加载。那为什么不先用系统类加载器 AppClassLoader 去加载?很显然,如果是这样的话,那就变成双亲委托机制了,这就是 Tomcat 类加载器的巧妙之处。

代码语言:javascript复制
public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
 
        Class<?> clazz = null;
        //1. 先在本地cache查找该类是否已经加载过
        clazz = findLoadedClass0(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
        //2. 从系统类加载器的cache中查找是否加载过
        clazz = findLoadedClass(name);
        if (clazz != null) {
            if (resolve)
                resolveClass(clazz);
            return clazz;
        }
        // 3. 尝试用ExtClassLoader类加载器类加载,为什么?
        ClassLoader javaseLoader = getJavaseClassLoader();
        try {
            clazz = javaseLoader.loadClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }
        // 4. 尝试在本地目录搜索class并加载
        try {
            clazz = findClass(name);
            if (clazz != null) {
                if (resolve)
                    resolveClass(clazz);
                return clazz;
            }
        } catch (ClassNotFoundException e) {
            // Ignore
        }
        // 5. 尝试用系统类加载器(也就是AppClassLoader)来加载
            try {
                clazz = Class.forName(name, false, parent);
                if (clazz != null) {
                    if (resolve)
                        resolveClass(clazz);
                    return clazz;
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }
       }
    
    //6. 上述过程都加载失败,抛出异常
    throw new ClassNotFoundException(name);
}

Tomcat类加载隔离

Tomcat 的 Context 组件为每个 Web 应用创建一个 WebAppClassLoader 类加载器,由于不同类加载器实例加载的类是互相隔离的,因此达到了隔离 Web 应用的目的,同时通过 CommonClassLoader 等父加载器来共享第三方 JAR 包。而共享的第三方 JAR 包怎么加载特定 Web 应用的类呢?可以通过设置线程上下文加载器来解决。而作为 Java 程序员,我们应该牢记的是:

  • 每个 Web 应用自己的 Java 类文件和依赖的 JAR 包,分别放在WEB-INF/classes和WEB-INF/lib目录下面。
  • 多个应用共享的 Java 类文件和 JAR 包,分别放在 Web 容器指定的共享目录下。
  • 当出现 ClassNotFound 错误时,应该检查你的类加载器是否正确。
Tomcat如何实现Servlet规范

Context 容器并不直接持有 Servlet 实例,而是通过子容器 Wrapper 来管理 Servlet类实例以及它相关的配置信息,比如它的 URL 映射、它的初始化参数等。

Wrapper 通过 loadServlet 方法来实例化 Servlet:

代码语言:javascript复制
public synchronized Servlet loadServlet() throws ServletException {
    Servlet servlet;
  
    //1. 创建一个Servlet实例
    servlet = (Servlet) instanceManager.newInstance(servletClass);    
    
    //2.调用了Servlet的init方法,这是Servlet规范要求的
    initServlet(servlet);
    
    return servlet;
}

其实 loadServlet 主要做了两件事:创建 Servlet 的实例,并且调用 Servlet 的 init 方法,因为这是 Servlet 规范要求的。那接下来的问题是,什么时候会调到这个 loadServlet 方法呢?为了加快系统的启动速度,我们往往会采取资源延迟加载的策略,Tomcat 也不例外,默认情况下 Tomcat 在启动时不会加载你的 Servlet,除非你把 Servlet 的loadOnStartup参数设置为true。

当请求到来时,Context 容器的 BasicValve 会调用 Wrapper 容器中 Pipeline 中的第一个 Valve,然后会调用到 StandardWrapperValve的 invoke 方法:

代码语言:javascript复制

public final void invoke(Request request, Response response) {
    //1.实例化Servlet
    servlet = wrapper.allocate();
   
    //2.给当前请求创建一个Filter链
    ApplicationFilterChain filterChain =
        ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
   //3. 调用这个Filter链,Filter链中的最后一个Filter会调用Servlet
   filterChain.doFilter(request.getRequest(), response.getResponse());
}

请求过来时会先执行所有的Filter,调用 Filter 链的 doFilter 方法,直到最后一个Filter调用完毕后才调用 Servlet 的 service 方法。

代码语言:javascript复制
public final class ApplicationFilterChain implements FilterChain {
  
  //Filter链中有Filter数组,这个好理解
  private ApplicationFilterConfig[] filters = new ApplicationFilterConfig[0];
    
  //Filter链中的当前的调用位置
  private int pos = 0;
    
  //总共有多少了Filter
  private int n = 0;
  //每个Filter链对应一个Servlet,也就是它要调用的Servlet
  private Servlet servlet = null;
  
  public void doFilter(ServletRequest req, ServletResponse res) {
        internalDoFilter(request,response);
  }
   
  private void internalDoFilter(ServletRequest req,
                                ServletResponse res){
    // 每个Filter链在内部维护了一个Filter数组
    if (pos < n) {
        ApplicationFilterConfig filterConfig = filters[pos  ];
        Filter filter = filterConfig.getFilter();
        filter.doFilter(request, response, this);
        return;
    }
    servlet.service(request, response);
   
}

Servlet 规范中最重要的就是 Servlet、Filter 和 Listener“三兄弟”。Web 容器最重要的职能就是把它们创建出来,并在适当的时候调用它们的方法。Tomcat 通过 Wrapper 容器来管理 Servlet,Wrapper 包装了 Servlet 本身以及相应的参数,这体现了面向对象中“封装”的设计原则。Tomcat 会给每个请求生成一个 Filter 链,Filter 链中的最后一个 Filter 会负责调用 Servlet 的 service 方法。对于 Listener 来说,我们可以定制自己的监听器来监听 Tomcat 内部发生的各种事件:包括 Web 应用级别的、Session 级别的和请求级别的。Tomcat 中的 Context 容器统一维护了这些监听器,并负责触发。

Tomcat的线程池

Tomcat 扩展了 Java 线程池的核心类 ThreadPoolExecutor,并重写了它的 execute 方法,定制了自己的任务处理流程。同时 Tomcat 还实现了定制版的任务队列,重写了 offer 方法,使得在任务队列长度无限制的情况下,线程池仍然有机会创建新的线程。

Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。那具体如何实现呢,其实很简单,我们来看一下 Tomcat 线程池的 execute 方法的核心代码。

代码语言:javascript复制
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
  
  ...
  
  public void execute(Runnable command, long timeout, TimeUnit unit) {
      submittedCount.incrementAndGet();
      try {
          //调用Java原生线程池的execute去执行任务
          super.execute(command);
      } catch (RejectedExecutionException rx) {
         //如果总线程数达到maximumPoolSize,Java原生线程池执行拒绝策略
          if (super.getQueue() instanceof TaskQueue) {
              final TaskQueue queue = (TaskQueue)super.getQueue();
              try {
                  //继续尝试把任务放到任务队列中去
                  if (!queue.force(command, timeout, unit)) {
                      submittedCount.decrementAndGet();
                      //如果缓冲队列也满了,插入失败,执行拒绝策略。
                      throw new RejectedExecutionException("...");
                  }
              } 
          }
      }
}

定制版的任务队列

TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程。那什么是合适的时机呢?请看下面 offer 方法的核心源码:

代码语言:javascript复制
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
  ...
   @Override
  //线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
  public boolean offer(Runnable o) {
      //如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
      if (parent.getPoolSize() == parent.getMaximumPoolSize()) 
          return super.offer(o);
          
      //执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
      //表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
      
      //1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
      if (parent.getSubmittedCount()<=(parent.getPoolSize())) 
          return super.offer(o);
          
      //2. 如果已提交的任务数大于当前线程数,线程不够用了,返回false去创建新线程
      if (parent.getPoolSize()<parent.getMaximumPoolSize()) 
          return false;
          
      //默认情况下总是把任务添加到任务队列
      return super.offer(o);
  }
  
}

Tomcat的Session管理

Servlet 规范中定义了 HttpServletRequest 和 HttpSession 接口,Tomcat 实现了这些接口,但具体实现细节并没有暴露给开发者,因此定义了两个包装类,RequestFacade 和 StandardSessionFacade。Tomcat 是通过 Manager 来管理 Session 的,默认实现是 StandardManager。StandardContext 持有 StandardManager 的实例,并存放了 HttpSessionListener 集合,Session 在创建和销毁时,会通知监听器。

如果想了解更多文章,关注一下公众号,点赞和转发。

0 人点赞