容器
请求定位 Servlet 的过程
一个请求是如何定位到让哪个 Wrapper
的 Servlet
处理的?答案是,Tomcat 是用 Mapper 组件来完成这个任务的。
Mapper
组件的功能就是将用户请求的 URL
定位到一个 Servlet
,它的工作原理是:Mapper
组件里保存了 Web 应用的配置信息,其实就是容器组件与访问路径的映射关系,比如 Host
容器里配置的域名、Context
容器里的 Web
应用路径,以及 Wrapper
容器里 Servlet
映射的路径,你可以想象这些配置信息就是一个多层次的 Map
。
当一个请求到来时,Mapper
组件通过解析请求 URL 里的域名和路径,再到自己保存的 Map 里去查找,就能定位到一个 Servlet
。请你注意,一个请求 URL 最后只会定位到一个 Wrapper
容器,也就是一个 Servlet
。
假如有用户访问一个 URL,比如图中的http://user.shopping.com:8080/order/buy
,Tomcat 如何将这个 URL 定位到一个 Servlet 呢?
- 首先根据协议和端口号确定 Service 和 Engine。Tomcat 默认的 HTTP 连接器监听 8080 端口、默认的 AJP 连接器监听 8009 端口。上面例子中的 URL 访问的是 8080 端口,因此这个请求会被 HTTP 连接器接收,而一个连接器是属于一个 Service 组件的,这样 Service 组件就确定了。我们还知道一个 Service 组件里除了有多个连接器,还有一个容器组件,具体来说就是一个 Engine 容器,因此 Service 确定了也就意味着 Engine 也确定了。
- 根据域名选定 Host。 Service 和 Engine 确定后,Mapper 组件通过 URL 中的域名去查找相应的 Host 容器,比如例子中的 URL 访问的域名是
user.shopping.com
,因此 Mapper 会找到 Host2 这个容器。
- 根据 URL 路径找到 Context 组件。 Host 确定以后,Mapper 根据 URL 的路径来匹配相应的 Web 应用的路径,比如例子中访问的是 /order,因此找到了 Context4 这个 Context 容器。
- 根据 URL 路径找到 Wrapper(Servlet)。 Context 确定后,Mapper 再根据 web.xml 中配置的 Servlet 映射路径来找到具体的 Wrapper 和 Servlet。
连接器中的 Adapter 会调用容器的 Service 方法来执行 Servlet,最先拿到请求的是 Engine 容器,Engine 容器对请求做一些处理后,会把请求传给自己子容器 Host 继续处理,依次类推,最后这个请求会传给 Wrapper 容器,Wrapper 会调用最终的 Servlet 来处理。那么这个调用过程具体是怎么实现的呢?答案是使用 Pipeline-Valve 管道。
Pipeline-Valve
是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处理,每个处理者负责做自己相应的处理,处理完之后将再调用下一个处理者继续处理,Valve 表示一个处理点(也就是一个处理阀门),因此 invoke
方法就是来处理请求的。
public interface Valve { public Valve getNext(); public void setNext(Valve valve); public void invoke(Request request, Response response) }
继续看 Pipeline 接口
public interface Pipeline { public void addValve(Valve valve); public Valve getBasic(); public void setBasic(Valve valve); public Valve getFirst(); }
Pipeline
中有 addValve
方法。Pipeline 中维护了 Valve
链表,Valve
可以插入到 Pipeline
中,对请求做某些处理。我们还发现 Pipeline 中没有 invoke 方法,因为整个调用链的触发是 Valve 来完成的,Valve
完成自己的处理后,调用 getNext.invoke()
来触发下一个 Valve 调用。
其实每个容器都有一个 Pipeline 对象,只要触发了这个 Pipeline 的第一个 Valve,这个容器里 Pipeline
中的 Valve 就都会被调用到。但是,不同容器的 Pipeline 是怎么链式触发的呢,比如 Engine 中 Pipeline 需要调用下层容器 Host 中的 Pipeline。
这是因为 Pipeline
中还有个 getBasic
方法。这个 BasicValve
处于 Valve
链表的末端,它是 Pipeline
中必不可少的一个 Valve
,负责调用下层容器的 Pipeline 里的第一个 Valve。
整个过程分是通过连接器中的 CoyoteAdapter
触发,它会调用 Engine 的第一个 Valve:
@Override public void service(org.apache.coyote.Request req, org.apache.coyote.Response res) { // 省略其他代码 // Calling the container connector.getService().getContainer().getPipeline().getFirst().invoke( request, response); ... }
Wrapper 容器的最后一个 Valve 会创建一个 Filter 链,并调用 doFilter()
方法,最终会调到 Servlet
的 service
方法。
前面我们不是讲到了 Filter
,似乎也有相似的功能,那 Valve
和 Filter
有什么区别吗?它们的区别是:
Valve
是Tomcat
的私有机制,与 Tomcat 的基础架构API
是紧耦合的。Servlet API
是公有的标准,所有的 Web 容器包括 Jetty 都支持 Filter 机制。
- 另一个重要的区别是
Valve
工作在 Web 容器级别,拦截所有应用的请求;而Servlet Filter
工作在应用级别,只能拦截某个Web
应用的所有请求。如果想做整个Web
容器的拦截器,必须通过Valve
来实现。
Lifecycle 生命周期
前面我们看到 Container
容器 继承了 Lifecycle
生命周期。如果想让一个系统能够对外提供服务,我们需要创建、组装并启动这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是说,Tomcat 需要动态地管理这些组件的生命周期。
如何统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组件?如何做到组件启动和停止不遗漏、不重复?
一键式启停:LifeCycle 接口
设计就是要找到系统的变化点和不变点。这里的不变点就是每个组件都要经历创建、初始化、启动这几个过程,这些状态以及状态的转化是不变的。而变化点是每个具体组件的初始化方法,也就是启动方法是不一样的。
因此,Tomcat 把不变点抽象出来成为一个接口,这个接口跟生命周期有关,叫作 LifeCycle。LifeCycle 接口里定义这么几个方法:init()、start()、stop() 和 destroy()
,每个具体的组件(也就是容器)去实现这些方法。
在父组件的 init()
方法里需要创建子组件并调用子组件的 init()
方法。同样,在父组件的 start()
方法里也需要调用子组件的 start()
方法,因此调用者可以无差别的调用各组件的 init()
方法和 start()
方法,这就是组合模式的使用,并且只要调用最顶层组件,也就是 Server 组件的 init()
和start()
方法,整个 Tomcat 就被启动起来了。所以 Tomcat 采取组合模式管理容器,容器继承 LifeCycle 接口,这样就可以向针对单个对象一样一键管理各个容器的生命周期,整个 Tomcat 就启动起来。
可扩展性:LifeCycle 事件
我们再来考虑另一个问题,那就是系统的可扩展性。因为各个组件init()
和 start()
方法的具体实现是复杂多变的,比如在 Host 容器的启动方法里需要扫描 webapps 目录下的 Web 应用,创建相应的 Context 容器,如果将来需要增加新的逻辑,直接修改start()
方法?这样会违反开闭原则,那如何解决这个问题呢?开闭原则说的是为了扩展系统的功能,你不能直接修改系统中已有的类,但是你可以定义新的类。
组件的 init()
和 start()
调用是由它的父组件的状态变化触发的,上层组件的初始化会触发子组件的初始化,上层组件的启动会触发子组件的启动,因此我们把组件的生命周期定义成一个个状态,把状态的转变看作是一个事件。而事件是有监听器的,在监听器里可以实现一些逻辑,并且监听器也可以方便的添加和删除,这就是典型的观察者模式。
以下就是 Lyfecycle
接口的定义:
重用性:LifeCycleBase 抽象基类
再次看到抽象模板设计模式。
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
Tomcat 定义一个基类 LifeCycleBase 来实现 LifeCycle 接口,把一些公共的逻辑放到基类中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的初始化、启动和停止等方法。
public abstract class LifecycleBase implements Lifecycle{ // 持有所有的观察者 private final List<LifecycleListener> lifecycleListeners = new CopyOnWriteArrayList<>(); /** * 发布事件 * * @param type Event type * @param data Data associated with event. */ protected void fireLifecycleEvent(String type, Object data) { LifecycleEvent event = new LifecycleEvent(this, type, data); for (LifecycleListener listener : lifecycleListeners) { listener.lifecycleEvent(event); } } // 模板方法定义整个启动流程,启动所有容器 @Override public final synchronized void init() throws LifecycleException { //1. 状态检查 if (!state.equals(LifecycleState.NEW)) { invalidTransition(Lifecycle.BEFORE_INIT_EVENT); } try { //2. 触发 INITIALIZING 事件的监听器 setStateInternal(LifecycleState.INITIALIZING, null, false); // 3. 调用具体子类的初始化方法 initInternal(); // 4. 触发 INITIALIZED 事件的监听器 setStateInternal(LifecycleState.INITIALIZED, null, false); } catch (Throwable t) { ExceptionUtils.handleThrowable(t); setStateInternal(LifecycleState.FAILED, null, false); throw new LifecycleException( sm.getString("lifecycleBase.initFail",toString()), t); } } }
Tomcat 为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想和设计模式发挥到了极致,Containaer
接口维护了容器的父子关系,Lifecycle
组合模式实现组件的生命周期维护,生命周期每个组件有变与不变的点,运用模板方法模式。 分别运用了组合模式、观察者模式、骨架抽象类和模板方法。
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
观察者模式听起来 “高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。实现了低耦合、非侵入式的通知与更新机制。
Container
继承了 LifeCycle,StandardEngine、StandardHost、StandardContext 和 StandardWrapper 是相应容器组件的具体实现类,因为它们都是容器,所以继承了 ContainerBase 抽象基类,而 ContainerBase 实现了 Container 接口,也继承了 LifeCycleBase 类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中接口分离的原则。
Tomcat 为何打破双亲委派机制
双亲委派
我们知道 JVM
的类加载器加载 Class 的时候基于双亲委派机制,也就是会将加载交给自己的父加载器加载,如果 父加载器为空则查找Bootstrap
是否加载过,当无法加载的时候才让自己加载。JDK 提供一个抽象类 ClassLoader
,这个抽象类中定义了三个关键方法。对外使用loadClass(String name) 用于子类重写打破双亲委派:loadClass(String name, boolean resolve)
public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 查找该 class 是否已经被加载过 Class<?> c = findLoadedClass(name); // 如果没有加载过 if (c == null) { // 委托给父加载器去加载,递归调用 if (parent != null) { c = parent.loadClass(name, false); } else { // 如果父加载器为空,查找 Bootstrap 是否加载过 c = findBootstrapClassOrNull(name); } // 若果依然加载不到,则调用自己的 findClass 去加载 if (c == null) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } } protected Class<?> findClass(String name){ //1. 根据传入的类名 name,到在特定目录下去寻找类文件,把.class 文件读入内存 ... //2. 调用 defineClass 将字节数组转成 Class 对象 return defineClass(buf, off, len); } // 将字节码数组解析成一个 Class 对象,用 native 方法实现 protected final Class<?> defineClass(byte[] b, int off, int len){ ... }
JDK 中有 3 个类加载器,另外你也可以自定义类加载器,它们的关系如下图所示。
BootstrapClassLoader
是启动类加载器,由 C 语言实现,用来加载JVM
启动时所需要的核心类,比如rt.jar
、resources.jar
等。
ExtClassLoader
是扩展类加载器,用来加载\jre\lib\ext
目录下 JAR 包。
AppClassLoader
是系统类加载器,用来加载classpath
下的类,应用程序默认用它来加载类。
- 自定义类加载器,用来加载自定义路径下的类。
这些类加载器的工作原理是一样的,区别是它们的加载路径不同,也就是说 findClass
这个方法查找的路径不同。双亲委托机制是为了保证一个 Java 类在 JVM 中是唯一的,假如你不小心写了一个与 JRE 核心类同名的类,比如 Object
类,双亲委托机制能保证加载的是 JRE
里的那个 Object
类,而不是你写的 Object
类。这是因为 AppClassLoader
在加载你的 Object 类时,会委托给 ExtClassLoader
去加载,而 ExtClassLoader
又会委托给 BootstrapClassLoader
,BootstrapClassLoader
发现自己已经加载过了 Object
类,会直接返回,不会去加载你写的 Object
类。我们最多只能 获取到 ExtClassLoader
这里注意下。
Tomcat 热加载
Tomcat 本质是通过一个后台线程做周期性的任务,定期检测类文件的变化,如果有变化就重新加载类。我们来看 ContainerBackgroundProcessor
具体是如何实现的。
protected class ContainerBackgroundProcessor implements Runnable { @Override public void run() { // 请注意这里传入的参数是 " 宿主类 " 的实例 processChildren(ContainerBase.this); } protected void processChildren(Container container) { try { //1. 调用当前容器的 backgroundProcess 方法。 container.backgroundProcess(); //2. 遍历所有的子容器,递归调用 processChildren, // 这样当前容器的子孙都会被处理 Container[] children = container.findChildren(); for (int i = 0; i < children.length; i++) { // 这里请你注意,容器基类有个变量叫做 backgroundProcessorDelay,如果大于 0,表明子容器有自己的后台线程,无需父容器来调用它的 processChildren 方法。 if (children[i].getBackgroundProcessorDelay() <= 0) { processChildren(children[i]); } } } catch (Throwable t) { ... }
Tomcat 的热加载就是在 Context 容器实现,主要是调用了 Context 容器的 reload 方法。抛开细节从宏观上看主要完成以下任务:
- 停止和销毁 Context 容器及其所有子容器,子容器其实就是 Wrapper,也就是说 Wrapper 里面 Servlet 实例也被销毁了。
- 停止和销毁 Context 容器关联的 Listener 和 Filter。
- 停止和销毁 Context 下的 Pipeline 和各种 Valve。
- 停止和销毁 Context 的类加载器,以及类加载器加载的类文件资源。
- 启动 Context 容器,在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中,类加载器发挥着关键作用。一个 Context 容器对应一个类加载器,类加载器在销毁的过程中会把它加载的所有类也全部销毁。Context 容器在启动过程中,会创建一个新的类加载器来加载新的类文件。
Tomcat 的类加载器
Tomcat 的自定义类加载器 WebAppClassLoader
打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载 Web 应用自己定义的类。具体实现就是重写 ClassLoader
的两个方法:findClass
和 loadClass
。
findClass 方法
org.apache.catalina.loader.WebappClassLoaderBase#findClass
;为了方便理解和阅读,我去掉了一些细节:
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; }
- 先在 Web 应用本地目录下查找要加载的类。
- 如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器
AppClassLoader
。
- 如何父加载器也没找到这个类,抛出
ClassNotFound
异常。
loadClass 方法
再来看 Tomcat 类加载器的 loadClass
方法的实现,同样我也去掉了一些细节:
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); }
主要有六个步骤:
- 先在本地 Cache 查找该类是否已经加载过,也就是说 Tomcat 的类加载器是否已经加载过这个类。
- 如果 Tomcat 类加载器没有加载过这个类,再看看系统类加载器是否加载过。
- 如果都没有,就让ExtClassLoader去加载,这一步比较关键,目的 防止 Web 应用自己的类覆盖 JRE 的核心类。因为 Tomcat 需要打破双亲委托机制,假如 Web 应用里自定义了一个叫 Object 的类,如果先加载这个 Object 类,就会覆盖 JRE 里面的那个 Object 类,这就是为什么 Tomcat 的类加载器会优先尝试用
ExtClassLoader
去加载,因为ExtClassLoader
会委托给BootstrapClassLoader
去加载,BootstrapClassLoader
发现自己已经加载了 Object 类,直接返回给 Tomcat 的类加载器,这样 Tomcat 的类加载器就不会去加载 Web 应用下的 Object 类了,也就避免了覆盖 JRE 核心类的问题。
- 如果
ExtClassLoader
加载器加载失败,也就是说JRE
核心类中没有这类,那么就在本地 Web 应用目录下查找并加载。
- 如果本地目录下没有这个类,说明不是 Web 应用自己定义的类,那么由系统类加载器去加载。这里请你注意,Web 应用是通过
Class.forName
调用交给系统类加载器的,因为Class.forName
的默认加载器就是系统类加载器。
- 如果上述加载过程全部失败,抛出
ClassNotFound
异常。
Tomcat 类加载器层次
Tomcat 作为 Servlet
容器,它负责加载我们的 Servlet
类,此外它还负责加载 Servlet
所依赖的 JAR 包。并且 Tomcat
本身也是也是一个 Java 程序,因此它需要加载自己的类和依赖的 JAR 包。首先让我们思考这一下这几个问题:
- 假如我们在 Tomcat 中运行了两个 Web 应用程序,两个 Web 应用中有同名的
Servlet
,但是功能不同,Tomcat 需要同时加载和管理这两个同名的Servlet
类,保证它们不会冲突,因此 Web 应用之间的类需要隔离。
- 假如两个 Web 应用都依赖同一个第三方的 JAR 包,比如
Spring
,那Spring
的 JAR 包被加载到内存后,Tomcat
要保证这两个 Web 应用能够共享,也就是说Spring
的 JAR 包只被加载一次,否则随着依赖的第三方 JAR 包增多,JVM
的内存会膨胀。
- 跟 JVM 一样,我们需要隔离 Tomcat 本身的类和 Web 应用的类。
1. WebAppClassLoader
Tomcat 的解决方案是自定义一个类加载器 WebAppClassLoader
, 并且给每个 Web 应用创建一个类加载器实例。我们知道,Context 容器组件对应一个 Web 应用,因此,每个 Context
容器负责创建和维护一个 WebAppClassLoader
加载器实例。这背后的原理是,不同的加载器实例加载的类被认为是不同的类,即使它们的类名相同。这就相当于在 Java 虚拟机内部创建了一个个相互隔离的 Java 类空间,每一个 Web 应用都有自己的类空间,Web 应用之间通过各自的类加载器互相隔离。
2.SharedClassLoader
本质需求是两个 Web 应用之间怎么共享库类,并且不能重复加载相同的类。在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的加载路径下不就行了吗。
因此 Tomcat 的设计者又加了一个类加载器 SharedClassLoader
,作为 WebAppClassLoader
的父加载器,专门来加载 Web 应用之间共享的类。如果 WebAppClassLoader
自己没有加载到某个类,就会委托父加载器 SharedClassLoader
去加载这个类,SharedClassLoader
会在指定目录下加载共享类,之后返回给 WebAppClassLoader
,这样共享的问题就解决了。
3. CatalinaClassloader
如何隔离 Tomcat 本身的类和 Web 应用的类?
要共享可以通过父子关系,要隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,基于此 Tomcat 又设计一个类加载器 CatalinaClassloader
,专门来加载 Tomcat 自身的类。
这样设计有个问题,那 Tomcat 和各 Web 应用之间需要共享一些类时该怎么办呢?
老办法,还是再增加一个 CommonClassLoader
,作为 CatalinaClassloader
和 SharedClassLoader
的父加载器。CommonClassLoader
能加载的类都可以被 CatalinaClassLoader
和 SharedClassLoader
使用