11 Host容器::如何实现热部署和热加载
要在运行的过程中升级Web应用,如果不想重启系统,实现的方式有两种:热加载和热部署。
具体实现跟类加载机制有关:
热加载的实现方式是Web容器启动一个后台线程,定期检测类文件的变化,如果有变化,就重新加载类,在这个过程中不会清空Session ,一般用在开发环境。
热部署原理类似,也是由后台线程定时检测Web应用的变化,但它会重新加载整个Web应用。这种方式会清空Session,比热加载更加干净、彻底,一般用在生产环境。
11.1 Tomcat的后台线程
开启后台线程做周期性的任务一般都会使用线程池中的ScheduledThreadPoolExecutor,它除了具有线程池的功能,还能够执行周期性的任务。
Tomcat就是通过它来开启后台线程的:
bgFuture = exec.scheduleWithFixedDelay(
new ContainerBackgroundProcessor(),//要执⾏的Runnable
backgroundProcessorDelay, //第⼀次执⾏延迟多久
backgroundProcessorDelay, //之后每次执⾏间隔多久
TimeUnit.SECONDS); //时间单位
11.2 ContainerBackgroundProcessor
protected class ContainerBackgroundProcessor implements Runnable {
@Override
public void run() {
//这⾥传⼊的参数是"宿主类"的实例
//ContainerBackgroundProcessor是ContainerBase的内部类
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) { ... }
使用这样的设计,只需要在顶层容器,也就是Engine容器中启动一个后台线程,那么这个线程不但会执行Engine容器的周期性任务,它还会执行所有子容器的周期性任务。
11.2.1 backgroundProcess
具体容器类做的任务由此函数实现,因此如果有周期性任务要执行,就实现backgroundProcess方法;如果没有,就重用基类ContainerBase的方法。
ContainerBase#backgroundProcess:,不仅每个容器可以有周期性任务,每个容器中的其他通用组件,比如跟集群管理有关的Cluster组件、跟安全管理有关的Realm组件、链式调用的Valve都可以有自己的周期性任务。
public void backgroundProcess() {
//1.执⾏容器中Cluster组件的周期性任务
Cluster cluster = getClusterInternal();
if (cluster != null) {
cluster.backgroundProcess();
}
//2.执⾏容器中Realm组件的周期性任务
Realm realm = getRealmInternal();
if (realm != null) {
realm.backgroundProcess();
}
//3.执⾏容器中Valve组件的周期性任务
Valve current = pipeline.getFirst();
while (current != null) {
current.backgroundProcess();
current = current.getNext();
}
//4. 触发容器的"周期事件",Host容器的监听器HostConfig就靠它来调⽤
fireLifecycleEvent(Lifecycle.PERIODIC_EVENT, null);
}
有了ContainerBase中的后台线程和backgroundProcess方法,各种子容器和通用组件不需要各自编写一个后台线程来处理周期性任务,这样的设计显得优雅和整洁。(设计技巧)
11.3 Tomcat热加载
有了ContainerBase的周期性任务处理“框架”,作为具体容器子类,只需要实现自己的周期性任务就行。
Tomcat的热加载是在Context容器中的backgroundProcess方法实现的,即重写了ContainerBase的backgroundProcess(模板方法模式):
public void backgroundProcess() {
//WebappLoader周期性的检查WEB-INF/classes和WEB-INF/lib⽬录下的类⽂件
Loader loader = getLoader();
if (loader != null) {
loader.backgroundProcess();
}
//Session管理器周期性的检查是否有过期的Session
Manager manager = getManager();
if (manager != null) {
manager.backgroundProcess();
}
//周期性的检查静态资源是否有变化
WebResourceRoot resources = getResources();
if (resources != null) {
resources.backgroundProcess();
}
//调⽤⽗类ContainerBase的backgroundProcess⽅法
super.backgroundProcess();
}
WebappLoade调用了Context容器的reload方法,其任务总结如下:(没有销毁Session,因此Session还在)
停止和销毁Context容器及其所有子容器,子容器其实就是Wrapper,也就是说Wrapper里面Servlet实例
也被销毁了。
停止和销毁Context容器关联的Listener和Filter。
停止和销毁Context下的Pipeline和各种Valve。
停止和销毁Context的类加载器,以及类加载器加载的类文件资源。
启动Context容器,在这个过程中会重新创建前面四步被销毁的资源。
在这个过程中,类加载器发挥着关键作用。
一个Context容器对应一个类加载器,类加载器在销毁的过程中会把它加载的所有类也全部销毁。
Context容器在启动过程中,会创建一个新的类加载器来加载新的类文件。
11.4 Tomcat热部署
热部署跟热加载的本质区别是:
热部署会重新部署Web应用,原来的Context对象会整个被销毁掉,因此这个Context所关联的一切资源都会被销毁,包括Session。(热加载一样都销毁了context容器,只是在销毁session有区别,还有个本质区别是Context对象本身有没有被gc调用)
Tomcat热部署不是由Context实现的,因为热部署过程中Context容器被销毁了,那么这个任务就落在Host身上了,因为它是Context的父容器。
跟Context不一样,Host容器并没有在backgroundProcess方法中实现周期性检测的任务,而是通过监听器
HostConfig来实现的;
HostConfig就是11.2.1提到的“周期事件”的监听器,“周期事件”达到时,HostConfig会执行check方法:
protected void check() {
if (host.getAutoDeploy()) {
// 检查这个Host下所有已经部署的Web应⽤
DeployedApplication[] apps = deployed.values().toArray(new DeployedApplication[0]);
for (int i = 0; i < apps.length; i++) {
//检查Web应⽤⽬录是否有变化
checkResources(apps[i], false);
}
//执⾏部署
deployApps();
}
}
即HostConfig会检查webapps目录下的所有Web应用,即HostConfig做的事情都是比较“宏观”的,它不会去检查具体类文件或者资源文件是否有变化,而是检查Web应用目录级别的变化。:
如果原来Web应用目录被删掉了,就把相应Context容器整个销毁掉。
是否有新的Web应用目录放进来了,或者有新的WAR包放进来了,就部署相应的Web应用。
11.5 总结
热加载和热部署的目的都是在不重启Tomcat的情况下实现Web应用的更新。
热加载的粒度比较小,主要是针对类文件的更新,通过创建新的类加载器来实现重新加载。
而热部署是针对整个Web应用的,Tomcat会将原来的Context对象整个销毁掉,再重新创建Context容器对象。
热加载和热部署的实现都离不开后台线程的周期性检查,Tomcat在基类ContainerBase中统一实现了后台线程的处理逻辑,并在顶层容器Engine启动后台线程,这样子容器组件甚至各种通用组件都不需要自己去创建后台线程,这样的设计显得优雅整洁。
问题:为什么 Host 容器不通过重写 backgroundProcess 方法来实现热部署呢?
1 因为这种方式的传递方向是从父容器到子容器,而HOST容器部署依赖Context容器部署完毕,才能部署应用,也就是先要子容器Context完成热部署后才能Host容器进行部署。所以针对这种情况,提供了周期性事件机制
2 Host的周期性任务比较简单,只要检查部署是否有更新;而Context组件周期性任务比较复杂,不得不重写父类的方法。
12 Context
12.1 Tomcat如何打破双亲委托机制?
JDK提供一个抽象类ClassLoader,定义了三个关键方法:
public abstract class ClassLoader {
//每个类加载器都有个⽗加载器
private final ClassLoader parent;
//修饰符为public,对外服务的接口
public Class<?> loadClass(String name) {
//查找⼀下这个类是不是已经加载过了
Class<?> c = findLoadedClass(name);
//如果没有加载过
if( c == null ){
//先委托给⽗加载器去加载,注意这是个递归调⽤
if (parent != null) {
c = parent.loadClass(name);
}else {
// 如果⽗加载器为空,查找Bootstrap加载器是不是加载过了
c = findBootstrapClassOrNull(name);
}
}
// 如果⽗加载器没加载成功,调⽤⾃⼰的findClass去加载
if (c == null) {
c = findClass(name);
}
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){
...
}
}
要打破双亲委托机制,就需要重写loadClass方法
12.1.1 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. 如果在本地⽬录没有找到,交给⽗加载器去查找(AppClassLoader)
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.如果Tomcat类加载器没有加载过这个类,从系统类加载器的cache中查找是否加载过
clazz = findLoadedClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
//3.开始尝试⽤ExtClassLoader类加载器类加载,目的是防止Web应用自己的类覆盖JRE的核心类,即此处还是使用了双亲委托的
ClassLoader javaseLoader = getJavaseClassLoader();
try {
clazz = javaseLoader.loadClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
//4.如果加载失败,说明JRE核心类中没有这类,那就尝试在本地Web应用⽬录查找class并加载
try {
clazz = findClass(name);
if (clazz != null) {
if (resolve)
resolveClass(clazz);
return clazz;
}
} catch (ClassNotFoundException e) {
// Ignore
}
//5.如果本地目录下没有这个类,说明不是Web应用自己定义的类,那么尝试⽤系统类加载器(也就是AppClassLoader)来加载(使用Class.forname,其默认加载器就是系统类加载器)
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);
}
从loadClass方法可以看出,Tomcat的类加载器打破了双亲委托机制:
没有一开始就直接委托给父加载器,而是先在本地目录下加载(第4步);
而为了避免本地目录下的类覆盖JRE的核心类,先尝试用JVM扩展类加载器ExtClassLoader去加载。
那为什么不先用系统类加载器AppClassLoader去加载?很显然,如果是这样的话,那就变成双亲委托机制了
打破双亲委托机制的目的:优先加载Web应用目录下的类,然后再加载其他目录下的类,这也是Servlet规范的推荐做法。
如果不想打破双亲委托机制,但是又想定义自己的类加载器来加载特定目录下的类,则需要继承ClassLoader类重写findClass方法,实际上在抽象类ClassLoader中findClass的默认行为只是抛了个异常:
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
重写此方法的方式为:即12.1中的代码
protected Class<?> findClass(String name){
//1. 根据传⼊的类名name,到在特定⽬录下去寻找类⽂件,把.class⽂件读⼊内存
...
//2. 调⽤defineClass将字节数组转成Class对象
return defineClass(buf, off, len);
}
12.2 Tomcat如何隔离Web应用?
问题:
1 Web应用之间的类需要隔离
2 第三方的JAR包需要共享,如Spring,否则每个web应用都加载一次,JVM的内存会膨胀
3 需要对Tomcat本身的类和Web应用的类进行隔离
使用Tomcat多层次的类加载器结构来解决这些问题
问题1的解决
自定义一个类加载器WebAppClassLoader, 并且给每个Web应用创建一个类加载器实例。
因为Context容器组件对应一个Web应用,所以每个Context容器负责创建和维护一个WebAppClassLoader加载器实例。
原理:不同的加载器实例加载的类被认为是不同的类,即使它们的类名相同。
这相当于在Java虚拟机内部创建了一个个相互隔离的Java类空间,每一个Web应用都有自己的类空间,Web应用之间通过各自的类加载器互相隔离。
问题2的解决
本质需求是两个Web应用之间怎么共享库类,并且不能重复加载相同的类。
在双亲委托机制里,各个子加载器都可以通过父加载器去加载类,那么把需要共享的类放到父加载器的加载路径下就行了,这样就是通过同一个类加载器加载
应用程序正是通过这种方式共享JRE的核心类。
因此Tomcat的设计者又加了一个类加载器SharedClassLoader,作为WebAppClassLoader的父加载器,专门来加载Web应用之间共享的类。
如果WebAppClassLoader自己没有加载到某个类,就会委托父加载器SharedClassLoader去加载这个类,SharedClassLoader会在指定目录下加载共享类,之后返回给WebAppClassLoader,这样共享的问题就解决了。
问题3的解决
基于Tomcat设计一个类加载器CatalinaClassloader,专门来加载Tomcat自身的类。
不过这样设计有个问题,那Tomcat和各Web应用之间需要共享一些类时该怎么办呢?
增加一个CommonClassLoader,作为CatalinaClassloader和SharedClassLoader的父加载器。CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader 使用,而
CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离,这样Tomcat本身的类和Web应用的类就隔离开了,且共享的类还是正常共享
补充:Spring的加载问题
在JVM的实现中有一条隐含的规则,默认情况下,如果一个类由类加载器A加载,那么这个类的依赖类也是由相同的类加载器加载。
比如Spring作为一个Bean工厂,它需要创建业务类的实例,并且在创建业务类实例之前需要加载这些类。(Spring是通过调用Class.forName来加载业务类的,此方法使用了Spring的加载器去加载业务类)
Web应用之间共享的JAR包可以交给SharedClassLoader来加载,从而避免重复加载。
Spring作为共享的第三方JAR包,它本身是由SharedClassLoader来加载的,Spring又要去加载业务类,按照前面所说的规则,加载Spring的类加载器也会用来加载业务类,但是业务类是在Web应用目录下的,不在SharedClassLoader的加载路径下,怎么办?
解决:使用线程上下文加载器
这是一种类加载器传递机制。
为什么叫作“线程上下文加载器”?因为这个类加载器保存在线程私有数据里,只要是同一个线程,一旦设置了线程上下文加载器,在线程后续执行过程中就能把这个类加载器取出来用。
因此Tomcat为每个Web应用创建一个WebAppClassLoarder类加载器,并在启动Web应用的线程里设置此加载器为线程上下文加载器,这样Spring在启动时就将线程上下文加载器取出来,用来加载Bean,不用SharedClassLoader来加载:
cl = Thread.currentThread().getContextClassLoader();//Spring取线程上下文加载的代码
设置线程上下文加载器的位置是在StandardContext的启动方法里:
originalClassLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(webApplicationClassLoader);
最后在StandardContext的启动方法结束的时候会恢复原先的线程上下文加载器,因为只需要跨Classloader的时候才需要线程上下文加载器
Thread.currentThread().setContextClassLoader(originalClassLoader);
注
每个Web应用自己的Java类文件和依赖的JAR包,分别放在WEB-INF/classes和WEB-INF/lib目录下面。
使用类加载器的核心:共享靠父子,隔离靠兄弟
12.3 Tomcat如何实现Servlet规范?(Servlet、Filter、Listener)
12.3.1 Servlet管理
使用Wrapper容器来管理Servlet,每个容器持有一个Servlet实例:
protected volatile Servlet instance = null;
通过loadServlet方法来实例化Servlet:创建Servlet的实例,并且调用Servlet的init方法,这是Servlet规范要求的。
调用loadServlet方法的时机:使用延迟加载,默认情况下Tomcat在启动时不会加载你的Servlet,除非把Servlet的loadOnStartup参数设置为true,只创建了Wrapper容器,当有请求来访问某个Servlet时,此Servlet的实例才会被创建
Servlet是被谁调用的:Wrapper的pipeline的基础阀StandardWrapperValve,其invoke方法如下:
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的service方法
filterChain.doFilter(request.getRequest(), response.getResponse());
}
12.3.2 Filter管理
跟Servlet一样,Filter也可以在web.xml文件里进行配置;
不同的是,Filter的作用域是整个Web应用,因此Filter的实例是在Context容器中进行管理的,Context容器用Map集合来保存Filter:
private Map<String, FilterDef> filterDefs = new HashMap<>();
12.3.1中的Filte链的存活期很短,它是跟每个请求对应的。
一个新的请求来了,就动态创建一个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);
}
//如果当前Filter的位置小于Filter数组的长度,也就是说Filter还没调完,就从Filter数组拿下一个Filter,调用它的doFilter方法。
//否则,意味着所有Filter都调到了,就调用Servlet的service方法。
//通过filter.doFilter实现的链式调用
private void internalDoFilter(ServletRequest req,ServletResponse res){
if (pos < n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
filter.doFilter(request, response, this);//下面代码有解析
return;
}
servlet.service(request, response);
}
}
//Filter本身的doFilter方法会调用Filter链的doFilter方法
public void doFilter(ServletRequest request, ServletResponse response,FilterChain chain){
...
//调⽤Filter的⽅法
chain.doFilter(request, response);
}
12.3.3 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.拿到所有的⽣命周期监听器
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容器的启停事件。