一、整体架构
通过对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。
1.功能架构
1.server功能结构
server.xml的整体结构如下:
<Server>
<Service>
<Connector />
<Connector />
<Engine>
<Host>
<Context /><!-- 现在常常使用自动部署,不推荐配置Context元素,Context小节有详细说明 -->
</Host>
</Engine>
</Service>
</Server>
而对应的结构体系可以参考下图
Server
Server是Tomcat中最顶层的组件,它可以包含多个Service组件。
在Tomcat源代码中Server组件对应源码中的 org.apache.catalina.core.StandardServer 类。
Service
接下来咋们来看看Service组件,Service组件相当于Connetor和Engine组件的包装器,它将一个或者多个Connector组件和一个Engine建立关联。
Connector
既然Tomcat需要提供http服务,而我们知道http应用层协议最终都是需要通过TCP层的协议进行传递的,而Connector正是Tomcat中监听TCP网络连接的组件,一个Connector会监听一个独立的端口来处理来自客户端的连接。
Connector的主要功能,是接收连接请求,创建Request和Response对象用于和请求端交换数据;
然后分配线程让Engine来处理这个请求,并把产生的Request和Response对象传给Engine,当Engine处理完请求后,也会通过Connector将响应返回给客户端。
而关于Connector内部的组件,我们会在下面的连接器章节中会细讲。
Engine
Tomcat中有一个容器的概念,而Engine,Host,Context都属于Contanier,我们先来说说最顶层的容器Engine.
一个Engine可以包含一个或者多个Host,也就是说我们一个Tomcat的实例可以配置多个虚拟主机。
Engine组件在Service组件中有且只有一个;Engine是Service组件中的请求处理组件,Engine组件从一个或多个Connector中接收请求并处理,并将完成的响应返回给Connector,最终传递给客户端。
前面已经提到过,Engine、Host和Context都是容器,但它们不是平行的关系,而是父子关系:Engine包含Host,Host包含Context。
而关于Engine内部的组件,我们会在下面的容器章节中会细讲。
2.Service功能结构
我们知道如果要设计一个系统,首先是要了解需求。
我们已经了解了Tomcat要实现2个核心功能:
-
处理Socket连接,负责网络字节流与Request和Response对象的转化。
-
加载和管理Servlet,以及具体处理Request请求。
因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。
连接器负责对外交流,容器负责内部处理,具体来说就是,连接器处理Socket通信和应用层协议的解析,得到Servlet请求;而容器则负责处理Servlet请求。
所以连接器和容器可以说是Tomcat架构里最重要的两部分,需要你花些精力理解清楚。
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。
但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件。这里请你注意,Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起。
Tomcat内可能有多个Service,这样的设计也是出于灵活性的考虑。通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
到此我们得到这样一张关系图:
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。
一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器,连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
2.应用层协议
Tomcat支持的应用层协议有:
-
HTTP/1.1:这是大部分Web应用采用的访问协议。
-
AJP:用于和Web服务器集成(如Apache)。
-
HTTP/2:HTTP 2.0大幅度的提升了Web性能。
3.I/O模型
I/O模型的本质就是为了缓解CPU和外设之间的速度差。当线程发起I/O请求时,比如读写网络数据,网卡数 据还没准备好,这个线程就会被阻塞,让出CPU,也就是说发生了线程切换。而线程切换是无用功,并且线 程被阻塞后,它持有内存资源并没有释放,阻塞的线程越多,消耗的内存就越大,因此I/O模型的目标就是 尽量减少线程阻塞。
Tomcat和Jetty都已经抛弃了传统的同步阻塞I/O,采用了非阻塞I/O或者异步I/O,目的 是业务线程不需要阻塞在I/O等待上。
Tomcat支持的I/O模型有:
- BIO:阻塞I/O。
-
NIO:非阻塞I/O,采用Java NIO类库实现。
-
NIO2:异步I/O,采用JDK 7最新的NIO2类库实现。
-
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
在Tomcat 8及更高版本中,Tomcat的默认IO模型是NIO,即非阻塞IO。
在Tomcat7及更早版本默认使用的BIO模型。
在Tomcat的server.xml配置文件中,你可以通过Connector元素的protocol属性来指定IO模型,例如:
<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol"
connectionTimeout="20000"
redirectPort="8443" />
4.线程模型
除了I/O模型,线程模型也是影响性能和并发的关键点。
Tomcat总体处理原则是:
- 连接请求由专门的Acceptor线程组处理。
- I/O事件侦测也由专门的Selector线程组来处理。
- 具体的协议解析和业务处理可能交给线程池(Tomcat)
将这些事情分开的好处是解耦,并且可以根据实际情况合理设置各部分的线程数。这里请你注意,线程数并 不是越多越好,因为CPU核的个数有限,线程太多也处理不过来,会导致大量的线程上下文切换。
5.Servlet协议
我们知道,Servlet容器最重要的任务就是创建Servlet的实例并且调用Servlet,在前面我谈到了Tomcat 如何定义自己的类加载器来加载Servlet,但加载Servlet的类不等于创建Servlet的实例,类加载只是第一 步,类加载好了才能创建类的实例,也就是说Tomcat先加载Servlet的类,然后在Java堆上创建了一个 Servlet实例。
一个Web应用里往往有多个Servlet,而在Tomcat中一个Web应用对应一个Context容器,也就是说一个 Context容器需要管理多个Servlet实例。但Context容器并不直接持有Servlet实例,而是通过子容器Wrapper 来管理Servlet,你可以把Wrapper容器看作是Servlet的包装。
那为什么需要Wrapper呢?Context容器直接维护一个Servlet数组不就行了吗?
这是因为Servlet不仅仅是一 个类实例,它还有相关的配置信息,比如它的URL映射、它的初始化参数,因此设计出了一个包装器,把 Servlet本身和它相关的数据包起来,没错,这就是面向对象的思想。
那管理好Servlet就完事大吉了吗?
Servlet规范中最重要的就是Servlet、Filter和Listener“三兄弟”。Web容器最重要的职能就是把它们创建出 来,并在适当的时候调用它们的方法。
1.Servlet实例创建
Tomcat是用Wrapper容器来管理Servlet的,那Wrapper容器具体长什么样子呢?
我们先来看看 它里面有哪些关键的成员变量:
毫无悬念,它拥有一个Servlet实例,并且Wrapper通过loadServlet方法来实例化Servlet。为了方便你阅读, 我简化了代码
其实loadServlet主要做了两件事:创建Servlet的实例,并且调用Servlet的init方法,因为这是Servlet规范要 求的。
那接下来的问题是,什么时候会调到这个loadServlet方法呢?
为了加快系统的启动速度,我们往往会采取资 源延迟加载的策略,Tomcat也不例外,默认情况下Tomcat在启动时不会加载你的Servlet,除非你把Servlet 的loadOnStartup参数设置为true
这里还需要你注意的是,虽然Tomcat在启动时不会创建Servlet实例,但是会创建Wrapper容器,就好比尽 管枪里面还没有子弹,先把枪造出来。那子弹什么时候造呢?是真正需要开枪的时候,也就是说有请求来访 问某个Servlet时,这个Servlet的实例才会被创建。
那Servlet是被谁调用的呢?
我们回忆一下专栏前面提到过Tomcat的Pipeline-Valve机制,每个容器组件都有 自己的Pipeline,每个Pipeline中有一个Valve链,并且每个容器组件有一个BasicValve(基础阀)。 Wrapper作为一个容器组件,它也有自己的Pipeline和BasicValve,Wrapper的BasicValve叫 StandardWrapperValve。 你可以想到,当请求到来时,Context容器的BasicValve会调用Wrapper容器中Pipeline中的第一个Valve,然 后会调用到StandardWrapperValve。
我们先来看看它的invoke方法是如何实现的,同样为了方便你阅读, 我简化了代码:
StandardWrapperValve的invoke方法比较复杂,去掉其他异常处理的一些细节,本质上就是上面三步
2.Filter过滤器链
你可能会问,为什么需要给每个请求创建一个Filter链?
这是因为每个请求的请求路径都不一样,而Filter都 有相应的路径映射,因此不是所有的Filter都需要来处理当前的请求,我们需要根据请求的路径来选择特定 的一些Filter来处理。
第二个问题是,为什么没有看到调到Servlet的service方法?
这是因为Filter链的doFilter方法会负责调用 Servlet,具体来说就是Filter链中的最后一个Filter会负责调用Servlet。
接下来我们来看Filter的实现原理。
我们知道,跟Servlet一样,Filter也可以在web.xml文件里进行配置,不同的是,Filter的作用域是整个Web 应用,因此Filter的实例是在Context容器中进行管理的,Context容器用Map集合来保存Filter。
那上面提到的Filter链又是什么呢?
Filter链的存活期很短,它是跟每个请求对应的。一个新的请求来了,就 动态创建一个FIlter链,请求处理完了,Filter链也就被回收了。
理解它的原理也非常关键,我们还是来看看 源码:
从ApplicationFilterChain的源码我们可以看到几个关键信息:
1. Filter链中除了有Filter对象的数组,还有一个整数变量pos,这个变量用来记录当前被调用的Filter在数组 中的位置。
2. Filter链中有个Servlet实例,这个好理解,因为上面提到了,每个Filter链最后都会调到一个Servlet。
3. Filter链本身也实现了doFilter方法,直接调用了一个内部方法internalDoFilter。
4. internalDoFilter方法的实现比较有意思,它做了一个判断,如果当前Filter的位置小于Filter数组的长度, 也就是说Filter还没调完,就从Filter数组拿下一个Filter,调用它的doFilter方法。否则,意味着所有Filter 都调到了,就调用Servlet的service方法
所以Tomcat会给每个请求生成一个Filter链,Filter链中的最后一个Filter会负责调用Servlet的service方法。
但问题是,方法体里没看到循环,谁在不停地调用Filter链的doFIlter方法呢?Filter是怎么依次调到的呢?
答案是Filter本身的doFilter方法会调用Filter链的doFilter方法,我们还是来看看代码就明白了:
注意Filter的doFilter方法有个关键参数FilterChain,就是Filter链。
每个Filter在实现doFilter时(自定义实现),必须要 调用Filter链的doFilter方法,而Filter链中保存当前FIlter的位置,会调用下一个FIlter的doFilter方法,这样 链式调用就完成了。
Filter链跟Tomcat的Pipeline-Valve本质都是责任链模式,但是在具体实现上稍有不同,你可以细细体会一 下。
从这里我们还可以得出一个结论:过滤器是要比spirngMvc中的拦截器先执行的,因为拦截器是在DisPatcherServlet层级别的。
然后我们在总结一下过滤器(Filter)、拦截器(Interceptor)和面向切面编程(AOP)的执行顺序
首先,请求到达Servlet容器,触发过滤器(Filter)。
然后,请求被DispatcherServlet捕获,触发拦截器(Interceptor)。
接着,如果定义了AOP切面,它们会在相应的时机(如方法执行前或后)被触发。
最后,请求到达控制器(Controller),执行相应的业务逻辑。
请注意,这个顺序可能因具体的配置和使用的技术而有所不同。在实际应用中,建议根据项目的具体需求来合理配置和使用这些组件。
3.Listener监听器
我们接着聊Servlet规范里Listener。跟Filter一样,Listener也是一种扩展机制,你可以监听容器内部发生的 事件,主要有两类事件:
- 第一类是生命状态的变化,比如Context容器启动和停止、Session的创建和销毁。
- 第二类是属性的变化,比如Context容器某个属性值变了、Session的某个属性值变了以及新的请求来了等。
我们可以在web.xml配置或者通过注解的方式来添加监听器,在监听器里实现我们的业务逻辑。对于 Tomcat来说,它需要读取配置文件,拿到监听器类的名字,实例化这些类,并且在合适的时机调用这些监 听器的方法。
Tomcat是通过Context容器来管理这些监听器的。Context容器将两类事件分开来管理,分别用不同的集合 来存放不同类型事件的监听器:
剩下的事情就是触发监听器了,比如在Context容器的启动方法里,就触发了所有的 ServletContextListener:
需要注意的是,这里的ServletContextListener接口是一种留给用户的扩展机制,用户可以实现这个接口来 定义自己的监听器,监听Context容器的启停事件,Spring就是这么做的。
ServletContextListener跟Tomcat 自己的生命周期事件LifecycleListener是不同的,LifecycleListener定义在生命周期管理组件中,由基类 LifeCycleBase统一管理。
4.Servlet实例销毁
这里原理比较简单,当请求处理完成之后直接调用实现Servlet接口的destroy方法。
6.异步Servlet
我们知道,当一个新的请求到达时,Tomcat和Jetty会从线程池里拿出一个线程来处理 请求,这个线程会调用你的Web应用,Web应用在处理请求的过程中,Tomcat线程会一直阻塞,直到Web 应用处理完毕才能再输出响应,最后Tomcat才回收这个线程。
我们来思考这样一个问题,假如你的Web应用需要较长的时间来处理请求(比如数据库查询或者等待下游的 服务调用返回),那么Tomcat线程一直不回收,会占用系统资源,在极端情况下会导致“线程饥饿”,也 就是说Tomcat和Jetty没有更多的线程来处理新的请求。
那该如何解决这个问题呢?
方案是Servlet 3.0中引入的异步Servlet。主要是在Web应用里启动一个单独的线 程来执行这些比较耗时的请求,而Tomcat线程立即返回,不再等待Web应用将请求处理完,这样Tomcat线 程可以立即被回收到线程池,用来响应其他请求,降低了系统的资源消耗,同时还能提高系统的吞吐量。
1.异步Servlet场景
我们先通过一个简单的示例来了解一下异步Servlet的实现。
上面的代码有三个要点:
1. 通过注解的方式来注册Servlet,除了@WebServlet注解,还需要加上asyncSupported=true的属性,表明 当前的Servlet是一个异步Servlet。
2. Web应用程序需要调用Request对象的startAsync方法来拿到一个异步上下文AsyncContext。这个上下文 保存了请求和响应对象。
3. Web应用需要开启一个新线程来处理耗时的操作,处理完成后需要调用AsyncContext的complete方法。 目的是告诉Tomcat,请求已经处理完成。
这里请你注意,虽然异步Servlet允许用更长的时间来处理请求,但是也有超时限制的,默认是30秒,如果 30秒内请求还没处理完,Tomcat会触发超时机制,向浏览器返回超时错误,如果这个时候你的Web应用再 调用ctx.complete方法,会得到一个IllegalStateException异常。
那什么样的场景适合异步Servlet呢?
适合的场景有很多,最主要的还是根据你的实际情况,如果你拿不准 是否适合异步Servlet,就看一条:如果你发现Tomcat的线程不够了,大量线程阻塞在等待Web应用的处理 上,而Web应用又没有优化的空间了,确实需要长时间处理,这个时候你不妨尝试一下异步Servlet。
但异步servlet只能说让tomcat有机会接受更多的请求,但并不能提升服务的并发吞吐量,因为如 果业务操作本身还是慢的话,业务线程池仍然会被占满,后面提交的任务会等待。还有就是业务处理一般阻塞在io等待上,越是IO密集型应用,越需要配置更多线程。
2.异步Servlet原理
通过上面的例子,相信你对Servlet的异步实现有了基本的理解。
我通过一张在帮你理解一下整个过程:
非阻塞I/O模型可以利用很少的线程处理大量的连接,提高了并发度,本质就是通过一个Selector线程查询 多个Socket的I/O事件,减少了线程的阻塞等待。 同样,异步Servlet机制也是减少了线程的阻塞等待,将Tomcat线程和业务线程分开,Tomca线程不再等待 业务代码的执行。
要理解Tomcat在这个过程都做了什么事 情,关键就是要弄清楚req.startAsync方法和ctx.complete方法都做了什么。
1.startAsync方法
startAsync方法其实就是创建了一个异步上下文AsyncContext对象,AsyncContext对象的作用是保存请求的 中间信息,比如Request和Response对象等上下文信息。
你来思考一下为什么需要保存这些信息呢?
这是因为Tomcat的工作线程在Request.startAsync调用之后,就直接结束回到线程池中了,线程本身 不会保存任何信息。也就是说一个请求到服务端,执行到一半,你的Web应用正在处理,这个时候Tomcat 的工作线程没了,这就需要有个缓存能够保存原始的Request和Response对象,而这个缓存就是 AsyncContext。
有了AsyncContext,你的Web应用通过它拿到request和response对象,拿到Request对象后就可以读取请 求信息,请求处理完了还需要通过Response对象将HTTP响应发送给浏览器。
除了创建AsyncContext对象,startAsync还需要完成一个关键任务,那就是告诉Tomcat当前的Servlet处理 方法返回时,不要把响应发到浏览器,因为这个时候,响应还没生成呢;并且不能把Request对象和 Response对象销毁,因为后面Web应用还要用呢。
在Tomcat中,负责flush响应数据的是CoyoteAdaptor,它还会销毁Request对象和Response对象,因此需 要通过某种机制通知CoyoteAdaptor,具体来说是通过下面这行代码
你可以把它理解为一个Callback,在这个action方法里设置了Request对象的状态,设置它为一个异步 Servlet请求。
我们知道连接器是调用CoyoteAdapter的service方法来处理请求的,而CoyoteAdapter会调用容器的service 方法,当容器的service方法返回时,CoyoteAdapter判断当前的请求是不是异步Servlet请求,如果是,就不 会销毁Request和Response对象,也不会把响应信息发到浏览器。
你可以通过下面的代码理解一下,这是CoyoteAdapter的service方法,我对它进行了简化:
接下来,当CoyoteAdaptor的service方法返回到ProtocolHandler组件时,ProtocolHandler判断返回值,如 果当前请求是一个异步Servlet请求,它会把当前Socket的协议处理者Processor缓存起来,将 SocketWrapper对象和相应的Processor存到一个Map数据结构里。
之所以要缓存是因为这个请求接下来还要接着处理,还是由原来的Processor来处理,通过SocketWrapper 就能从Map里找到相应的Processor。
2.complete方法
接着我们再来看关键的ctx.complete方法,当请求处理完成时,Web应用调用这个方法。
那么这个方法 做了些什么事情呢?
最重要的就是把响应数据发送到浏览器。
这件事情不能由Web应用线程来做,也就是说ctx.complete方法不能直接把响应数据发送到浏览器,因 为这件事情应该由Tomcat线程来做,但具体怎么做呢?
我们知道,连接器中的Endpoint组件检测到有请求数据达到时,会创建一个SocketProcessor对象交给线程池去处理,因此Endpoint的通信处理和具体请求处理在两个线程里运行
在异步Servlet的场景里,Web应用通过调用ctx.complete方法时,也可以生成一个新的SocketProcessor 任务类,交给线程池处理。对于异步Servlet请求来说,相应的Socket和协议处理组件Processor都被缓存起 来了,并且这些对象都可以通过Request对象拿到。
讲到这里,你可能已经猜到ctx.complete是如何实现的了:
我们可以看到complete方法调用了Request对象的action方法。而在action方法里,则是调用了Processor的 processSocketEvent方法,并且传入了操作码OPEN_READ。
我们接着看processSocketEvent方法,它调用SocketWrapper的processSocket方法:
而SocketWrapper的processSocket方法会创建SocketProcessor任务类,并通过Tomcat线程池来处理:
请你注意createSocketProcessor函数的第二个参数是SocketEvent,这里我们传入的是OPEN_READ。通过这 个参数,我们就能控制SocketProcessor的行为,因为我们不需要再把请求发送到容器进行处理,只需要向 浏览器端发送数据,并且重新在这个Socket上监听新的请求就行了。
7.嵌入式Web容器
为了方便开发和部署,Spring Boot在内部启动了一个嵌入式的Web容器。
我们知道Tomcat和Jetty是组件化 的设计,要启动Tomcat或者Jetty其实就是启动这些组件。
在Tomcat独立部署的模式下,我们通过startup脚 本来启动Tomcat,Tomcat中的Bootstrap和Catalina会负责初始化类加载器,并解析server.xml和启动这 些组件。
在内嵌式的模式下,Bootstrap和Catalina的工作就由Spring Boot来做了,Spring Boot调用了Tomcat和Jetty 的API来启动这些组件。
1.SpringBoot定义Web容器
那Spring Boot具体是怎么做的呢?而作为程序员,我们如何向SpringBoot中的 Tomcat注册Servlet或者Filter呢?我们又如何定制内嵌式的Tomcat?
既然要支持多种Web容器,Spring Boot对内嵌式Web容器进行了抽象,定义了WebServer接口:
各种Web容器比如Tomcat和Jetty需要去实现这个接口。
Spring Boot还定义了一个工厂ServletWebServerFactory来创建Web容器,返回的对象就是上面提到的 WebServer。
可以看到getWebServer有个参数,类型是ServletContextInitializer。它表示ServletContext的初始化器,用 于ServletContext中的一些配置:
这里请注意,上面提到的getWebServer方法会调用ServletContextInitializer的onStartup方法,也就是说如果你想在Servlet容器启动时做一些事情,比如注册你自己的Servlet,可以实现一个 ServletContextInitializer接口如下面所示
在Web容器启动时,Spring Boot会把所有实现了ServletContextInitializer接口的 类收集起来,统一调它们的onStartup方法。
这里请注意两点:
- ServletRegistrationBean其实也是通过ServletContextInitializer来实现的,它实现了 ServletContextInitializer接口。
- 注意到onStartup方法的参数是我们熟悉的ServletContext,可以通过调用它的addServlet方法来动态注册 新的Servlet,这是Servlet 3.0以后才有的功能。
为了支持对内嵌式Web容器的定制化,Spring Boot还定义了 WebServerFactoryCustomizerBeanPostProcessor接口,它是一个BeanPostProcessor,它在 postProcessBeforeInitialization过程中去寻找Spring容器中WebServerFactoryCustomizer 类型的Bean,并依次调用WebServerFactoryCustomizer 接口的customize方法做一些定制化。
2.内嵌式Web容器创建和启动
铺垫了这些接口,我们再来看看Spring Boot是如何实例化和启动一个Web容器的。我们知道,Spring的核 心是一个ApplicationContext,它的抽象实现类AbstractApplicationContext 实现了著名的refresh方法,它用来新建或者刷新一个ApplicationContext,在refresh方法中会调用 onRefresh方法,AbstractApplicationContext的子类可以重写这个方法onRefresh方法,来实现特定Context 的刷新逻辑,因此ServletWebServerApplicationContext就是通过重写onRefresh方法来创建内嵌式的Web容 器,具体创建过程是这样的:
再来看看getWebSever具体做了什么,以Tomcat为例,主要调用Tomcat的API去创建各种组件:
你可能好奇prepareContext方法是做什么的呢?这里的Context是指Tomcat中的Context组件,为了方便控 制Context组件的行为,Spring Boot定义了自己的TomcatEmbeddedContext,它扩展了Tomcat的 StandardContext:
3.Web容器定制
我们再来考虑一个问题,那就是如何在Spring Boot中定制Web容器。在Spring Boot 2.0中,我们可以通过 两种方式来定制Web容器。
第一种方式是通过通用的Web容器工厂ConfigurableServletWebServerFactory,来定制一些Web容器通用的 参数:
第二种方式是通过特定Web容器的工厂比如TomcatServletWebServerFactory来进一步定制。下面的例子 里,我们给Tomcat增加一个Valve,这个Valve的功能是向请求头里添加traceid,用于分布式追踪。
TraceValve的定义如下:
跟第一种方式类似,再添加一个定制器,代码如下:
8.Session管理机制
我们可以通过Request对象的getSession方法来获取Session,并通过Session对象来读取和写入属性值。而 Session的管理是由Web容器来完成的,主要是对Session的创建和销毁,除此之外Web容器还需要将 Session状态的变化通知给监听者。
当然Session管理还可以交给Spring来做,好处是与特定的Web容器解耦,Spring Session的核心原理是通过 Filter拦截Servlet请求,将标准的ServletRequest包装一下,换成Spring的Request对象,这样当我们调用 Request对象的getSession方法时,Spring在背后为我们创建和管理Session。
那么Tomcat的Session管理机制我们还需要了解吗?
我觉得还是有必要,因为只有了解这些原理,我们才能 更好的理解Spring Session,以及Spring Session为什么设计成这样。
今天我们就从Session的创建、Session 的清理以及Session的事件通知这几个方面来了解Tomcat的Session管理机制。
关于session的源码细节还是有一定复杂的,想要深入理解可参考
极客时间《深入拆解 Tomcat & Jetty》
1.Session创建
Tomcat中主要由每个Context容器内的一个Manager对象来管理Session。
默认实现类为 StandardManager。不出意外我们在接口中看到了添加和删除Session的方法;另外还有load和unload方法,它们的作用是分别 是将Session持久化到存储介质和从存储介质加载Session。
当我们调用HttpServletRequest.getSession(true)时,这个参数true的意思是“如果当前请求还没 有Session,就创建一个新的”。
那Tomcat在背后为我们做了些什么呢?
我们来看ManagerBase的createSession是如何实现的:
到此我们明白了Session是如何创建出来的,创建出来后Session会被保存到一个ConcurrentHashMap中:
不过在实际生产中,大多数通过sping session来管理,并将session存储到第三方分布式存储介质中如redis
2.Session清理
我们再来看看Tomcat是如何清理过期的Session。
在Tomcat热加载和热部署的里,我讲到容器组件会开启一个ContainerBackgroundProcessor后台线程,调用自己以及子容器的backgroundProcess进行一些后台 逻辑的处理,和Lifecycle一样,这个动作也是具有传递性的,也就是说子容器还会把这个动作传递给自己的 子容器。
你可以参考下图来理解这个过程。
其中父容器会遍历所有的子容器并调用其backgroundProcess方法,而StandardContext重写了该方法,它 会调用StandardManager的backgroundProcess进而完成Session的清理工作,下面是StandardManager的 backgroundProcess方法的代码
backgroundProcess由Tomcat后台线程调用,默认是每隔10秒调用一次,但是Session的清理动作不能太频 繁,因为需要遍历Session列表,会耗费CPU资源,所以在backgroundProcess方法中做了取模处理, backgroundProcess调用6次,才执行一次Session清理,也就是说Session清理每60秒执行一次。
3.Session事件通知
按照Servlet规范,在Session的生命周期过程中,要将事件通知监听者,Servlet规范定义了Session的监听器 接口:
注意到这两个方法的参数都是HttpSessionEvent,所以Tomcat需要先创建HttpSessionEvent对象,然后遍历 Context内部的LifecycleListener,并且判断是否为HttpSessionListener实例,如果是的话则调用 HttpSessionListener的sessionCreated方法进行事件通知。
这些事情都是在Session的setId方法中完成的:
从代码我们看到setId方法调用了tellNew方法,那tellNew又是如何实现的呢?
上面代码的逻辑是,先通过StandardContext将HttpSessionListener类型的Listener取出,然后依次调用它 们的sessionCreated方法。
9.高可用集群
为了支持水平扩展和高可用,Tomcat提供了集群部署的能力,但与此同时也带来了分布式系统的一个通用 问题,那就是如何在集群中的多个节点之间保持数据的一致性,比如会话(Session)信息。
要实现这一点,基本上有两种方式,一种是把所有Session数据放到一台服务器或者一个数据库中,集群中 的所有节点通过访问这台Session服务器来获取数据。
另一种方式就是在集群中的节点间进行Session数据的 同步拷贝,这里又分为两种策略:第一种是将一个节点的Session拷贝到集群中其他所有节点;第二种是只 将一个节点上的Session数据拷贝到另一个备份节点。
但在实际生产中,基本上是要保证tomcat的无状态,即会选择将session数据放在第三方存储介质如redis里,然后通过nginx或者注册中心如zk做集群负载均衡。
二、连接器
1.整体设计
1.功能清单
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,无论是HTTP还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。
我们可以把连接器的功能需求进一步细化,如下面这些核心的功能
-
监听网络端口。
-
接受网络连接请求。
-
读取请求网络字节流。
-
根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象。
-
将Tomcat Request对象转成标准的ServletRequest。
-
调用Servlet容器,得到ServletResponse。
-
将ServletResponse转成Tomcat Response对象。
-
将Tomcat Response转成网络字节流。
-
将响应字节流写回给浏览器。
2.模块设计
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?
优秀的模块化设计应该考虑高内聚、低耦合。
通过分析连接器的详细功能列表,我们发现连接器需要完成3个高内聚的功能:
-
网络通信。
-
应用层协议解析。
-
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是EndPoint、Processor和Adaptor。
组件之间通过抽象接口交互。这样做还有一个好处是封装变化。这是面向对象设计的精髓,将系统中经常变化的部分和稳定的部分隔离,有助于增加复用性,并降低系统耦合度。
网络通信的I/O模型是变化的,可能是非阻塞I/O、异步I/O或者APR。应用层协议也是变化的,可能是HTTP、HTTPS、AJP。浏览器端发送的请求信息也是变化的。
但是整体的处理逻辑是不变的,EndPoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adaptor,Adaptor负责提供ServletRequest对象给容器。
如果要支持新的I/O方案、新的应用层协议,只需要实现相关的具体子类,上层通用的处理逻辑是不变的。
由于I/O模型和应用层协议可以自由组合,比如NIO + HTTP或者NIO2 + AJP。Tomcat的设计者将网络通信和应用层协议解析放在一起考虑,设计了一个叫ProtocolHandler的接口来封装这两种变化点。各种协议和通信模型的组合有相应的具体实现类。比如:Http11NioProtocol和AjpNioProtocol。
除了这些变化点,系统也存在一些相对稳定的部分,因此Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。
下面我整理一下它们的继承关系。
通过上面的图,你可以清晰地看到它们的继承和层次关系,这样设计的目的是尽量将稳定的部分放到抽象基类,同时每一种I/O模型和协议的组合都有相应的具体实现类,我们在使用时可以自由选择。
小结一下,连接器模块用三个核心组件:Endpoint、Processor和Adaptor来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
2.ProtocolHandler组件
顾名思义,连接器用ProtocolHandler来处理网络连接和应用层协议,包含了2个重要部件:EndPoint和Processor,下面我来详细介绍它们的工作原理。
这里我们在细化一下ProtocolHandler组件的内部功能结构
从图中我们看到,EndPoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的Run方法会调用Processor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法。
1.EndPoint
EndPoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此EndPoint是用来实现TCP/IP协议的。
EndPoint是一个接口,它的抽象实现类AbstractEndpoint里面定义了两个内部类:Acceptor和SocketProcessor。
其中Acceptor用于监听Socket连接请求。
所以当请求来的时候入口调用就是在Acceptor的run方法里,下面这句话用来接收一个新的连接
socket = endpoint.serverSocketAccept();
SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在Run方法里调用协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。
而这个线程池叫作执行器(Executor),我在后面的专栏会详细介绍Tomcat如何扩展原生的Java线程池。关于EndPoint的详细设计,后面我还会专门介绍EndPoint是如何最大限度地利用Java NIO的非阻塞以及NIO2的异步特性,来实现高并发。
2.Processor
如果说EndPoint是用来实现TCP/IP协议的,那么Processor用来实现HTTP协议,Processor接收来自EndPoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
Processor是一个接口,定义了请求的处理等方法。它的抽象实现类AbstractProcessor对一些协议共有的属性进行封装,没有对方法进行实现。具体的实现有AJPProcessor、HTTP11Processor等,这些具体实现类实现了特定协议的解析方法和请求处理方式。
3.Adaptor组件
我在前面说过,由于协议不同,客户端发过来的请求信息也不尽相同,Tomcat定义了自己的Request类来“存放”这些请求信息。
ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。
Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的Sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的Service方法
三、容器
容器,顾名思义就是用来装载东西的器具,在Tomcat里,容器就是用来装载Servlet的。
那Tomcat的Servlet容器是如何设计的呢?
1.容器结构
![](https://i-blog.csdnimg.cn/blog_migrate/3b8fc06de627b5fbb5bd20c601b217f5.png)
你可能会问,为什么要设计成这么多层次的容器,这不是增加了复杂度吗?
其实这背后的考虑是,Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。
Engine
Engine表示引擎,用来管理多个虚拟站点,一个Service最多只能有一个Engine。
Host
Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序,即可以有多个Context。
缺省的配置如下:
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">….</Host>
其中appBase为webapps,也就是<CATALINA_HOME>\webapps目录,unpackingWARS属性指定在appBase指定的目录中的war包都自动的解压,缺省配置为true,autoDeploy属性指定是否对加入到appBase目录的war包进行自动的部署,缺省为true.
Context
Context表示一个Web应用程序;
在Tomcat中,每一个运行的webapp其实最终都是以Context的形成存在,每个Context都有一个根路径和请求URL路径,即每个context就对应一个web应用
Context对应源代码中的org.apache.catalina.core.StandardContext
Wrapper
Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;
你可以再通过Tomcat的server.xml配置文件来加深对Tomcat容器的理解。
Tomcat采用了组件化的设计,它的构成组件都是可配置的,其中最外层的是Server,其他组件按照一定的格式要求配置在这个顶层容器中。
那么,Tomcat是怎么管理这些容器的呢?
你会发现这些容器具有父子关系,形成一个树形结构,你可能马上就想到了设计模式中的组合模式。没错,Tomcat就是用组合模式来管理这些容器的。
具体实现方法是,所有容器组件都实现了Container接口,因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper,组合容器对象指的是上面的Context、Host或者Engine。
Container接口定义如下
public interface Container extends Lifecycle {
public void setName(String name);
public Container getParent();
public void setParent(Container container);
public void addChild(Container child);
public void removeChild(Container child);
public Container findChild(String name);
}
正如我们期望的那样,我们在上面的接口看到了getParent、SetParent、addChild和removeChild等方法。
你可能还注意到Container接口扩展了LifeCycle接口,LifeCycle接口用来统一管理各组件的生命周期,后面我也用专门的篇幅去详细介绍。
1.Context
我们思考一个问题:
Tomcat内的Context组件跟Servlet规范中的ServletContext接口有什么区别?跟Spring中的
ApplicationContext又有什么关系?
1.Servlet规范中ServletContext表示web应用的上下文环境,而web应用对应在tomcat的概念是Context。
所以从设计上,ServletContext自然会成为tomcat的Context具体实现的一个成员变量。
2.tomcat内部实现也是这样完成的,ServletContext对应在tomcat实现是org.apache.catalina.core.ApplicationContext,Context容器对应tomcat实现是org.apache.catalina.core.StandardContext。ApplicationContext是StandardContext的一个成员变量。
3.Spring的ApplicationContext之前已经介绍过,tomcat启动过程中ContextLoaderListener会监听到容 器初始化事件,它的contextInitialized方法中,Spring会初始化全局的Spring根容器ApplicationContext, 初始化完毕后,Spring将其存储到ServletContext中。
总而言之,Servlet规范中ServletContext是tomcat的Context实现的一个成员变量,而Spring的ApplicationContext是Servlet规范中ServletContext的一个属性。
2.Servlet定位处理流程
你可能好奇,设计了这么多层次的容器,Tomcat是怎么确定请求是由哪个Wrapper容器里的Servlet来处理的呢?
答案是,Tomcat是用Mapper组件来完成这个任务的。
Mapper组件的功能就是将用户请求的URL定位到一个Servlet,它的工作原理是:Mapper组件里保存了Web 应用的配置信息,其实就是容器组件与访问路径的映射关系,比如Host容器里配置的域名、Context容器里的Web应用路径,以及Wrapper容器里Servlet映射的路径,你可以想象这些配置信息就是一个多层次的 Map。
当一个请求到来时,Mapper组件通过解析请求URL里的域名和路径,再到自己保存的Map里去查找,就能 定位到一个Servlet。请你注意,一个请求URL最后只会定位到一个Wrapper容器,也就是一个Servlet。
假如有一个网购系统,有面向网站管理人员的后台管理系统,还有面向终端客户的在线购物系统。
这两个系 统跑在同一个Tomcat上,为了隔离它们的访问域名,配置了两个虚拟域名:manage.shopping.com和 user.shopping.com,网站管理人员通过manage.shopping.com域名访问Tomcat去管理用户和商品, 而用户管理和商品管理是两个单独的Web应用。终端客户通过user.shopping.com域名去搜索商品和下 订单,搜索功能和订单管理也是两个独立的Web应用。
针对这样的部署,Tomcat会创建一个Service组件和一个Engine容器组件,在Engine容器下创建两个Host子容器,在每个Host容器下创建两个Context子容器。由于一个Web应用通常有多个Servlet,Tomcat还会在每 个Context容器里创建多个Wrapper子容器。每个容器都有对应的访问路径,你可以通过下面这张图来帮助你理解。
假如有用户访问一个URL,比如图中的http://user.shopping.com:8080/order/buy,Tomcat如何将这个URL定位到一个Servlet呢?
1.首先,根据协议和端口号选定Service和Engine。
我们知道Tomcat的每个连接器都监听不同的端口,比如Tomcat默认的HTTP连接器监听8080端口、默认的 AJP连接器监听8009端口。
上面例子中的URL访问的是8080端口,因此这个请求会被HTTP连接器接收,而一个连接器是属于一个Service组件的,这样Service组件就确定了。我们还知道一个Service组件里除了有多个 连接器,还有一个容器组件,具体来说就是一个Engine容器,因此Service确定了也就意味着Engine也确定了。
2.然后,根据域名选定Host。
Service和Engine确定后,Mapper组件通过URL中的域名去查找相应的Host容器,比如例子中的URL访问的 域名是user.shopping.com,因此Mapper会找到Host2这个容器。
3.之后,根据URL路径找到Context组件。
Host确定以后,Mapper根据URL的路径来匹配相应的Web应用的路径,比如例子中访问的是/order,因此 找到了Context4这个Context容器。
4.最后,根据URL路径找到Wrapper(Servlet)。
Context确定后,Mapper再根据web.xml中配置的Servlet映射路径来找到具体的Wrapper和Servlet。
那么找到了对应的Wrapper后,之后的处理流程又是怎样的?
其处理流程如下:Wrapper -> Filter -> DispatcherServlet -> Controller
3.Pipeline-Valve管道
看到这里,我想你应该已经了解了什么是容器,以及Tomcat如何通过一层一层的父子容器找到某个Servlet 来处理请求。需要注意的是,并不是说只有Servlet才会去处理请求,实际上这个查找路径上的父子容器都会对请求做一些处理。
我在上一期说过,连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。
那么这个调用过程具体 是怎么实现的呢?
答案是使用Pipeline-Valve管道。
Pipeline-Valve是责任链模式,责任链模式是指在一个请求处理的过程中有很多处理者依次对请求进行处 理,每个处理者负责做自己相应的处理,处理完之后将再调用下一个处理者继续处理。
这样的设计使得系统具有良好的可扩展性,如果需要扩展容器本身的功能,只需要增加相应的Valve即可。
Valve表示一个处理点,比如权限认证和记录日志。如果你还不太理解的话,可以来看看Valve和Pipeline接 口中的关键方法。
public interface Valve {
public Valve getNext();
public void setNext(Valve valve);
public void invoke(Request request, Response response)
}
由于Valve是一个处理点,因此invoke方法就是来处理请求的。注意到Valve中有getNext和setNext方法,因此我们大概可以猜到有一个链表将Valve链起来了。
请你继续看Pipeline接口:
public interface Pipeline extends Contained {
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。
我还是通过一张图来解释。
整个调用过程由连接器中的Adapter触发的,它会调用Engine的第一个Valve:
// 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来实现
四、Executor线程池管理组件
为了提高处理能力和并发度,Web容器一般会把处理请求的工作放到线程池里来执行,Tomcat扩展 了原生的Java线程池,来满足Web容器高并发的需求。
Tomcat扩展了Java线程 池的核心类ThreadPoolExecutor,并重写了它的execute方法,定制了自己的任务处理流程。同时Tomcat还 实现了定制版的任务队列,重写了offer方法,使得在任务队列长度无限制的情况下,线程池仍然有机会创 建新的线程。
1.定制版资源限制
跟FixedThreadPool/CachedThreadPool一样,Tomcat的线程池也是一个定制版的ThreadPoolExecutor。通过比较FixedThreadPool和CachedThreadPool,我们发现它们传给ThreadPoolExecutor的参数有两个关键 点:
- 是否限制线程个数。
- 是否限制队列长度。
对于Tomcat来说,这两个资源都需要限制,也就是说要对高并发进行控制,否则CPU和内存有资源耗尽的 风险。
因此Tomcat传入的参数是这样的:
//定制版的任务队列
taskqueue = new TaskQueue(maxQueueSize);
//定制版的线程⼯⼚
TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
//定制版的线程池
executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS
你可以看到其中的两个关键点:
1.Tomcat有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。
2.Tomcat对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。
2.定制版任务处理流程
除了资源限制以外,Tomcat线程池还定制自己的任务处理流程。
我们知道Java原生线程池的任务处理逻辑比较简单:
1. 前corePoolSize个任务时,来一个任务就创建一个新线程。
2. 后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
3. 如果总线程数达到maximumPoolSize,执行拒绝策略。
Tomcat线程池扩展了原生的ThreadPoolExecutor,通过重写execute方法实现了自己的任务处理逻辑:
1. 前corePoolSize个任务时,来一个任务就创建一个新线程。
2. 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
3. 如果总线程数达到maximumPoolSize,则继续尝试把任务添加到任务队列中去。
4. 如果缓冲队列也满了,插入失败,执行拒绝策略。
观察Tomcat线程池和Java原生线程池的区别,其实就是在第3步,Tomcat在线程总数达到最大数时,不是立 即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。
那具体如何实现呢,其实 很简单,我们来看一下Tomcat线程池的execute方法的核心代码。
从这个方法你可以看到,Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务,如果 总线程数达到maximumPoolSize,Java原生线程池的execute方法会抛出RejectedExecutionException异 常,但是这个异常会被Tomcat线程池的execute方法捕获到,并继续尝试把这个任务放到任务队列中去;如 果任务队列也满了,再执行拒绝策略。
3.定制版的任务队列
细心的你有没有发现,在Tomcat线程池的execute方法最开始有这么一行:
submittedCount.incrementAndGet();
这行代码的意思把submittedCount这个原子变量加一,并且在任务执行失败,抛出拒绝异常时,将这个原 子变量减一:
submittedCount.decrementAndGet();
其实Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池,但是还没有执行完的任务个数。
Tomcat为什么要维护这个变量呢?
这跟Tomcat的定制版的任务队列有关。
Tomcat的任务队列 TaskQueue扩展了Java中的LinkedBlockingQueue,我们知道LinkedBlockingQueue默认情况下长度是没有 限制的,除非给它一个capacity。因此Tomcat给了它一个capacity,TaskQueue的构造函数中有个整型的参 数capacity,TaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的,但问题是默认情况下maxQueueSize的值是Integer.MAX_VALUE,等于没有限制,这样就带来一个问题:当前线程数达到核心线程数之后,再来 任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
为了解决这个问题,TaskQueue重写了LinkedBlockingQueue的offer方法,在合适的时机返回false,返回 false表示任务添加失败,这时线程池会创建新的线程。
那什么是合适的时机呢?请看下面offer方法的核心源码
从上面的代码我们看到,只有当前线程数大于核心线程数、小于最大线程数,并且已提交的任务个数大于当 前线程数时,也就是说线程不够用了,但是线程数又没达到极限,才会去创建新的线程。
这就是为什么 Tomcat需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机 会创建新的线程。
当然默认情况下Tomcat的任务队列是没有限制的,你可以通过设置maxQueueSize参数来限制任务队列的长 度。
五、tomcat启停架构设计
tomcat启停设计,顾名思义就是tomcat启动和停止的设计,里面不可避免的就是了解各个组件的生命周期
1.组件协作处理流程
我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在Tomcat中流转的过程。
上面这张图描述了组件之间的静态关系,如果想让一个系统能够对外提供服务,我们需要创建、组装并启动 这些组件;在服务停止的时候,我们还需要释放资源,销毁这些组件,因此这是一个动态的过程。也就是 说,Tomcat需要动态地管理这些组件的生命周期。
在我们实际的工作中,如果你需要设计一个比较大的系统或者框架时,你同样也需要考虑这几个问题:如何 统一管理组件的创建、初始化、启动、停止和销毁?如何做到代码逻辑清晰?如何方便地添加或者删除组 件?如何做到组件启动和停止不遗漏、不重复?
今天我们就来解决上面的问题,在这之前,先来看看组件之间的关系。如果你仔细分析过这些组件,可以发 现它们具有两层关系。
第一层关系是组件有大有小,大组件管理小组件,比如Server管理Service,Service又管理连接器和容 器。
第二层关系是组件有外有内,外层组件控制内层组件,比如连接器是外层组件,负责对外交流,外层组件 调用内层组件完成业务功能。也就是说,请求的处理过程是由外层组件来驱动的。
这两层关系决定了系统在创建组件时应该遵循一定的顺序。
第一个原则是先创建子组件,再创建父组件,子组件需要被“注入”到父组件中。
第二个原则是先创建内层组件,再创建外层组件,内层组建需要被“注入”到外层组件。
因此,最直观的做法就是将图上所有的组件按照先小后大、先内后外的顺序创建出来,然后组装在一起。
知道你注意到没有,这个思路其实很有问题!因为这样不仅会造成代码逻辑混乱和组件遗漏,而且也不利于 后期的功能扩展。
为了解决这个问题,我们希望找到一种通用的、统一的方法来管理组件的生命周期,就像汽车“一键启 动”那样的效果。
2.组件生命周期
Tomcat来管理组件的生命周期,主要有两个要点,
一是父组件负责子组件的创 建、启停和销毁。这样只要启动最上层组件,整个Web容器就被启动起来了,也就实现了一键式启停;
二是 Tomcat和Jetty都定义了组件的生命周期状态,并且把组件状态的转变定义成一个事件,一个组件的状态变 化会触发子组件的变化,比如Host容器的启动事件里会触发Web应用的扫描和加载,最终会在Host容器下创建相应的Context容器,而Context组件的启动事件又会触发Servlet的扫描,进而创建Wrapper组件。
那么 如何实现这种联动呢?答案是观察者模式。具体来说就是创建监听器去监听容器的状态变化,在监听器的方 法里去实现相应的动作,这些监听器其实是组件生命周期过程中的“扩展点”。
1.LifeCycle接口
设计就是要找到系统的变化点和不变点。
这里的不变点就是每个组件都要经历创建、初始 化、启动这几个过程,这些状态以及状态的转化是不变的。
而变化点是每个具体组件的初始化方法,也就是 启动方法是不一样的。
因此,我们把不变点抽象出来成为一个接口,这个接口跟生命周期有关,叫作LifeCycle。
LifeCycle接口里应 该定义这么几个方法:init()、start()、stop()和destroy(),每个具体的组件去实现这些方法。 理所当然,在父组件的init()方法里需要创建子组件并调用子组件的init()方法。同样,在父组件的start()方法 里也需要调用子组件的start()方法,因此调用者可以无差别的调用各组件的init()方法和start()方法,这就是 组合模式的使用,并且只要调用最顶层组件,也就是Server组件的init()和start()方法,整个Tomcat就被启动 起来了。
下面是LifeCycle接口的定义。
2.LifeCycle事件
我们再来考虑另一个问题,那就是系统的可扩展性。
因为各个组件init()和start()方法的具体实现是复杂多变 的,比如在Host容器的启动方法里需要扫描webapps目录下的Web应用,创建相应的Context容器,如果将来需要增加新的逻辑,直接修改start()方法?
这样会违反开闭原则,那如何解决这个问题呢?
开闭原则说的 是为了扩展系统的功能,你不能直接修改系统中已有的类,但是你可以定义新的类。
我们注意到,组件的init()和start()调用是由它的父组件的状态变化触发的,上层组件的初始化会触发子组件 的初始化,上层组件的启动会触发子组件的启动,因此我们把组件的生命周期定义成一个个状态,把状态的 转变看作是一个事件。而事件是有监听器的,在监听器里可以实现一些逻辑,并且监听器也可以方便的添加 和删除,这就是典型的观察者模式。
具体来说就是在LifeCycle接口里加入两个方法:添加监听器和删除监听器。除此之外,我们还需要定义一个 Enum来表示组件有哪些状态,以及处在什么状态会触发什么样的事件。因此LifeCycle接口和LifeCycleState 就定义成了下面这样。
从图上你可以看到,组件的生命周期有NEW、INITIALIZING、INITIALIZED、STARTING_PREP、 STARTING、STARTED等,而一旦组件到达相应的状态就触发相应的事件,比如NEW状态表示组件刚刚被实 例化;而当init()方法被调用时,状态就变成INITIALIZING状态,这个时候,就会触发BEFORE_INIT_EVENT 事件,如果有监听器在监听这个事件,它的方法就会被调用。
3.LifeCycleBase抽象基类
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同 的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一 个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
而基类中往往会定义一些抽象方法,所谓的抽象方法就是说基类不会去实现这些方法,而是调用这些方法来 实现骨架逻辑。抽象方法是留给各个子类去实现的,并且子类必须实现,否则无法实例化。
回到LifeCycle接口,Tomcat定义一个基类LifeCycleBase来实现LifeCycle接口,把一些公共的逻辑放到基类 中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的 初始化、启动和停止等方法。为了避免跟基类中的方法同名,我们把具体子类的实现方法改个名字,在后面 加上Internal,叫initInternal()、startInternal()等。
我们再来看引入了基类LifeCycleBase后的类图:
从图上可以看到,LifeCycleBase实现了LifeCycle接口中所有的方法,还定义了相应的抽象方法交给具体子类 去实现,这是典型的模板设计模式。
我们还是看一看代码,可以帮你加深理解,下面是LifeCycleBase的init()方法实现。
这个方法逻辑比较清楚,主要完成了四步:
第一步,检查状态的合法性,比如当前状态必须是NEW然后才能进行初始化。
第二步,触发INITIALIZING事件的监听器:
第三步,调用具体子类实现的抽象方法initInternal()方法。我在前面提到过,为了实现一键式启动,具体组 件在实现initInternal()方法时,又会调用它的子组件的init()方法。
第四步,子组件初始化后,触发INITIALIZED事件的监听器,相应监听器的业务方法就会被调用。
总之,LifeCycleBase调用了抽象方法来实现骨架逻辑。
讲到这里, 你可能好奇,LifeCycleBase负责触发事 件,并调用监听器的方法,那是什么时候、谁把监听器注册进来的呢?
分为两种情况:
1.Tomcat自定义了一些监听器,这些监听器是父组件在创建子组件的过程中注册到子组件的。比如 MemoryLeakTrackingListener监听器,用来检测Context容器中的内存泄漏,这个监听器是Host容器在创 建Context容器时注册到Context中的。
2.我们还可以在server.xml中定义自己的监听器,Tomcat在启动时会解析server.xml,创建监听器并注册到 容器组件。
4.生命周期总体类设计
有了上面的基础,我们就可以看看整个生命周期的架构设计
StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类,因 为它们都是容器,所以继承了ContainerBase抽象基类。
而ContainerBase实现了Container接口,也继承了 LifeCycleBase类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中接口分离的原则。
Tomcat为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想 和设计模式发挥到了极致,分别运用了组合模式、观察者模式、模板方法模式。
在使用设计模式时,同时考虑了一些权威的设计原则,比如运用观察者模式遵守了开闭原则,防止动态添加生命周期组件而更改基类,同时生命周期接口和容器接口拆分也体现了接口分离原则
3.tomcat启动流程
使用过Tomcat的同学都知道,我们可以通过Tomcat的/bin目录下的脚本startup.sh来启动Tomcat,那你是 否知道我们执行了这个脚本后发生了什么呢?
你可以通过下面这张流程图来了解一下。
1.Tomcat本质上是一个Java程序,因此startup.sh脚本会启动一个JVM来运行Tomcat的启动类Bootstrap。
2.Bootstrap的主要任务是初始化Tomcat的类加载器,并且创建Catalina。关于Tomcat为什么需要自己的类 加载器,我会在专栏后面详细介绍。
3.Catalina是一个启动类,它通过解析server.xml、创建相应的组件,并调用Server的start方法。
4.Server组件的职责就是管理Service组件,它会负责调用Service的start方法。
5.Service组件的职责就是管理连接器和顶层容器Engine,因此它会调用连接器和Engine的start方法。
这样Tomcat的启动就算完成了。
下面我来详细介绍一下上面这个启动过程中提到的几个非常关键的启动类 和组件。
你可以把Bootstrap看作是上帝,它初始化了类加载器,也就是创造万物的工具。 如果我们把Tomcat比作是一家公司,那么Catalina应该是公司创始人,因为Catalina负责组建团队,也就是创建Server以及它的子组件。
Server是公司的CEO,负责管理多个事业群,每个事业群就是一个Service。
Service是事业群总经理,它管理两个职能部门:一个是对外的市场部,也就是连接器组件;另一个是对内 的研发部,也就是容器组件。
Engine则是研发部经理,因为Engine是最顶层的容器组件。
你可以看到这些启动类或者组件不处理具体请求,它们的任务主要是“管理”,管理下层组件的生命周期, 并且给下层组件分配任务,也就是把请求路由到负责“干活儿”的组件。
因此我把它们比作Tomcat的“高 层”。
当我们在设计这样的组件时,需要考虑两个方面:
首先要选用合适的数据结构来保存子组件,比如Server用数组来保存Service组件,并且采取动态扩容的方 式,这是因为数组结构简单,占用内存小;再比如ContainerBase用HashMap来保存子容器,虽然Map占用 内存会多一点,但是可以通过Map来快速的查找子容器。因此在实际的工作中,我们也需要根据具体的场景 和需求来选用合适的数据结构。
其次还需要根据子组件依赖关系来决定它们的启动和停止顺序,以及如何优雅的停止,防止异常情况下的资 源泄漏。
1.Catalina启动类
Catalina的主要任务就是创建Server,它不是直接new一个Server实例就完事了,而是需要解析server.xml, 把在server.xml里配置的各种组件一一创建出来,接着调用Server组件的init方法和start方法,这样整个 Tomcat就启动起来了。
作为“管理者”,Catalina还需要处理各种“异常”情况,比如当我们通过“Ctrl + C”关闭Tomcat时,Tomcat将如何优雅的停止并且清理资源呢?因此Catalina在JVM中注册一个“关闭钩 子”。
源码位置:org.apache.catalina.startup.Catalina#start
/**
* Start a new server instance.
*/
public void start() {
if (getServer() == null) {
load();
}
if (getServer() == null) {
log.fatal("Cannot start server. Server instance is not configured.");
return;
}
long t1 = System.nanoTime();
// Start the new server
try {
getServer().start();
} catch (LifecycleException e) {
log.fatal(sm.getString("catalina.serverStartFail"), e);
try {
getServer().destroy();
} catch (LifecycleException e1) {
log.debug("destroy() failed for failed Server ", e1);
}
return;
}
long t2 = System.nanoTime();
if(log.isInfoEnabled()) {
log.info("Server startup in " + ((t2 - t1) / 1000000) + " ms");
}
// Register shutdown hook
if (useShutdownHook) {
if (shutdownHook == null) {
shutdownHook = new CatalinaShutdownHook();
}
Runtime.getRuntime().addShutdownHook(shutdownHook);
// If JULI is being used, disable JULI's shutdown hook since
// shutdown hooks run in parallel and log messages may be lost
// if JULI's hook completes before the CatalinaShutdownHook()
LogManager logManager = LogManager.getLogManager();
if (logManager instanceof ClassLoaderLogManager) {
((ClassLoaderLogManager) logManager).setUseShutdownHook(
false);
}
}
if (await) {
await();
stop();
}
}
代码的实现逻辑如下
1. 如果持有的Server实例为空,就解析server.xml创建出来
2. 如果创建失败,报错退出
3.启动Server
4.创建并注册关闭钩⼦
5.⽤await⽅法监听停⽌请求
那什么是“关闭钩子”,它又是做什么的呢?
如果我们需要在JVM关闭时做一些清理工作,比如将缓存数据 刷到磁盘上,或者清理一些临时文件,可以向JVM注册一个“关闭钩子”。“关闭钩子”其实就是一个线 程,JVM在停止之前会尝试执行这个线程的run方法。
下面我们来看看Tomcat的“关闭钩 子”CatalinaShutdownHook做了些什么。
// --------------------------------------- CatalinaShutdownHook Inner Class
// XXX Should be moved to embedded !
/**
* Shutdown hook which will perform a clean shutdown of Catalina if needed.
*/
protected class CatalinaShutdownHook extends Thread {
@Override
public void run() {
try {
if (getServer() != null) {
Catalina.this.stop();
}
} catch (Throwable ex) {
ExceptionUtils.handleThrowable(ex);
log.error(sm.getString("catalina.shutdownHookFail"), ex);
} finally {
// If JULI is used, shut JULI down *after* the server shuts down
// so log messages aren't lost
LogManager logManager = LogManager.getLogManager();
if (logManager instanceof ClassLoaderLogManager) {
((ClassLoaderLogManager) logManager).shutdown();
}
}
}
}
从这段代码中你可以看到,Tomcat的“关闭钩子”实际上就执行了Server的stop方法,Server的stop方法会 释放和清理所有的资源。
2.Server启动类
Server组件的具体实现类是StandardServer,我们来看下StandardServer具体实现了哪些功能。Server继承 了LifeCycleBase,它的生命周期被统一管理,并且它的子组件是Service,因此它还需要管理Service的生命 周期,也就是说在启动时调用Service组件的启动方法,在停止时调用它们的停止方法。Server在内部维护了 若干Service组件,它是以数组来保存的。
那Server是如何添加一个Service到数组中的呢?
从上面的代码你能看到,它并没有一开始就分配一个很长的数组,而是在添加的过程中动态地扩展数组长 度,当添加一个新的Service实例时,会创建一个新数组并把原来数组内容复制到新数组,这样做的目的其 实是为了节省内存空间。
除此之外,Server组件还有一个重要的任务是启动一个Socket来监听停止端口,这就是为什么你能通过 shutdown命令来关闭Tomcat。
不知道你留意到没有,上面Caralina的启动方法的最后一行代码就是调用了 Server的await方法。 在await方法里会创建一个Socket监听8005端口,并在一个死循环里接收Socket上的连接请求,如果有新的 连接到来就建立连接,然后从Socket中读取数据;如果读到的数据是停止命令“SHUTDOWN”,就退出循 环,进入stop流程。
3.Service启动类
Service组件的具体实现类是StandardService
我们先来看看它的定义以及关键的成员变量。
StandardService继承了LifecycleBase抽象类,此外StandardService中还有一些我们熟悉的组件,比如 Server、Connector、Engine和Mapper。
那为什么还有一个MapperListener?
这是因为Tomcat支持热部署,当Web应用的部署发生变化时,Mapper 中的映射信息也要跟着变化,MapperListener就是一个监听器,它监听容器的变化,并把信息更新到 Mapper中,这是典型的观察者模式。
作为“管理”角色的组件,最重要的是维护其他组件的生命周期。此外在启动各种组件时,要注意它们的依 赖关系,也就是说,要注意启动的顺序。
我们来看看Service启动方法:
源码位置:org.apache.catalina.core.StandardService#startInternal
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();
}
}
}
}
从启动方法可以看到,Service先启动了Engine组件,再启动Mapper监听器,最后才是启动连接器。
这很好理解,因为内层组件启动好了才能对外提供服务,才能启动外层的连接器组件。而Mapper也依赖容器组件,容器组件启动好了才能监听它们的变化,因此Mapper和MapperListener在容器组件之后启动。
组件停止的顺序跟启动顺序正好相反的,也是基于它们的依赖关系。
4.Engine启动类
最后我们再来看看顶层的容器组件Engine具体是如何实现的。
Engine本质是一个容器,因此它继承了ContainerBase基类,并且实现了Engine接口。
我们知道,Engine的子容器是Host,所以它持有了一个Host容器的数组,这些功能都被抽象到了
ContainerBase中,ContainerBase中有这样一个数据结构:
ContainerBase用HashMap保存了它的子容器,并且ContainerBase还实现了子容器的“增删改查”,甚至连子组件的启动和停止都提供了默认实现,比如ContainerBase会用专门的线程池来启动子容器。
所以Engine在启动Host子容器时就直接重用了这个方法。
那Engine自己做了什么呢?
我们知道容器组件最重要的功能是处理请求,而Engine容器对请求的“处理”,其实就是把请求转发给某一个Host子容器来处理,具体是通过Valve来实现的。
通过专栏前面的学习,我们知道每一个容器组件都有一个Pipeline,而Pipeline中有一个基础阀(Basic Valve),而Engine容器的基础阀定义如下:
这个基础阀实现非常简单,就是把请求转发到Host容器。
你可能好奇,从代码中可以看到,处理请求的 Host容器对象是从请求中拿到的,请求对象中怎么会有Host容器呢?
这是因为请求到达Engine容器中之 前,Mapper组件已经对请求进行了路由处理,Mapper组件通过请求的URL定位了相应的容器,并且把容器 对象保存到了请求对象中。
3.Tomcat类加载机制
1.Tomcat 为何不使用默认的类加载机制?
我们思考一下:Tomcat是个web容器, 那么它要解决什么问题:
- 一个web容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。
- 部署在同一个web容器中相同的类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么要有10份相同的类库加载进虚拟机,这是扯淡的。
- web容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来。
- web容器要支持jsp的修改,我们知道,jsp 文件最终也是要编译成class文件才能在虚拟机中运行,但程序运行后修改jsp已经是司空见惯的事情,否则要你何用? 所以,web容器需要支持 jsp 修改后不用重启。
再看看我们的问题:Tomcat 如果使用默认的类加载机制行不行?
答案是不行的。为什么?
为什么不行?
我们看,第一个问题,如果使用默认的类加载器机制,那么是无法加载两个相同类库的不同版本的,默认的类加载器是不管你是什么版本的,只在乎你的全限定类名,并且只有一份。
第二个问题,默认的类加载器是能够实现的,因为他的职责就是保证唯一性。
第三个问题和第一个问题一样,是不行的,tomcat的类是应用程序的。
我们再看第四个问题,我们想我们要怎么实现jsp文件的热修改(楼主起的名字),jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。
那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件。
2.Tomcat 如何实现自己独特的类加载机制?
Tomcat的自定义类加载器WebAppClassLoader打破了双亲委托机制,它首先自己尝试去加载某个类,如果 找不到再代理给父类加载器,其目的是优先加载Web应用(tomcat)自己定义的类。
具体实现就是重写ClassLoader的 两个方法:findClass 和loadClass。
我们先来看看findClass方法的实现,为了方便理解和阅读,我去掉了一些细节:
在findClass方法里,主要有三个步骤:
1. 先在Web应用本地目录下查找要加载的类。
2.如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器AppClassLoader。
3.如果父加载器也没找到这个类,抛出ClassNotFound异常。
接着我们再来看Tomcat类加载器的loadClass方法的实现,同样我也去掉了一些细节:
tomcat的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String等)。
各个web应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给commonClassLoader并走双亲委托。
具体的加载逻辑位于WebAppClassLoaderBase.loadClass()方法中,代码篇幅长,主要有六个步骤:
1. 先在本地Cache查找该类是否已经加载过,也就是说Tomcat的类加载器是否已经加载过这个类。(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中)
2. 如果Tomcat类加载器没有加载过这个类,再看看系统类加载器是否加载过。系统类加载器只判断不直接加载,要让父级先去加载,如果没有在交给Tomcat类加载器加载,如果没有加载,则最后有由系统类加载器加载。
3. 如果都没有,就让ExtClassLoader去加载,这一步比较关键,目的防止Web应用自己的类覆盖JRE的核心 类。因为Tomcat需要打破双亲委托机制,假如Web应用里自定义了一个叫Object的类,如果先加载这个 Object类,就会覆盖JRE里面的那个Object类,这就是为什么Tomcat的类加载器会优先尝试用 ExtClassLoader去加载,因为ExtClassLoader会委托给BootstrapClassLoader去加载, BootstrapClassLoader发现自己已经加载了Object类,直接返回给Tomcat的类加载器,这样Tomcat的类 加载器就不会去加载Web应用下的Object类了,也就避免了覆盖JRE核心类的问题。
4. 如果ExtClassLoader加载器加载失败,也就是说JRE核心类中没有这类,那么就在本地Web应用目录下查找并加载。即这里是交给了Tomcat类加载器WebAppClassLoader去加载
5. 如果本地目录下没有这个类,说明不是Web应用自己定义的类,那么由系统类加载器去加载。
这里请你 注意,Web应用是通过Class.forName调用交给系统类加载器AppClassLoader的,因为Class.forName的默认加载器 就是系统类加载器。
6. 如果上述加载过程全部失败,抛出ClassNotFound异常。
从上面的过程我们可以看到,Tomcat的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载 器,而是先在本地目录下加载,为了避免本地目录下的类覆盖JRE的核心类,又先尝试用JVM扩展类加载器 ExtClassLoader去加载。
那为什么不先用系统类加载器AppClassLoader去加载?
很显然,如果是这样的话, 那就变成双亲委托机制了,这就是Tomcat类加载器的巧妙之处。
3.Tomcat类加载器的层次结构
我们知道,Tomcat作为Servlet容器,它负责加载我们的Servlet类,此外它还负责加载Servlet所依赖的JAR 包。并且Tomcat本身也是也是一个Java程序,因此它需要加载自己的类和依赖的JAR包。
首先让我们思考这 一下这几个问题:
1. 假如我们在Tomcat中运行了两个Web应用程序,两个Web应用中有同名的Servlet,但是功能不同, Tomcat需要同时加载和管理这两个同名的Servlet类,保证它们不会冲突,因此Web应用之间的类需要隔 离。
2. 假如两个Web应用都依赖同一个第三方的JAR包,比如Spring,那Spring的JAR包被加载到内存后, Tomcat要保证这两个Web应用能够共享,也就是说Spring的JAR包只被加载一次,否则随着依赖的第三方 JAR包增多,JVM的内存会膨胀。
3. 跟JVM一样,我们需要隔离Tomcat本身的类和Web应用的类。
为了解决这些问题,Tomcat设计了类加载器的层次结构,它们的关系如下图所示。
我们看到,前面3个类加载和默认的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader则是Tomcat自己定义的类加载器.
作为Java程序员,我们应该牢记
- 每个Web应用自己的Java类文件和依赖的JAR包,分别放在WEB-INF/classes和WEB-INF/lib目录下面。
- 多个应用共享的Java类文件和JAR包,分别放在Web容器指定的共享目录下。
- 当出现ClassNotFound错误时,应该检查你的类加载器是否正确。
Tomcat的Context组件为每个Web应用创建一个WebAppClassLoarder类加载器,由于不同类加载器实例加载的类是互相隔离的,因此达到了隔离Web应用的目的,同时通过CommonClassLoader等父加载器来共享 第三方JAR包。而共享的第三方JAR包怎么加载特定Web应用的类呢?可以通过设置线程上下文加载器来解 决。
其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
commonLoader:Tomcat最基本的类加载器,加载路径/common/中的class可以被Tomcat容器本身以及各个Webapp(web应用)访问;
catalinaLoader:Tomcat容器私有的类加载器,加载路径/server/中的class对于Webapp不可见;
sharedLoader:各个Webapp共享的类加载器,加载路径/shared/*(在tomcat 6之后已经合并到根目录下的lib目录下)中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
WebappClassLoader:各个Webapp私有的类加载器,加载路径/WebApp/*/WEB-INF/*中的class只对当前Webapp可见;
我们先来看第1个问题,假如我们使用JVM默认AppClassLoader来加载Web应用,AppClassLoader只能加载 一个Servlet类,在加载第二个同名Servlet类时,AppClassLoader会返回第一个Servlet类的Class实例,这是 因为在AppClassLoader看来,同名的Servlet类只被加载一次。
因此Tomcat的解决方案是自定义一个类加载器WebAppClassLoader, 并且给每个Web应用创建一个类加载 器实例。我们知道,Context容器组件对应一个Web应用,因此,每个Context容器负责创建和维护一个 WebAppClassLoader加载器实例。这背后的原理是,不同的加载器实例加载的类被认为是不同的类,即使 它们的类名相同。这就相当于在Java虚拟机内部创建了一个个相互隔离的Java类空间,每一个Web应用都有 自己的类空间,Web应用之间通过各自的类加载器互相隔离
我们再来看第2个问题,本质需求是两个Web应用之间怎么共享库类,并且不能重复加载相同的类。我们知 道,在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的 加载路径下不就行了吗,应用程序也正是通过这种方式共享JRE的核心类。
因此Tomcat的设计者又加了一个 类加载器SharedClassLoader,作为WebAppClassLoader的父加载器,专门来加载Web应用之间共享的类。 如果WebAppClassLoader自己没有加载到某个类,就会委托父加载器SharedClassLoader去加载这个类, SharedClassLoader会在指定目录下加载共享类,之后返回给WebAppClassLoader,这样共享的问题就解决 了。
我们来看第3个问题,如何隔离Tomcat本身的类和Web应用的类?
我们知道,要共享可以通过父子关系,要 隔离那就需要兄弟关系了。兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,但是两 个兄弟类加载器加载的类是隔离的。基于此Tomcat又设计一个类加载器CatalinaClassloader,专门来加载 Tomcat自身的类。
这样设计有个问题,那Tomcat和各Web应用之间需要共享一些类时该怎么办呢?
老办法,还是再增加一个CommonClassLoader,作为CatalinaClassloader和SharedClassLoader的父加载 器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader 使用,而 CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离。WebAppClassLoader可以使用 SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。
思考下面一个问题
如果tomcat的不同应用引用了不同版本的spring依赖,sharedClassloader 怎么区分不同版本呢?
答:这种情况就不是公共类库了,应该放到各Web应用的路径下去
4.线程上下文加载器
在JVM的实现中有一条隐含的规则,默认情况下,如果一个类由类加载器A加载,那么这个类的依赖类也是 由相同的类加载器加载。比如Spring作为一个Bean工厂,它需要创建业务类的实例,并且在创建业务类实 例之前需要加载这些类。
Spring是通过调用Class.forName来加载业务类的,我们来看一下forName的源 码:
可以看到在forName的函数里,会用调用者也就是Spring的加载器去加载业务类。
我在前面提到,Web应用之间共享的JAR包可以交给SharedClassLoader来加载,从而避免重复加载。Spring 作为共享的第三方JAR包,它本身是由SharedClassLoader来加载的,Spring又要去加载业务类,按照前面那 条规则,加载Spring的类加载器也会用来加载业务类,但是业务类在Web应用目录下,不在 SharedClassLoader的加载路径下,这该怎么办呢?
于是线程上下文加载器登场了,它其实是一种类加载器传递机制。
为什么叫作“线程上下文加载器”呢,因 为这个类加载器保存在线程私有数据里,只要是同一个线程,一旦设置了线程上下文加载器,在线程后续执 行过程中就能把这个类加载器取出来用。因此Tomcat为每个Web应用创建一个WebAppClassLoarder类加载 器,并在启动Web应用的线程里设置线程上下文加载器,这样Spring在启动时就将线程上下文加载器取出 来,用来加载Bean。
Spring取线程上下文加载的代码如下:
cl = Thread.currentThread().getContextClassLoader();
线程上下文加载器不仅仅可以用在Tomcat和Spring类加载的场景里,核心框架类需要加载具体实现类时都 可以用到它,比如我们熟悉的JDBC就是通过上下文类加载器来加载不同的数据库驱动的,感兴趣的话可以 深入了解一下。
在StandardContext的启动方法里,会将当前线程的上下文加载器设置为WebAppClassLoader
在启动方法结束的时候,还会恢复线程的上下文加载器:
这是为什么呢?
线程上下文加载器其实是线程的一个私有数据,跟线程绑定的,这个线程做完启动Context组件的事情后 ,会被回收到线程池,之后被用来做其他事情,为了不影响其他事情,需要恢复之前的线程上下文加载器 。
所以比如说我在加载spring的线程设置为webappclassloader那么就算spring的jar 是由shared classloader加载的,那么spring加载的过程中也是由webappclassloader来加载,而用完设置 回去,是因为我只需要跨classloader的时候才需要线程上下文加载器。
结合类加载器层次结构可以分析Tomcat类加载过程
具体的加载逻辑位于WebAppClassLoaderBase.loadClass()方法中,代码篇幅长。
1.先在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在resourceEntries这个数据结构中),如果已经加载即返回,否则 继续下一步。
2.让系统类加载器(AppClassLoader)尝试加载该类,此时还是用双亲委派模型,主要是为了防止一些基础类会被web中的类覆盖,如果加载到即返回,返回继续。
3.前两步均没加载到目标类,那么web应用的类加载器将自行加载,如果加载到则返回,否则继续下一步。
4.最后还是加载不到的话,则委托父类加载器(Common ClassLoader)去加载。主要加载一些公用的如tomcat自带的jar包。
第3第4两个步骤的顺序已经违反了双亲委托机制,除了tomcat之外,JDBC,JNDI,Thread.currentThread().setContextClassLoader();等很多地方都一样是违反了双亲委托。
4.tomcat启动优化
我们在使用Tomcat时可能会碰到启动比较慢的问题,比如我们的系统发布新版本上线时,可能需要重启服 务,这个时候我们希望Tomcat能快速启动起来提供服务。
其实关于如何让Tomcat启动变快,官方网站有专 门的文章来介绍这个话题。
下面我也针对Tomcat 8.5和9.0版本,给出几条非常明确的建议,可以现学现 用。
1.禁止Tomcat TLD扫描
Tomcat为了支持JSP,在应用启动的时候会扫描JAR包里面的TLD文件,加载里面定义的标签库,所以在 Tomcat的启动日志里,你可能会碰到这种提示:
At least one JAR was scanned for TLDs yet contained no TLDs. Enable debug logging for this logger for a complete list of JARs that were scanned but no TLDs were found in them. Skipping unneeded JARs during scanning can improve startup time and JSP compilation time
Tomcat的意思是,我扫描了你Web应用下的JAR包,发现JAR包里没有TLD文件。我建议配置一下Tomcat不要去扫描这些JAR包,这样可以提高Tomcat的启动速度,并节省JSP编译时间。
那如何配置不去扫描这些JAR包呢,这里分两种情况:
1.如果你的项目没有使用JSP作为Web页面模板,而是使用Velocity之类的模板引擎,你完全可以把TLD扫描 禁止掉。
方法是,找到Tomcat的conf/目录下的context.xml文件,在这个文件里Context标签下,加 上JarScanner和JarScanFilter子标签,像下面这样。
2.如果你的项目使用了JSP作为Web页面模块,意味着TLD扫描无法避免,但是我们可以通过配置来告诉 Tomcat,只扫描那些包含TLD文件的JAR包。方法是,找到Tomcat的conf/目录下的 catalina.properties文件,在这个文件里的jarsToSkip配置项中,加上你的JAR包
tomcat.util.scan.StandardJarScanFilter.jarsToSkip=xxx.jar
2.关闭WebSocket支持
Tomcat会扫描WebSocket注解的API实现,比如@ServerEndpoint注解的类。我们知道,注解扫描一般是 比较慢的,如果不需要使用WebSockets就可以关闭它。具体方法是,找到Tomcat的conf/目录下的 context.xml文件,给Context标签加一个containerSciFilter的属性,像下面这样。
更进一步,如果你不需要WebSockets这个功能,你可以把Tomcat lib目录下的websocket-api.jar和 tomcat-websocket.jar这两个JAR文件删除掉,进一步提高性能。
3.关闭JSP支持
跟关闭WebSocket一样,如果你不需要使用JSP,可以通过类似方法关闭JSP功能,像下面这样。
我们发现关闭JSP用的也是containerSciFilter属性,如果你想把WebSocket和JSP都关闭,那就这样配置:
4.禁止Servlet注解扫描
Servlet 3.0引入了注解Servlet,Tomcat为了支持这个特性,会在Web应用启动时扫描你的类文件,因此如果 你没有使用Servlet注解这个功能,可以告诉Tomcat不要去扫描Servlet注解。具体配置方法是,在你的Web 应用的web.xml文件中,设置元素的属性metadata-complete="true",像下面这样。
metadata-complete的意思是,web.xml里配置的Servlet是完整的,不需要再去库类中找Servlet的定 义。
5.热加载与热部署
要在运行的过程中升级Web应用,如果你不想重启系统,实现的方式有 两种:热加载和热部署。
那如何实现热部署和热加载呢?
它们跟类加载机制有关,具体来说就是:
- 热加载的实现方式是Web容器启动一个后台线程,定期检测类文件的变化,如果有变化,就重新加载类, 在这个过程中不会清空Session ,一般用在开发环境。
- 热部署原理类似,也是由后台线程定时检测Web应用的变化,但它会重新加载整个Web应用。这种方式会 清空Session,比热加载更加干净、彻底,一般用在生产环境。
热加载的粒度比较小,主要是针对类文件的更新,通过创建新的类加载器来实现重新加载。而热部署是针对 整个Web应用的,Tomcat会将原来的Context对象整个销毁掉,再重新创建Context容器对象。
Tomcat通过开启后台线程,使得各个层次的容器组件都有机会完成一些周期性任务。我们在实际工作中,往往也需要执行一些周期性的任 务,比如监控程序周期性拉取系统的健康状态,就可以借鉴这种设计。
热加载和热部署的实现都离不开后台线程的周期性检查,Tomcat在基类ContainerBase中统一实现了后台线 程的处理逻辑,并在顶层容器Engine启动后台线程,这样子容器组件甚至各种通用组件都不需要自己去创建 后台线程,这样的设计显得优雅整洁。
1.Tomcat热加载
基于ContainerBase的周期性任务处理“框架”,作为具体容器子类,只需要实现自己的周期性任务就行。 而Tomcat的热加载,就是在Context容器中实现的。
Context容器的backgroundProcess方法是这样实现的:
从上面的代码我们看到Context容器通过WebappLoader来检查类文件是否有更新,通过Session管理器来检查是否有Session过期,并且通过资源管理器来检查静态资源是否有更新,最后还调用了父类ContainerBase 的backgroundProcess方法。
这里我们要重点关注,WebappLoader是如何实现热加载的,它主要是调用了Context容器的reload方法, 而Context的reload方法比较复杂,总结起来,主要完成了下面这些任务:
1. 停止和销毁Context容器及其所有子容器,子容器其实就是Wrapper,也就是说Wrapper里面Servlet实例 也被销毁了。
2. 停止和销毁Context容器关联的Listener和Filter。
3. 停止和销毁Context下的Pipeline和各种Valve。
4. 停止和销毁Context的类加载器,以及类加载器加载的类文件资源。
5. 启动Context容器,在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中,类加载器发挥着关键作用。一个Context容器对应一个类加载器,类加载器在销毁的过程中 会把它加载的所有类也全部销毁。Context容器在启动过程中,会创建一个新的类加载器来加载新的类文 件。
在Context的reload方法里,并没有调用Session管理器的distroy方法,也就是说这个Context关联的Session 是没有销毁的。
你还需要注意的是,Tomcat的热加载默认是关闭的,你需要在conf目录下的Context.xml文 件中设置reloadable参数来开启这个功能,像下面这样:
2.Tomcat热部署
我们再来看看热部署,热部署跟热加载的本质区别是,热部署会重新部署Web应用,原来的Context对象会 整个被销毁掉,因此这个Context所关联的一切资源都会被销毁,包括Session。
那么Tomcat热部署又是由哪个容器来实现的呢?
应该不是由Context,因为热部署过程中Context容器被销 毁了,那么这个重担就落在Host身上了,因为它是Context的父容器。
跟Context不一样,Host容器并没有在backgroundProcess方法中实现周期性检测的任务,而是通过监听器 HostConfig来实现的,HostConfig就是前面提到的“周期事件”的监听器。
那“周期事件”达到时, HostConfig会做什么事呢?
它主要执行了check方法,我们接着来看check方法里做了什么。
其实HostConfig会检查webapps目录下的所有Web应用:
- 如果原来Web应用目录被删掉了,就把相应Context容器整个销毁掉。
- 是否有新的Web应用目录放进来了,或者有新的WAR包放进来了,就部署相应的Web应用。
因此HostConfig做的事情都是比较“宏观”的,它不会去检查具体类文件或者资源文件是否有变化,而是检 查Web应用目录级别的变化。
核心组件的关联
1、整体关系
Server元素在最顶层,代表整个Tomcat容器;一个Server元素中可以有一个或多个Service元素。
Service在Connector和Engine外面包了一层,把它们组装在一起,对外提供服务。一个Service可以包含多个Connector,但是只能包含一个Engine;Connector接收请求,Engine处理请求。
Engine、Host和Context都是容器,且 Engine包含Host,Host包含Context。每个Host组件代表Engine中的一个虚拟主机;每个Context组件代表在特定Host上运行的一个Web应用。
2、如何确定请求由谁处理?
当请求被发送到Tomcat所在的主机时,如何确定最终哪个Web应用来处理该请求呢?
(1)根据协议和端口号选定Service和Engine
Service中的Connector组件可以接收特定端口的请求,因此,当Tomcat启动时,Service组件就会监听特定的端口。在第一部分的例子中,Catalina这个Service监听了8080端口(基于HTTP协议)和8009端口(基于AJP协议)。当请求进来时,Tomcat便可以根据协议和端口号选定处理请求的Service;Service一旦选定,Engine也就确定。
通过在Server中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用。
(2)根据域名或IP地址选定Host
Service确定后,Tomcat在Service中寻找名称与域名/IP地址匹配的Host处理该请求。如果没有找到,则使用Engine中指定的defaultHost来处理该请求。在第一部分的例子中,由于只有一个Host(name属性为localhost),因此该Service/Engine的所有请求都交给该Host处理。
(3)根据URI选定Context/Web应用
这一点在Context一节有详细的说明:Tomcat根据应用的 path属性与URI的匹配程度来选择Web应用处理相应请求,这里不再赘述。
(4)举例
以请求http://localhost:8080/app1/index.html为例,首先通过协议和端口号(http和8080)选定Service;然后通过主机名(localhost)选定Host;然后通过uri(/app1/index.html)选定Web应用。
Tomcat Server处理一个HTTP请求的过程
Tomcat Server处理一个HTTP请求的过程
1、用户点击网页内容,请求被发送到本机端口8080,被在那里监听的Coyote HTTP/1.1 Connector获得。
2、Connector把该请求交给它所在的Service的Engine来处理,并等待Engine的回应。
3、Engine获得请求localhost/test/index.jsp,匹配所有的虚拟主机Host。
4、Engine匹配到名为localhost的Host(即使匹配不到也把请求交给该Host处理,因为该Host被定义为该Engine的默认主机),名为localhost的Host获得请求/test/index.jsp,匹配它所拥有的所有的Context。Host匹配到路径为/test的Context(如果匹配不到就把该请求交给路径名为“ ”的Context去处理)。
5、path=“/test”的Context获得请求/index.jsp,在它的mapping table中寻找出对应的Servlet。Context匹配到URL PATTERN为*.jsp的Servlet,对应于JspServlet类。
6、构造HttpServletRequest对象和HttpServletResponse对象,作为参数调用JspServlet的doGet()或doPost().执行业务逻辑、数据存储等程序。
7、Context把执行完之后的HttpServletResponse对象返回给Host。
8、Host把HttpServletResponse对象返回给Engine。
9、Engine把HttpServletResponse对象返回Connector。
10、Connector把HttpServletResponse对象返回给客户Browser。
Connector处理请求流程
无论是BIO,还是NIO,Connector处理请求的大致流程是一样的:
1.在accept队列中接收连接(当客户端向服务器发送请求时,如果客户端与OS完成三次握手建立了连接,则OS将该连接放入accept队列);
2.在accept队列连接中获取请求的数据,生成request;
3.调用servlet容器处理请求;
4.返回response。
为了便于后面的说明,首先明确一下连接与请求的关系:连接是TCP层面的(传输层),对应socket;请求是HTTP层面的(应用层),必须依赖于TCP的连接实现;一个TCP连接中可能传输多个HTTP请求。
BIO方式
在BIO实现的Connector中,处理请求的主要实体是JIoEndpoint对象。JIoEndpoint维护了Acceptor和Worker:Acceptor接收socket,然后从Worker线程池中找出空闲的线程处理socket,如果worker线程池没有空闲线程,则Acceptor将阻塞。其中Worker是Tomcat自带的线程池,如果通过<Executor>配置了其他线程池,原理与Worker类似。其模型可以参考下图:
NIO方式
在NIO实现的Connector中,处理请求的主要实体是NIoEndpoint对象。NIoEndpoint中除了包含Acceptor和Worker外,还是用了Poller,处理流程如下图
Acceptor接收socket后,不是直接使用Worker中的线程处理请求,而是先将请求发送给了Poller,而Poller是实现NIO的关键。Acceptor向Poller发送请求通过队列实现,使用了典型的生产者-消费者模式。在Poller中,维护了一个Selector对象;当Poller从队列中取出socket后,注册到该Selector中;然后通过遍历Selector,找出其中可读的socket,并使用Worker中的线程处理相应请求。与BIO类似,Worker也可以被自定义的线程池代替。
其模型可参考下图:
通过上述过程可以看出,在NIoEndpoint处理请求的过程中,无论是Acceptor接收socket,还是线程处理请求,使用的仍然是阻塞方式;但在“读取socket并交给Worker中的线程”的这个过程中,使用非阻塞的NIO实现,这是NIO模式与BIO模式的最主要区别(其他区别对性能影响较小,暂时略去不提)。而这个区别,在并发量较大的情形下可以带来Tomcat效率的显著提升。
博主理解:
nio方式在读取socket并交给Worker中的线程中主要有两大区别:
1.中间引入了队列实现异步效果
2.使用多路复用IO模型,即Reactor模式
目前大多数HTTP请求使用的是长连接(HTTP/1.1默认keep-alive为true),而长连接意味着,一个TCP的socket在当前请求结束后,如果没有新的请求到来,socket不会立马释放,而是等timeout后再释放。如果使用BIO,“读取socket并交给Worker中的线程”这个过程是阻塞的,也就意味着在socket等待下一个请求或等待释放的过程中,处理这个socket的工作线程会一直被占用,无法释放;因此Tomcat可以同时处理的socket数目不能超过最大线程数,性能受到了极大限制。而使用NIO,“读取socket并交给Worker中的线程”这个过程是非阻塞的,当socket在等待下一个请求或等待释放时,并不会占用工作线程,因此Tomcat可以同时处理的socket数目远大于最大线程数,并发性能大大提高。
六、Jetty对比
1.线程模型
相比较Tomcat的连接器,Jetty的Connector在设计上有自己的特点。Jetty的Connector支持NIO通信模型, 我们知道NIO模型中的主角就是Selector,Jetty在Java原生Selector的基础上封装了自己的Selector,叫作 ManagedSelector。ManagedSelector在线程策略方面做了大胆尝试,将I/O事件的侦测和处理放到同一个线 程来处理,充分利用了CPU缓存并减少了线程上下文切换的开销。
具体的数字是,根据Jetty的官方测试,这种名为“EatWhatYouKill”的线程策略将吞吐量提高了8倍
1.Selector编程的一般思路
常规的NIO编程思路是,将I/O事件的侦测和请求的处理分别用不同的线程处理。
具体过程是: 启动一个线程,在一个死循环里不断地调用select方法,检测Channel的I/O状态,一旦I/O事件达到,比如 数据就绪,就把该I/O事件以及一些数据包装成一个Runnable,将Runnable放到新线程中去处理。
在这个过程中按照职责划分,有两个线程在干活,一个是I/O事件检测线程,另一个是I/O事件处理线程。我 们仔细思考一下这两者的关系,其实它们是生产者和消费者的关系。I/O事件侦测线程作为生产者,负 责“生产”I/O事件,也就是负责接活儿的老板;I/O处理线程是消费者,它“消费”并处理I/O事件,就是 干苦力的员工。
把这两个工作用不同的线程来处理,好处是它们互不干扰和阻塞对方。
2.Jetty中的Selector编程
然而世事无绝对,将I/O事件检测和业务处理这两种工作分开的思路也有缺点。当Selector检测读就绪事件 时,数据已经被拷贝到内核中的缓存了,同时CPU的缓存中也有这些数据了,我们知道CPU本身的缓存比内 存快多了,这时当应用程序去读取这些数据时,如果用另一个线程去读,很有可能这个读线程使用另一个 CPU核,而不是之前那个检测数据就绪的CPU核,这样CPU缓存中的数据就用不上了,并且线程切换也需要 开销。
因此Jetty的Connector做了一个大胆尝试,那就是用把I/O事件的生产和消费放到同一个线程来处理,如果 这两个任务由同一个线程来执行,如果执行过程中线程不阻塞,操作系统会用同一个CPU核来执行这两个任 务,这样就能利用CPU缓存了。那具体是如何做的呢,我们还是来详细分析一下Connector中的 ManagedSelector组件。
ManagedSelector将I/O事件的生产和消费看作是生产者消费者模式,为了充分利用CPU缓存,生产和消费 尽量放到同一个线程处理,那这是如何实现的呢?
Jetty定义了ExecutionStrategy接口:
我们看到ExecutionStrategy接口比较简单,它将具体任务的生产委托内部接口Producer,而在自己的 produce方法里来实现具体执行逻辑,也就是生产出来的任务要么由当前线程执行,要么放到新线程中执 行。
Jetty提供了一些具体策略实现类:ProduceConsume、ProduceExecuteConsume、 ExecuteProduceConsume和EatWhatYouKill。
它们的区别是:
ProduceConsume:任务生产者自己依次生产和执行任务,对应到NIO通信模型就是用一个线程来侦测和 处理一个ManagedSelector上所有的I/O事件,后面的I/O事件要等待前面的I/O事件处理完,效率明显不高,但比较节约线程资源类似于单线程模型。
通过图来理解,图中绿色表示生产一个任务,蓝色表示执行这个任务。
ProduceExecuteConsume:任务生产者开启新线程来运行任务,这是典型的I/O事件侦测和处理用不同的线程来处理,这种io事件线程利用率比较高相对节约线程资源。缺点是不能利用CPU缓存,并且线程切换成本高。
同样我们通过一张图来理解,图中的棕色 表示线程切换。
ExecuteProduceConsume:任务生产者自己运行任务,但是该策略可能会新建一个新线程以继续生产和 执行任务。这种策略也被称为“吃掉你杀的猎物”,它来自狩猎伦理,认为一个人不应该杀死他不吃掉的 东西,对应线程来说,不应该生成自己不打算运行的任务。
它的优点是能利用CPU缓存,但是潜在的问题 是如果处理I/O事件的业务代码执行时间过长,会导致线程大量阻塞和线程饥饿,这和一个线程对应一个io事件模型比较相似,我认为非常的消耗线程资源。
EatWhatYouKill:这是Jetty对ExecuteProduceConsume策略的改良,在线程池线程充足的情况下等同于 ExecuteProduceConsume;当系统比较忙线程不够时,切换成ProduceExecuteConsume策略。
为什么要 这么做呢,原因是ExecuteProduceConsume是在同一线程执行I/O事件的生产和消费,它使用的线程来自 Jetty全局的线程池,这些线程有可能被业务代码阻塞,如果阻塞得多了,全局线程池中的线程自然就不够 用了,最坏的情况是连I/O事件的侦测都没有线程可用了,会导致Connector拒绝浏览器请求。于是Jetty 做了一个优化,在低线程情况下,就执行ProduceExecuteConsume策略,I/O侦测用专门的线程处理, I/O事件的处理扔给线程池处理,其实就是放到线程池的队列里慢慢处理。
分析了这几种线程策略,我们再来看看Jetty是如何实现ExecutionStrategy接口的。答案其实就是实现 produce接口生产任务,一旦任务生产出来,ExecutionStrategy会负责执行这个任务。
SelectorProducer是ManagedSelector的内部类,SelectorProducer实现了ExecutionStrategy中的Producer 接口中的produce方法,需要向ExecutionStrategy返回一个Runnable。在这个方法里SelectorProducer主要 干了三件事情
1. 如果Channel集合中有I/O事件就绪,调用前面提到的Selectable接口获取Runnable,直接返回给 ExecutionStrategy去处理。
2. 如果没有I/O事件就绪,就干点杂活,看看有没有客户提交了更新Selector上事件注册的任务,也就是上 面提到的SelectorUpdate任务类。
3. 干完杂活继续执行select方法,侦测I/O就绪事件。
2.对象池
Java对象,特别是一个比较大、比较复杂的Java对象,它们的创建、初始化和GC都需要耗费CPU和内存资 源,为了减少这些开销,Tomcat和Jetty都使用了对象池技术。
所谓的对象池技术,就是说一个Java对象用 完之后把它保存起来,之后再拿出来重复使用,省去了对象创建、初始化和GC的过程。对象池技术是典型 的以空间换时间的思路。
由于维护对象池本身也需要资源的开销,不是所有场景都适合用对象池。如果你的Java对象数量很多并且存 在的时间比较短,对象本身又比较大比较复杂,对象初始化的成本比较高,这样的场景就适合用对象池技 术。比如Tomcat和Jetty处理HTTP请求的场景就符合这个特征,请求的数量很多,为了处理单个请求需要创 建不少的复杂对象(比如Tomcat连接器中SocketWrapper 和SocketProcessor),而且一般来说请求处理的时间比较短,一旦请求处理完毕,这些对象就需要被销 毁,因此这个场景适合对象池技术。
对象池作为全局资源,高并发环境中多个线程可能同时需要获取对象池中的对象,因此多个线程在争抢对象 时会因为锁竞争而阻塞, 因此使用对象池有线程同步的开销,而不使用对象池则有创建和销毁对象的开 销。对于对象池本身的设计来说,需要尽量做到无锁化,比如Jetty就使用了ConcurrentLinkedDeque。如果 你的内存足够大,可以考虑用线程本地(ThreadLocal)对象池,这样每个线程都有自己的对象池,线程之 间互不干扰。
为了防止对象池的无限膨胀,必须要对池的大小做限制。对象池太小发挥不了作用,对象池太大的话可能有 空闲对象,这些空闲对象会一直占用内存,造成内存浪费。这里你需要根据实际情况做一个平衡,因此对象 池本身除了应该有自动扩容的功能,还需要考虑自动缩容。
所有的池化技术,包括缓存,都会面临内存泄露的问题,原因是对象池或者缓存的本质是一个Java集合类, 比如List和Stack,这个集合类持有缓存对象的引用,只要集合类不被GC,缓存对象也不会被GC。维持大量 的对象也比较占用内存空间,所以必要时我们需要主动清理这些对象。以Java的线程池ThreadPoolExecutor 为例,它提供了allowCoreThreadTimeOut和setKeepAliveTime两种方法,可以在超时后销毁线程,我们在 实际项目中也可以参考这个策略。
另外在使用对象池时,我这里还有一些小贴士供你参考:
- 对象在用完后,需要调用对象池的方法将对象归还给对象池。
- 对象池中的对象在再次使用时需要重置,否则会产生脏对象,脏对象可能持有上次使用的引用,导致内存 泄漏等问题,并且如果脏对象下一次使用时没有被清理,程序在运行过程中会发生意想不到的问题。
- 对象一旦归还给对象池,使用者就不能对它做任何操作了。
- 向对象池请求对象时有可能出现的阻塞、异常或者返回null值,这些都需要我们做一些额外的处理,来确 保程序的正常运行。
所以对象池技术一般使用在比较成熟的中间件内部里,再业务代码里慎用对象池技术,因为业务服务器的硬件配置大多满足业务需求,且对象池的管理内部还是有一定的复杂度,开发成本较高。
1.SynchronizedStack----tomcat
Tomcat用SynchronizedStack类来实现对象池,下面我贴出它的关键代码来帮助你理解。
这个代码逻辑比较清晰,主要是SynchronizedStack内部维护了一个对象数组,并且用数组来实现栈的接 口:push和pop方法,这两个方法分别用来归还对象和获取对象。
你可能好奇为什么Tomcat使用一个看起 来比较简单的SynchronizedStack来做对象容器,为什么不使用高级一点的并发容器比如 ConcurrentLinkedQueue呢?
这是因为SynchronizedStack用数组而不是链表来维护对象,可以减少结点维护的内存开销,并且它本身只 支持扩容不支持缩容,也就是说数组对象在使用过程中不会被重新赋值,也就不会被GC。这样设计的目的 是用最低的内存和GC的代价来实现无界容器,同时Tomcat的最大同时请求数是有限制的,因此不需要担心 对象的数量会无限膨胀。
2.ByteBufferPool----Jetty
我们再来看Jetty中的对象池ByteBufferPool,它本质是一个ByteBuffer对象池。当Jetty在进行网络数据读写 时,不需要每次都在JVM堆上分配一块新的Buffer,只需在ByteBuffer对象池里拿到一块预先分配好的 Buffer,这样就避免了频繁的分配内存和释放内存。
这种设计你同样可以在高性能通信中间件比如Mina和 Netty中看到。
你可以通过下面的图再来理解一下:
ByteBufferPool是用不同的桶(Bucket)来管理不同长度的ByteBuffer,因为我们 可能需要分配一块1024字节的Buffer,也可能需要一块64K字节的Buffer。而桶的内部用一个 ConcurrentLinkedDeque来放置ByteBuffer对象的引用。
而Buffer的分配和释放过程,就是找到相应的桶,并对桶中的Deque做出队和入队的操作,而不是直接向 JVM堆申请和释放内存。
源码解析
1.源码阅读
1.内嵌jar包
可以参考我的gitee地址
2.下载压缩包
你可以通过内嵌的jar包或者直接下载压缩包进行源码阅读,两者之间的对应关系如下图所示
参考资料
1.详解Tomcat 配置文件server.xml http://www.cnblogs.com/kismetv/p/7228274.html
2.详解tomcat的连接数与线程池 http://www.cnblogs.com/kismetv/p/7806063.html
3.解析Tomcat内部结构和请求过程https://www.cnblogs.com/zhouyuqin/p/5143121.html4.极客时间《深入拆解 Tomcat & Jetty》