Tomcat
WEB技术
- 早期的web应用主要用于浏览新闻等静态页面,用户通过HTTP协议请求服务器上的静态页面,服务器上的web服务器软件接收到请求后,读取URI标示的资源,再加上消息报头发送给客户端浏览器,浏览器负责解析HTML,将结果呈现出来。
- 随着时间发展,用户需要一些交互操作,获取一些动态结果;所以需要一些扩展机制来实现用户想要的功能;早期使用的Web服务器扩展机制是CGI(Common Gateway Interface,公共网关接口)。
- CGI:是外部应用程序(CGI程序)与Web服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的规程;使用这种方法,用户单击某个链接或输入网址来访问CGI程序,web服务器收到请求后,运行该CGI程序,对用户请求进行处理,紧接着将处理结果并产生一个响应,该响应被返回给web服务器,web服务器对响应进行包装,以HTTP响应的方式返回给浏览器。
- CGI程序在一定程度上解决了用户需求。不过还存在一些不足之处,如CGI程序编写困难,响应时间较长,以进程方式运行导致性能受限;个人理解,就是java应用socket技术实现网络通信,使client与server进行交互的一个缩影。
SERVLET接口
浏览器发给服务端的是一个HTTP格式的请求,HTTP服务器收到这个请求后,需要调用服务端程序来处理,所谓的服务端程序就是写的Java类(对于java开发来说啊),一般来说不同的请求需要由不同的Java类来处理。
但是,HTTP服务器怎么知道要调用哪个Java类的哪个方法呢????
咱们继续推进,最直接的办法就是在HTTP服务器里面硬编码来判断哪个请求调用哪个类的哪个方法不就OK了吗。
虽然可以解决调用问题,但这样做明显存在其他问题啊
因为HTTP服务器的代码跟业务逻辑耦合在一起了,如果新加一个业务方法还要改HTTP服务器的代码,高度耦合。
于是乎,面向接口编程这个解决耦合问题的法宝便出现了
定义了一个接口,各种业务类都必须实现这个接口,这个接口就叫Servlet接口;我们通常把实现了Servlet接口的业务类叫作Servlet。
但是情况不容乐观这里还有问题啊,对于特定的请求呢,HTTP服务器如何知道由哪个Servlet来处理呢?Servlet又该由谁初始化呢?
HTTP服务器很显然不适合做这个工作,不然又跟业务代码耦合在一起了。
顺理成章Servlet容器变风光上场了!!!
Servlet与Servlet容器
- 于是1997年,sun公司推出了Servlet技术,作为java阵营的CGI解决方案。
- Servlet是平台独立的Java类,编写一个Servlet,实际上就是按照Servlet规范编写一个Java类。Servlet被编译为平台独立的字节码,可以被动态地加载到支持Java技术的Web服务器中运行。
- Servlet就是一个普普通通的Java类,它没有main方法,不能独立运行;这时候就需要解决服务器如何来运行这个Servlet程序。
- 为了运行Servlet程序,Servlet容器便出现了。
Servlet容器的主要作用是:
Servlet容器用来加载和管理业务类。
Servlet容器工作模式的不同,Servlet容器有以下分类:
1> 独立的Servlet容器
当我们使用基于Java技术的Web服务器时,Servlet容器作为构成Web服务器的一部分而存在;然而大多数的Web服务器并非基于Java语言,因此,就有了下面两种Servlet容器的工作模式。
2> 进程内的Servlet容器
Servlet容器由Web服务器插件和Java容器两部分实现组成。Web服务器插件在某个Web服务器内部地址空间中打开一个JVM(Java虚拟机),使得Java容器可以在此JVM中加载并运行Servlet。如有客户端调用Servlet的请求到来,插件取得对此请求的控制并将它传递(使用JNI技术)给Java容器,然后由Java容器将此请求交由Servlet进行处理。**进程内的Servlet容器对于单进程、多线程的服务器非常适合,提供了较高的运行速度,但伸缩性有所不足**。JNI 即Java native interface,是一种技术,提供了丰富的接口,可以在Java层调用native代码,也可以在native层调用Java代码,native代码一般是指C/C++程序或者其他语音。
3> 进程外的Servlet容器
Servlet容器运行于Web服务器之外的地址空间,它也是由Web服务器插件和Java容器两部分实现组成的。Web服务器插件和Java容器(在外部JVM中运行)使用IPC机制(通常是TCP/IP)进行通信。当一个调用Servlet的请求到达时,插件取得对此请求的控制并将其传递(使用IPC机制)给Java容器。进程外Servlet容器对客户请求的响应速度不如进程内的Servlet容器,但进程外容器具有更好的伸缩性和稳定性。IPC机制 Inter-Process Communication 多个进程间的通信机制,两个进程间进行数据交互的过程;在linux下有多种进程间通信的方法:半双工管道、命名管道、消息队列、信号、信号量、共享内存、内存映射文件,socket等等。
HTTP服务器和Servlet容器完美组合,就满足了业务需求!!!
建立socket连接
调用Servlet处理业务逻辑
响应数据
关闭socket连接
此时,Servlet接口和Servlet容器这一整套规范变成了Servlet规范,Tomcat按照这个规范实现了Servlet容器同时增加了HTTP服务能力,轻量级的WEB服务器Tomcat随即问世了。
TOMCAT容器
Tomcat是一个免费的开放源的Servlet容器,并且具有处理HTTP请求的能力,因此称它为轻量级的WEB服务器。
让我们看一下Tomcat工作原理图
简单回顾下Servlet的使用
Web应用的目录结构
| - MyWebApp
| - WEB-INF/web.xml -- 配置文件,用来配置Servlet等
| - WEB-INF/lib/ -- 存放Web应用所需各种JAR包
| - WEB-INF/classes/ -- 存放你的应用类,比如Servlet类
| - META-INF/ -- 目录存放工程的一些信息
下面简单介绍一下这些目录:
/bin:存放Windows或Linux平台上启动和关闭Tomcat的脚本文件。
/conf:存放Tomcat的各种全局配置文件,其中最重要的是server.xml。
/lib:存放Tomcat以及所有Web应用都可以访问的JAR文件。
/logs:存放Tomcat执行时产生的日志文件。
/work:存放JSP编译后产生的Class文件。
/webapps:Tomcat的Web应用目录,默认情况下把Web应用放在这个目录下。
catalina.***.log
主要是记录Tomcat启动过程的信息,在这个文件可以看到启动的JVM参数以及操作系统等日志信息。
catalina.out
是Tomcat的标准输出(stdout)和标准错误(stderr),这是在Tomcat的启动脚本里指定的,如果没有修改的话stdout和stderr会重定向到这里。
localhost.**.log
主要记录Web应用在初始化过程中遇到的未处理的异常,会被Tomcat捕获而输出这个日志文件。
localhost_access_log.**.txt
存放访问Tomcat的请求日志,包括IP地址以及请求的路径、时间、请求协议以及状态码等信息。
manager.***.log/host-manager.***.log
存放Tomcat自带的Manager项目的日志信息。
javac -cp ./servlet-api.jar MyServlet.java
##分析Tomcat的设计思路 Tomcat总体架构
处理Socket连接,负责网络字节流与Request和Response对象的转化。
加载和管理Servlet,以及具体处理Request请求。
因此Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。
下面我们就先聊聊连接器(Connector)
做个铺垫:
Tomcat支持的I/O模型有:
NIO:非阻塞I/O,采用Java NIO类库实现。
NIO.2:异步I/O,采用JDK 7最新的NIO.2类库实现。
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
Tomcat 支持的应用层协议有:
HTTP/1.1:这是大部分Web应用采用的访问协议。
AJP:用于和Web服务器集成(如 Apache)。
HTTP/2:HTTP2.0大幅度的提升了Web性能。
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门;但是单独的连接器或者容器都不能对外提供服务,需要把它们组装起来才能工作,组装后这个整体叫作Service组件; Tomcat内可能有多个Service,通过在Tomcat中配置多个Service,可以实现通过不同的端口号来访问同一台机器上部署的不同应用,但是Service本身没有做什么重要的事情,只是在连接器和容器外面多包了一层,把它们组装在一起;
此时,我们可以得出一张图
从图上你可以看到,最顶层是Server,这里的Server指的就是一个Tomcat实例。一个Server中有一个或者多个Service,一个Service中有多个连接器和一个容器。连接器与容器之间通过标准的ServletRequest和ServletResponse通信。
连接器对Servlet容器屏蔽了协议及I/O模型等的区别,在容器中获取到的都是一个标准的 ServletRequest和ServletResponse对象。
连接器的功能进一步细化
监听网络端口。
接受网络连接请求。
读取网络请求字节流。
根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的Tomcat Request对象。
将Tomcat Request对象转成标准的ServletRequest。
调用Servlet容器,得到ServletResponse。
将ServletResponse转成Tomcat Response对象。
将Tomcat Response转成网络字节流。
将响应字节流写回给浏览器。
如果让你设计连接器(Connector),你该如何设计,达到高内聚、低耦合的效果???
简单阐述下这两个概念:
高内聚是指相关度比较高的功能要尽可能集中,不要分散。
低耦合是指两个相关的模块要尽可能减少依赖的部分和降低依赖的程度,不要让两个模块产生强依赖。
好了,让我们看一看Tomcat是如何设计的??
有3个高内聚的功能:
网络通信。
应用层协议解析。
Tomcat Request/Response与ServletRequest/ServletResponse的转化。
因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是Endpoint、Processor和Adapter。
老规矩,看图说话
ProtocolHandler 来处理网络连接和应用层协议,包含了Endpoint和Processor两个重要组件。
Endpoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此Endpoint是用来实现TCP/IP协议的。
Processor用来实现HTTP协议等其他应用层协议,Processor接收来自Endpoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
Adapter组件的作用就是连接器调用CoyoteAdapter的sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的service方法。
好了,连接器(Connector)就先到这,接下来我们看一下容器(Container)!!
容器的层次结构
Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。 先简单的看一下面的图,形象直观的看一下Tomcat的容器的层次结构:
Context表示一个Web应用程序;
Wrapper表示一个Servlet,一个Web应用程序中可能会有多个Servlet;
Host代表的是一个虚拟主机,或者说一个站点,可以给Tomcat配置多个虚拟主机地址,而一个虚拟主机下可以部署多个Web应用程序;
Engine表示引擎,用来管理多个虚拟站点;
Service最多只能有一个Engine。
下面我们再从server.xml配置文件对Tomcat容器进一步探讨:
你会发现这些容器具有父子关系,形成一个树形结构,Tomcat就是用组合模式来管理这些容器的。这些容器都实现了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);
}
从接口定义上也进一步验证了这些容器具有父子关系;你可能还注意到Container接口扩展了 Lifecycle接口,它是用来统一管理Tomcat各组件的生命周期,在后面我会介绍它。
在使用Tomcat之初,不知道你是否存在好奇,在配置文件一通配置,它一启动拼接好请求连接就能访问到我们定义的Servlet,它是怎么定位到我们要请求的Servlet?
我先说一下答案吧,那就是Mapper组件。
/**
* Mapper, which implements the servlet API mapping rules (which are derived
* from the HTTP rules).
*
* @author Remy Maucherat
*/
public final class Mapper {}
从接口定义上不难看出他就是servlet API mapping rules。 Mapper组件的功能就是将用户请求的URL定位到一个Servlet,它的工作原理是:Mapper组件里保存了Web应用的配置信息,其实就是容器组件与访问路径的映射关系,比如Host容器里配置的域名、Context容器里的Web应用路径,以及Wrapper容器里Servlet映射的路径,你可以想象这些配置信息就是一个多层次的Map。
下面就总结一下Tomcat如何将一个URL定位到一个Servlet的过程:
首先,根据协议和端口号选定Service和Engine。
然后,根据域名选定Host。
之后,根据URL路径找到Context组件。
最后,根据URL路径找到Wrapper(Servlet)。
看到这里,我想大家应该已经了解了什么是容器,以及Tomcat如何通过一层一层的父子容器找到某个Servlet来处理请求。需要注意的是,并不是说只有Servlet才会去处理请求,实际上这个查找路径上的父子容器都会对请求做一些处理。连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。
那么这个调用过程具体是怎么实现的呢?
答案就是使用Pipeline-Valve管道。
Pipeline-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。
public class StandardPipeline extends LifecycleBase implements Pipeline {
// ------------------------------ Instance Variables
/**
* The basic Valve (if any) associated with this Pipeline.
*/
protected Valve basic = null;
/**
* The Container with which this Pipeline is associated.
*/
protected Container container = null;
/**
* The first valve associated with this Pipeline.
*/
protected Valve first = null;
}
我还是通过下面一张图来解释:
整个调用过程由连接器中的Adapter触发的,它会调用 Engine 的第一个 Valve:
/**
* Implementation of a request processor which delegates the processing to a
* Coyote processor.
*
* @author Craig R. McClanahan
* @author Remy Maucherat
*/
public class CoyoteAdapter implements Adapter {
@Override
public void service(org.apache.coyote.Request req, org.apache.coyote.Response res)
throws Exception {
// Calling the container
connector.getService().getContainer().getPipeline()
.getFirst().invoke(request, response);
}
}
Tomcat为了提高处理能力和并发度,把处理请求的工作放到线程池里来执行,那么Tomcat是如何扩展Java线程池的??
先看一下java原生的线程池核心类ThreadPoolExecutor的构造函数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler){}
每次提交任务时,如果线程数还没达到核心线程数corePoolSize,线程池就创建新线程来执行。
当线程数达到corePoolSize后,新增的任务就放到工作队列workQueue里,而线程池中的线程则努力地从workQueue里拉活来干,也就是调用poll方法来获取任务。
如果任务很多,并且workQueue是个有界队列,队列可能会满,此时线程池就会紧急创建新的临时线程来救场,如果总的线程数达到了最大线程数maximumPoolSize,则不能再创建新的临时线程了,转而执行拒绝策略handler,比如抛出异常或者由调用者线程来执行任务等。
如果高峰过去了,线程池比较闲了怎么办?临时线程使用poll(keepAliveTime, unit)方法从工作队列中拉活干,请注意poll方法设置了超时时间,如果超时了仍然两手空空没拉到活,表明它太闲了,这个线程会被销毁回收。
Tomcat定制版的ThreadPoolExecutor
Tomcat线程池对资源限制:
Tomcat有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。
Tomcat对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程数(maxThreads)。
Tomcat线程池还定制自己的任务处理流程:
1.前corePoolSize个任务时,来一个任务就创建一个新线程。
2.后面再来任务,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
3.如果总线程数达到 maximumPoolSize,则继续尝试把任务添加到任务队列中去。
4.如果缓冲队列也满了,插入失败,执行拒绝策略。
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("...");
}
}
}
}
}
从这个方法你可以看到,Tomcat线程池的execute方法会调用Java原生线程池的execute去执行任务,如果总线程数达到maximumPoolSize,Java原生线程池的execute方法会抛出RejectedExecutionException异常,但是这个异常会被Tomcat线程池的execute方法捕获到,并继续尝试把这个任务放到任务队列中去;如果任务队列也满了,再执行拒绝策略。
Tomcat线程池是用这个变量submittedCount来维护已经提交到了线程池,但是还没有执行完的任务个数。Tomcat的任务队列TaskQueue扩展了Java中的LinkedBlockingQueue,我们知道 LinkedBlockingQueue默认情况下长度是没有限制的,除非给它一个capacity。因此 Tomcat给了它一个capacity,TaskQueue的构造函数中有个整型的参数capacity,TaskQueue将capacity传给父类LinkedBlockingQueue的构造函数。
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
public TaskQueue(int capacity) {
super(capacity);
}
...
}
这个capacity参数是通过Tomcat的maxQueueSize参数来设置的,但问题是默认情况下 maxQueueSize的值是Integer.MAX_VALUE,等于没有限制,
这样就带来一个问题:当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。
为了解决这个问题,TaskQueue重写了LinkedBlockingQueue的offer方法,在合适的时机返回false,返回false表示任务添加失败,这时线程池会创建新的线程。
那什么是合适的时机呢?请看下面offer方法的核心源码:
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需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。当然默认情况下Tomcat的任务队列是没有限制的,你可以通过设置maxQueueSize参数来限制任务队列的长度。
Tomcat的类加载器
Tomcat的自定义类加载器WebAppClassLoader打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载Web应用自己定义的类。具体实现就是重写ClassLoader的两个方法:findClass和loadClass。
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;
}
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);
}
1.先在本地Cache查找该类是否已经加载过,也就是说Tomcat的类加载器是否已经加载过这个类。
2.如果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 应用目录下查找并加载。
5.如果本地目录下没有这个类,说明不是Web应用自己定义的类,那么由系统类加载器去加载。
6.这里请你注意,Web应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器。如果上述加载过程全部失败,抛出ClassNotFound异常。
从上面的过程我们可以看到,Tomcat的类加载器打破了双亲委托机制,没有一上来就直接委托给父加载器,而是先在本地目录下加载,为了避免本地目录下的类覆盖JRE的核心类,先尝试用 JVM扩展类加载器ExtClassLoader去加载。
首先让我们再思考一下这几个问题:
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类加载器的层次结构
还是老规矩看图说话:
WebAppClassLoader:
我们先来看第1个问题,假如我们使用JVM默认AppClassLoader来加载Web应用,AppClassLoader只能加载一个Servlet类,在加载第二个同名Servlet类时,AppClassLoader会返回第一个Servlet类的Class实例,这是因为在AppClassLoader看来,同名的Servlet类只被加载一次。
因此Tomcat的解决方案是自定义一个类加载器WebAppClassLoader,并且给每个Web应用创建一个类加载器实例。我们知道,Context容器组件对应一个Web应用,因此,每个Context容器负责创建和维护一个WebAppClassLoader加载器实例。这背后的原理是,不同的加载器实例加载的类被认为是不同的类,即使它们的类名相同。这就相当于在Java虚拟机内部创建了一个个相互隔离的Java类空间,每一个Web应用都有自己的类空间,Web应用之间通过各自的类加载器互相隔离。
SharedClassLoader:
我们再来看第2个问题,本质需求是两个Web应用之间怎么共享库类,并且不能重复加载相同的类。
我们知道,在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的加载路径下不就行了吗,应用程序也正是通过这种方式共享JRE的核心类。
因此Tomcat的设计者又加了一个类加载器SharedClassLoader,作为WebAppClassLoader 的父加载器,专门来加载Web应用之间共享的类。
如果WebAppClassLoader自己没有加载到某个类,就会委托父加载器SharedClassLoader去加载这个类,SharedClassLoader会在指定目录下加载共享类,之后返回给WebAppClassLoader,这样共享的问题就解决了。
CatalinaClassLoader:
我们来看第3个问题,如何隔离Tomcat本身的类和Web应用的类?
我们知道,要共享可以通过父子关系,要隔离那就需要兄弟关系了。
兄弟关系就是指两个类加载器是平行的,它们可能拥有同一个父加载器,但是两个兄弟类加载器加载的类是隔离的。
基于此Tomcat又设计一个类加载器CatalinaClassLoader,专门来加载Tomcat自身的类。
这样设计有个问题,那Tomcat和各Web应用之间需要共享一些类时该怎么办呢?
CommonClassLoader:
还是再增加一个CommonClassLoader,作为CatalinaClassLoader和SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,而CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离。WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个 WebAppClassLoader实例之间相互隔离。
探讨下Tomcat如何实现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还有两个兄弟:Listener和Filter,它们也是Servlet规范中的重要成员,因此Tomcat也需要创建它们的实例,也需要在合适的时机去调用它们的方法。
public class StandardWrapper extends ContainerBase
implements ServletConfig, Wrapper, NotificationEmitter {
protected volatile Servlet instance = null;
public synchronized Servlet loadServlet() throws ServletException {
Servlet servlet;
InstanceManager instanceManager = ((StandardContext)getParent()).getInstanceManager();
//创建实例
servlet = (Servlet) instanceManager.newInstance(servletClass);
//初始化实例
initServlet(servlet);
return 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方法是如何实现的,同样为了方便阅读,简化了代码:
public final void invoke(Request request, Response response)
throws IOException, ServletException {
//创建实例
servlet = wrapper.allocate();
//创建当前请求的FilterChain
ApplicationFilterChain filterChain =
ApplicationFilterFactory.createFilterChain(request, wrapper, servlet);
//调用这个Filter链,Filter链中的最后一个Filter会调用Servlet
filterChain.doFilter(request.getRequest(),
response.getResponse());
}
StandardWrapperValve的invoke方法比较复杂,去掉其他异常处理的一些细节,本质上就是三步: 第一步,创建Servlet实例; 第二步,给当前请求创建一个Filter链; 第三步,调用这个Filter链。
你可能会问,为什么需要给每个请求创建一个Filter链? 这是因为每个请求的请求路径都不一样,而Filter都有相应的路径映射,因此不是所有的 Filter都需要来处理当前的请求,我们需要根据请求的路径来选择特定的一些Filter来处理。
第二个问题是,为什么没有看到调到Servlet的service方法? 这是因为Filter链的doFilter方法会负责调用Servlet,具体来说就是Filter链中的最后一个Filter会负责调用Servlet。
大家都应该知道,Filter跟Servlet一样,也可以在web.xml文件里进行配置,不同的是,Filter的作用域是整个Web应用,因此Filter的实例是在Context容器中进行管理的,Context容器用Map集合来保存Filter。
private Map<String, FilterDef> filterDefs = new HashMap<>();
那上面提到的Filter链又是什么呢? Filter链的存活期很短,它是跟每个请求对应的。一个新的请求来了,就动态创建一个Filter 链,请求处理完了,Filter链也就被回收了。
理解它的原理也非常关键,我们还是来看看源码:
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);
}
从ApplicationFilterChain的源码我们可以看到几个关键信息: 1.Filter链中除了有Filter对象的数组,还有一个整数变量 pos,这个变量用来记录当前被调用的Filter在数组中的位置。 2.Filter链中有个Servlet实例,这个好理解,因为上面提到了,每个Filter链最后都会调到一个 Servlet。 3.Filter链本身也实现了doFilter方法,直接调用了一个内部方法internalDoFilter。internalDoFilter 方法的实现比较有意思,它做了一个判断,如果当前 Filter 的位置小于 4.Filter数组的长度,也就是说Filter还没调完,就从Filter数组拿下一个Filter,调用它的doFilter方法。否则,意味着所有Filter都调到了,就调用Servlet的service方法。
但问题是,方法体里没看到循环,谁在不停地调用Filter链的doFilter方法呢? Filter是怎么依次调到的呢?
答案是Filter本身的doFilter方法会调用Filter链的doFilter方法,我们还是来看看代码就明白了:
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain){
...
//调用Filter的方法
chain.doFilter(request, response);
}
注意Filter的doFilter方法有个关键参数FilterChain,就是Filter链。并且每个Filter 在实现doFilter时,必须要调用Filter链的doFilter方法,而Filter链中保存当前Filter 的位置,会调用下一个Filter的doFilter方法,这样链式调用就完成了。Filter链跟Tomcat的Pipeline-Valve本质都是责任链模式,但是在具体实现上稍有不同,大家可以细细体会一下。
我们接着聊Servlet规范里Listener。跟Filter一样,Listener也是一种扩展机制,你可以监听容器内部发生的事件。 主要有两类事件: 第一类是生命状态的变化,比如Context容器启动和停止、Session的创建和销毁。 第二类是属性的变化,比如Context容器某个属性值变了、Session的某个属性值变了以及新的请求来了等。 我们可以在web.xml配置或者通过注解的方式来添加监听器,在监听器里实现我们的业务逻辑。对于Tomcat来说,它需要读取配置文件,拿到监听器类的名字,实例化这些类,并且在合适的时机调用这些监听器的方法。Tomcat是通过Context容器来管理这些监听器的。Context容器将两类事件分开来管理,分别用不同的集合来存放不同类型事件的监听器:
//监听属性值变化的监听器
private List<Object> applicationEventListenersList = new CopyOnWriteArrayList<>();
//监听生命事件的监听器
private Object applicationLifecycleListenersObjects[] = new Object[0];
剩下的事情就是触发监听器了,比如在Context容器的启动方法里,就触发了所有的 ServletContextListener:
//1.拿到所有的生命周期监听器(这个不是Tomcat的生命周期)
Object instances[] = getApplicationLifecycleListeners();
for (int i = 0; i < instances.length; i++) {
//2. 判断Listener的类型是不是ServletContextListener
if (!(instances[i] instanceof ServletContextListener))
continue;
//3.触发Listener的方法
ServletContextListener lr = (ServletContextListener) instances[i];
lr.contextInitialized(event);
}
需要注意的是,这里的ServletContextListener接口是一种留给用户的扩展机制,用户可以实现这个接口来定义自己的监听器,监听Context容器的启停事件ServletContextListener跟Tomcat自己的生命周期事件LifecycleListener是不同的。LifecycleListener定义在生命周期管理组件中,由基类LifecycleBase统一管理。