Tomcat发展这么多年,已经比较成熟稳定。在如今『追新求快』的时代,Tomcat作为JavaWeb开发必备的工具似乎变成了『熟悉的陌生人』,难道说如今就没有必要深入学习它了么?学习它我们又有什么收获呢?
静下心来,细细品味经典的开源作品。提升我们的「内功」,具体来说就是学习大牛们如何设计、架构一个中间件系统,并且让这些经验为我所用。
美好的事物往往是整洁而优雅的。但这并不等于简单,而是要将复杂的系统分解成一个个小模块,并且各个模块的职责划分也要清晰合理。
与此相反的是凌乱无序,比如你看到城中村一堆互相纠缠在一起的电线,可能会感到不适。维护的代码一个类几千行、一个方法好几百行。方法之间相互耦合糅杂在一起,你可能会说whatthef*k!
学习目的
掌握Tomcat架构设计与原理提高内功
宏观上看
微观上看
Tomcat内部也隔离了变化点与不变点,使用了组件化设计,目的就是为了实现「俄罗斯套娃式」的高度定制化(组合模式),而每个组件的生命周期管理又有一些共性的东西,则被提取出来成为接口和抽象类,让具体子类实现变化点,也就是模板方法设计模式。
当今流行的微服务也是这个思路,按照功能将单体应用拆成「微服务」,拆分过程要将共性提取出来,而这些共性就会成为核心的基础服务或者通用库。「中台」思想亦是如此。
设计模式往往就是封装变化的一把利器,合理的运用设计模式能让我们的代码与系统设计变得优雅且整洁。
这就是学习优秀开源软件能获得的「内功」,从不会过时,其中的设计思想与哲学才是根本之道。从中借鉴设计经验,合理运用设计模式封装变与不变,更能从它们的源码中汲取经验,提升自己的系统设计能力。
宏观理解一个请求如何与Spring联系起来
在工作过程中,我们对Java语法已经很熟悉了,甚至「背」过一些设计模式,用过很多Web框架,但是很少有机会将它们用到实际项目中,让自己独立设计一个系统似乎也是根据需求一个个Service实现而已。脑子里似乎没有一张JavaWeb开发全景图,比如我并不知道浏览器的请求是怎么跟Spring中的代码联系起来的。
为了突破这个瓶颈,为何不站在巨人的肩膀上学习优秀的开源系统,看大牛们是如何思考这些问题。
学习Tomcat的原理,我发现Servlet技术是Web开发的原点,几乎所有的JavaWeb框架(比如Spring)都是基于Servlet的封装,Spring应用本身就是一个Servlet(DispatchSevlet),而Tomcat和Jetty这样的Web容器,负责加载和运行Servlet。如图所示:
提升自己的系统设计能力
学习Tomcat,我还发现用到不少Java高级技术,比如Java多线程并发编程、Socket网络编程以及反射等。之前也只是了解这些技术,为了面试也背过一些题。但是总感觉「知道」与会用之间存在一道沟壑,通过对Tomcat源码学习,我学会了什么场景去使用这些技术。
还有就是系统设计能力,比如面向接口编程、组件化组合模式、骨架抽象类、一键式启停、对象池技术以及各种设计模式,比如模板方法、观察者模式、责任链模式等,之后我也开始模仿它们并把这些设计思想运用到实际的工作中。
整体架构设计
今天咱们就来一步一步分析Tomcat的设计思路,一方面我们可以学到Tomcat的总体架构,学会从宏观上怎么去设计一个复杂系统,怎么设计顶层模块,以及模块之间的关系;另一方面也为我们深入学习Tomcat的工作原理打下基础。
Tomcat启动流程:startup.sh->catalina.shstart->java-jarorg.apache.catalina.startup.Bootstrap.main()
Tomcat实现的2个核心功能:
处理Socket连接,负责网络字节流与Request和Response对象的转化。
加载并管理Servlet,以及处理具体的Request请求。
所以Tomcat设计了两个核心组件连接器(Connector)和容器(Container)。连接器负责对外交流,容器负责内部处理。
Tomcat为了实现支持多种I/O模型和应用层协议,一个容器可能对接多个连接器,就好比一个房间有多个门。
Tomcat整体架构
Server对应的就是一个Tomcat实例。
Service默认只有一个,也就是一个Tomcat实例默认一个Service。
Connector:一个Service可能多个连接器,接受不同连接协议。
Container:多个连接器对应一个容器,顶层容器其实就是Engine。
每个组件都有对应的生命周期,需要启动,同时还要启动自己内部的子组件,比如一个Tomcat实例包含一个Service,一个Service包含多个连接器和一个容器。而一个容器包含多个Host,Host内部可能有多个Context容器,而一个Context也会包含多个Servlet,所以Tomcat利用组合模式管理组件每个组件,对待过个也像对待单个组一样对待。整体上每个组件设计就像是「俄罗斯套娃」一样。
连接器
在开始讲连接器前,我先铺垫一下Tomcat支持的多种I/O模型和应用层协议。
Tomcat支持的I/O模型有:
NIO:非阻塞I/O,采用JavaNIO类库实现。
NIO2:异步I/O,采用JDK7最新的NIO2类库实现。
APR:采用Apache可移植运行库实现,是C/C++编写的本地库。
Tomcat支持的应用层协议有:
HTTP/1.1:这是大部分Web应用采用的访问协议。
AJP:用于和Web服务器集成(如Apache)。
HTTP/2:HTTP2.0大幅度地提升了Web性能。
所以一个容器可能对接多个连接器。连接器对Servlet容器屏蔽了网络协议与I/O模型的区别,无论是Http还是AJP,在容器中获取到的都是一个标准的ServletRequest对象。
细化连接器的功能需求就是:
监听网络端口。
接受网络连接请求。
读取请求网络字节流。
根据具体应用层协议(HTTP/AJP)解析字节流,生成统一的TomcatRequest对象。
将TomcatRequest对象转成标准的ServletRequest。
调用Servlet容器,得到ServletResponse。
将ServletResponse转成TomcatResponse对象。
将TomcatResponse转成网络字节流。
将响应字节流写回给浏览器。
需求列清楚后,我们要考虑的下一个问题是,连接器应该有哪些子模块?优秀的模块化设计应该考虑高内聚、低耦合。
我们发现连接器需要完成3个高内聚的功能:
网络通信。
应用层协议解析。
TomcatRequest/Response与ServletRequest/ServletResponse的转化。
因此Tomcat的设计者设计了3个组件来实现这3个功能,分别是EndPoint、Processor和Adapter。
网络通信的I/O模型是变化的,应用层协议也是变化的,但是整体的处理逻辑是不变的,EndPoint负责提供字节流给Processor,Processor负责提供TomcatRequest对象给Adapter,Adapter负责提供ServletRequest对象给容器。
封装变与不变
因此Tomcat设计了一系列抽象基类来封装这些稳定的部分,抽象基类AbstractProtocol实现了ProtocolHandler接口。每一种应用层协议有自己的抽象基类,比如AbstractAjpProtocol和AbstractHttp11Protocol,具体协议的实现类扩展了协议层抽象基类。
这就是模板方法设计模式的运用。
应用层协议抽象
总结下来,连接器的三个核心组件Endpoint、Processor和Adapter来分别做三件事情,其中Endpoint和Processor放在一起抽象成了ProtocolHandler组件,它们的关系如下图所示。
ProtocolHandler组件
主要处理网络连接和应用层协议,包含了两个重要部件EndPoint和Processor,两个组件组合形成ProtocoHandler,下面我来详细介绍它们的工作原理。
EndPoint
EndPoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此EndPoint是用来实现TCP/IP协议数据读写的,本质调用操作系统的socket接口。
EndPoint是一个接口,对应的抽象实现类是AbstractEndpoint,而AbstractEndpoint的具体子类,比如在NioEndpoint和Nio2Endpoint中,有两个重要的子组件:Acceptor和SocketProcessor。
其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理Acceptor接收到的Socket请求,它实现Runnable接口,在Run方法里调用应用层协议处理组件Processor进行处理。为了提高处理能力,SocketProcessor被提交到线程池来执行。
我们知道,对于Java的多路复用器的使用,无非是两步:
创建一个Seletor,在它身上注册各种感兴趣的事件,然后调用select方法,等待感兴趣的事情发生。
感兴趣的事情发生了,比如可以读了,这时便创建一个新的线程从Channel中读数据。
在Tomcat中NioEndpoint则是AbstractEndpoint的具体实现,里面组件虽然很多,但是处理逻辑还是前面两步。它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件,分别分工合作实现整个TCP/IP协议的处理。
LimitLatch是连接控制器,它负责控制最大连接数,NIO模式下默认是10000,达到这个阈值后,连接请求被拒绝。
Acceptor跑在一个单独的线程里,它在一个死循环里调用accept方法来接收新连接,一旦有新的连接请求到来,accept方法返回一个Channel对象,接着把Channel对象交给Poller去处理。
Poller的本质是一个Selector,也跑在单独线程里。Poller在内部维护一个Channel数组,它在一个死循环里不断检测Channel的数据就绪状态,一旦有Channel可读,就生成一个SocketProcessor任务对象扔给Executor去处理。
SocketProcessor实现了Runnable接口,其中run方法中的getHandler().process(socketWrapper,SocketEvent.CONNECT_FAIL);代码则是获取handler并执行处理socketWrapper,最后通过socket获取合适应用层协议处理器,也就是调用Http11Processor组件来处理请求。Http11Processor读取Channel的数据来生成ServletRequest对象,Http11Processor并不是直接读取Channel的。这是因为Tomcat支持同步非阻塞I/O模型和异步I/O模型,在JavaAPI中,相应的Channel类也是不一样的,比如有AsynchronousSocketChannel和SocketChannel,为了对Http11Processor屏蔽这些差异,Tomcat设计了一个包装类叫做SocketWrapper,Http11Processor只调用SocketWrapper的方法去读写数据。
Executor就是线程池,负责运行SocketProcessor任务类,SocketProcessor的run方法会调用Http11Processor来读取和解析请求数据。我们知道,Http11Processor是应用层协议的封装,它会调用容器获得响应,再把响应通过Channel写出。
工作流程如下所示:
NioEndPoint
Processor
Processor用来实现HTTP协议,Processor接收来自EndPoint的Socket,读取字节流解析成TomcatRequest和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。
从图中我们看到,EndPoint接收到Socket连接后,生成一个SocketProcessor任务提交到线程池去处理,SocketProcessor的Run方法会调用HttpProcessor组件去解析应用层协议,Processor通过解析生成Request对象后,会调用Adapter的Service方法,方法内部通过以下代码将请求传递到容器中。
//Callingthecontainerconnector.getService().getContainer().getPipeline().getFirst().invoke(request,response);
Adapter组件
由于协议的不同,Tomcat定义了自己的Request类来存放请求信息,这里其实体现了面向对象的思维。但是这个Request不是标准的ServletRequest,所以不能直接使用Tomcat定义Request作为参数直接容器。
Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的Sevice方法,传入的是TomcatRequest对象,CoyoteAdapter负责将TomcatRequest转成ServletRequest,再调用容器的Service方法。
容器
连接器负责外部交流,容器负责内部处理。具体来说就是,连接器处理Socket通信和应用层协议的解析,得到Servlet请求;而容器则负责处理Servlet请求。
容器:顾名思义就是拿来装东西的,所以Tomcat容器就是拿来装载Servlet。
Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。Server代表Tomcat实例。
要注意的是这4种容器不是平行关系,属于父子关系,如下图所示:
你可能会问,为啥要设计这么多层次的容器,这不是增加复杂度么?其实这背后的考虑是,Tomcat通过一种分层的架构,使得Servlet容器具有很好的灵活性。因为这里正好符合一个Host多个Context,一个Context也包含多个Servlet,而每个组件都需要统一生命周期管理,所以组合模式设计这些容器
Wrapper表示一个Servlet,Context表示一个Web应用程序,而一个Web程序可能有多个Servlet;Host表示一个虚拟主机,或者说一个站点,一个Tomcat可以配置多个站点(Host);一个站点(Host)可以部署多个Web应用;Engine代表引擎,用于管理多个站点(Host),一个Service只能有一个Engine。
可通过Tomcat配置文件加深对其层次关系理解。
//顶层组件,可包含多个Service,代表一个Tomcat实例//顶层组件,包含一个Engine,多个连接器connectionTimeout="20000"redirectPort="8443"/>//连接器//容器组件:一个Engine处理Service所有请求,包含多个Host//容器组件:处理指定Host下的客户端请求,可包含多个ContextunpackWARs="true"autoDeploy="true">//容器组件:处理特定ContextWeb应用的所有客户端请求
如何管理这些容器?我们发现容器之间具有父子关系,形成一个树形结构,是不是想到了设计模式中的组合模式。
Tomcat就是用组合模式来管理这些容器的。具体实现方法是,所有容器组件都实现了Container接口,因此组合模式可以使得用户对单容器对象和组合容器对象的使用具有一致性。这里单容器对象指的是最底层的Wrapper,组合容器对象指的是上面的Context、Host或者Engine。Container接口定义如下:
publicinterfaceContainerextendsLifecycle{publicvoidsetName(Stringname);publicContainergetParent();publicvoidsetParent(Containercontainer);publicvoidaddChild(Containerchild);publicvoidremoveChild(Containerchild);publicContainerfindChild(Stringname);}
我们看到了getParent、SetParent、addChild和removeChild等方法,这里正好验证了我们说的组合模式。我们还看到Container接口拓展了Lifecycle,Tomcat就是通过Lifecycle统一管理所有容器的组件的生命周期。通过组合模式管理所有容器,拓展Lifecycle实现对每个组件的生命周期管理,Lifecycle主要包含的方法init()、start()、stop()和destroy()。
请求定位Servlet的过程
一个请求是如何定位到让哪个Wrapper的Servlet处理的?答案是,Tomcat是用Mapper组件来完成这个任务的。
Mapper组件的功能就是将用户请求的URL定位到一个Servlet,它的工作原理是:Mapper组件里保存了Web应用的配置信息,其实就是容器组件与访问路径的映射关系,比如Host容器里配置的域名、Context容器里的Web应用路径,以及Wrapper容器里Servlet映射的路径,你可以想象这些配置信息就是一个多层次的Map。
当一个请求到来时,Mapper组件通过解析请求URL里的域名和路径,再到自己保存的Map里去查找,就能定位到一个Servlet。请你注意,一个请求URL最后只会定位到一个Wrapper容器,也就是一个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方法就是来处理请求的。
publicinterfaceValve{publicValvegetNext();publicvoidsetNext(Valvevalve);publicvoidinvoke(Requestrequest,Responseresponse)}
继续看Pipeline接口
publicinterfacePipeline{publicvoidaddValve(Valvevalve);publicValvegetBasic();publicvoidsetBasic(Valvevalve);publicValvegetFirst();}
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:
@Overridepublicvoidservice(org.apache.coyote.Requestreq,org.apache.coyote.Responseres){//省略其他代码//Callingthecontainerconnector.getService().getContainer().getPipeline().getFirst().invoke(request,response);...}
Wrapper容器的最后一个Valve会创建一个Filter链,并调用doFilter()方法,最终会调到Servlet的service方法。
前面我们不是讲到了Filter,似乎也有相似的功能,那Valve和Filter有什么区别吗?它们的区别是:
Valve是Tomcat的私有机制,与Tomcat的基础架构API是紧耦合的。ServletAPI是公有的标准,所有的Web容器包括Jetty都支持Filter机制。
另一个重要的区别是Valve工作在Web容器级别,拦截所有应用的请求;而ServletFilter工作在应用级别,只能拦截某个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接口的定义:
Lyfecycle
重用性:LifeCycleBase抽象基类
再次看到抽象模板设计模式。
有了接口,我们就要用类去实现接口。一般来说实现类不止一个,不同的类在实现接口时往往会有一些相同的逻辑,如果让各个子类都去实现一遍,就会有重复代码。那子类如何重用这部分逻辑呢?其实就是定义一个基类来实现共同的逻辑,然后让各个子类去继承它,就达到了重用的目的。
Tomcat定义一个基类LifeCycleBase来实现LifeCycle接口,把一些公共的逻辑放到基类中去,比如生命状态的转变与维护、生命事件的触发以及监听器的添加和删除等,而子类就负责实现自己的初始化、启动和停止等方法。
Tomcat为了实现一键式启停以及优雅的生命周期管理,并考虑到了可扩展性和可重用性,将面向对象思想和设计模式发挥到了极致,Containaer接口维护了容器的父子关系,Lifecycle组合模式实现组件的生命周期维护,生命周期每个组件有变与不变的点,运用模板方法模式。分别运用了组合模式、观察者模式、骨架抽象类和模板方法。
如果你需要维护一堆具有父子关系的实体,可以考虑使用组合模式。
观察者模式听起来“高大上”,其实就是当一个事件发生后,需要执行一连串更新操作。实现了低耦合、非侵入式的通知与更新机制。
Container继承了LifeCycle,StandardEngine、StandardHost、StandardContext和StandardWrapper是相应容器组件的具体实现类,因为它们都是容器,所以继承了ContainerBase抽象基类,而ContainerBase实现了Container接口,也继承了LifeCycleBase类,它们的生命周期管理接口和功能接口是分开的,这也符合设计中接口分离的原则。
Tomcat为何打破双亲委派机制
双亲委派
我们知道JVM的类加载器加载Class的时候基于双亲委派机制,也就是会将加载交给自己的父加载器加载,如果父加载器为空则查找Bootstrap是否加载过,当无法加载的时候才让自己加载。JDK提供一个抽象类ClassLoader,这个抽象类中定义了三个关键方法。对外使用loadClass(Stringname)用于子类重写打破双亲委派:loadClass(Stringname,booleanresolve)
publicClassloadClass(Stringname)throwsClassNotFoundException{returnloadClass(name,false);}protectedClassloadClass(Stringname,booleanresolve)throwsClassNotFoundException{synchronized(getClassLoadingLock(name)){//查找该class是否已经被加载过Classc=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);}returnc;}}protectedClassfindClass(Stringname){//1.根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存...//2.调用defineClass将字节数组转成Class对象returndefineClass(buf,off,len);}//将字节码数组解析成一个Class对象,用native方法实现protectedfinalClassdefineClass(byte[]b,intoff,intlen){...}
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具体是如何实现的。
protectedclassContainerBackgroundProcessorimplementsRunnable{@Overridepublicvoidrun(){//请注意这里传入的参数是"宿主类"的实例processChildren(ContainerBase.this);}protectedvoidprocessChildren(Containercontainer){try{//1.调用当前容器的backgroundProcess方法。container.backgroundProcess();//2.遍历所有的子容器,递归调用processChildren,//这样当前容器的子孙都会被处理Container[]children=container.findChildren();for(inti=0;i 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;为了方便理解和阅读,我去掉了一些细节: publicClassfindClass(Stringname)throwsClassNotFoundException{...Classclazz=null;try{//1.先在Web应用目录下查找类clazz=findClassInternal(name);}catch(RuntimeExceptione){throwe;}if(clazz==null){try{//2.如果在本地目录没有找到,交给父加载器去查找clazz=super.findClass(name);}catch(RuntimeExceptione){throwe;}//3.如果父类也没找到,抛出ClassNotFoundExceptionif(clazz==null){thrownewClassNotFoundException(name);}returnclazz;} 先在Web应用本地目录下查找要加载的类。 如果没有找到,交给父加载器去查找,它的父加载器就是上面提到的系统类加载器AppClassLoader。 如何父加载器也没找到这个类,抛出ClassNotFound异常。 loadClass方法 再来看Tomcat类加载器的loadClass方法的实现,同样我也去掉了一些细节: 主要有六个步骤: 先在本地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使用。 整体架构设计解析收获总结 通过前面对Tomcat整体架构的学习,知道了Tomcat有哪些核心组件,组件之间的关系。以及Tomcat是怎么处理一个HTTP请求的。下面我们通过一张简化的类图来回顾一下,从图上你可以看到各种组件的层次关系,图中的虚线表示一个请求在Tomcat中流转的过程。 Tomcat整体组件关系 Tomcat的整体架构包含了两个核心组件连接器和容器。连接器负责对外交流,容器负责内部处理。连接器用ProtocolHandler接口来封装通信协议和I/O模型的差异,ProtocolHandler内部又分为EndPoint和Processor模块,EndPoint负责底层Socket通信,Proccesor负责应用层协议解析。连接器通过适配器Adapter调用容器。 对Tomcat整体架构的学习,我们可以得到一些设计复杂系统的基本思路。首先要分析需求,根据高内聚低耦合的原则确定子模块,然后找出子模块中的变化点和不变点,用接口和抽象基类去封装不变点,在抽象基类中定义模板方法,让子类自行实现抽象方法,也就是具体子类去实现变化点。 运用了组合模式管理容器、通过观察者模式发布启动事件达到解耦、开闭原则。骨架抽象类和模板方法抽象变与不变,变化的交给子类实现,从而实现代码复用,以及灵活的拓展。使用责任链的方式处理请求,比如记录日志等。 Tomcat的自定义类加载器WebAppClassLoader为了隔离Web应用打破了双亲委托机制,它首先自己尝试去加载某个类,如果找不到再代理给父类加载器,其目的是优先加载Web应用自己定义的类。防止Web应用自己的类覆盖JRE的核心类,使用ExtClassLoader去加载,这样即打破了双亲委派,又能安全加载。 如何阅读源码持续学习 学习是一个反人类的过程,是比较痛苦的。尤其学习我们常用的优秀技术框架本身比较庞大,设计比较复杂,在学习初期很容易遇到“挫折感”,debug跳来跳去陷入恐怖细节之中无法自拔,往往就会放弃。 找到适合自己的学习方法非常重要,同样关键的是要保持学习的兴趣和动力,并且得到学习反馈效果。 学习优秀源码,我们收获的就是架构设计能力,遇到复杂需求我们学习到可以利用合理模式与组件抽象设计了可拓展性强的代码能力。 如何阅读 比如我最初在学习Spring框架的时候,一开始就钻进某个模块啃起来。然而由于Spring太庞大,模块之间也有联系,根本不明白为啥要这么写,只觉得为啥设计这么“绕”。 错误方式 陷入细节,不看全局:我还没弄清楚森林长啥样,就盯着叶子看,看不到全貌和整体设计思路。所以阅读源码学习的时候不要一开始就进入细节,而是宏观看待整体架构设计思想,模块之间的关系。 还没学会用就研究如何设计:首先基本上框架都运用了设计模式,我们最起码也要了解常用的设计模式,即使是“背”,也得了然于胸。在学习一门技术,我推荐先看官方文档,看看有哪些模块、整体设计思想。然后下载示例跑一遍,最后才是看源码。 看源码深究细节:到了看具体某个模块源码的时候也要下意识的不要去深入细节,重要的是学习设计思路,而不是具体一个方法实现逻辑。除非自己要基于源码做二次开发。 正确方式 定焦原则:抓主线(抓住一个核心流程去分析,不要漫无目的的到处阅读)。 宏观思维:从全局的视角去看待,上帝视角理出主要核心架构设计,先森林后树叶。切勿不要试图去搞明白每一行代码。 断点:合理运用调用栈(观察调用过程上下文)。 带着目标去学 又或者接到一个稍微复杂的需求,学习从优秀源码中借鉴设计思路与优化技巧。 实际场景运用 简单的分析了Tomcat整体架构设计,从【连接器】到【容器】,并且分别细说了一些组件的设计思想以及设计模式。接下来就是如何学以致用,借鉴优雅的设计运用到实际工作开发中。学习,从模仿开始。 责任链模式 在工作中,有这么一个需求,用户可以输入一些信息并可以选择查验该企业的【工商信息】、【司法信息】、【中登情况】等如下如所示的一个或者多个模块,而且模块之间还有一些公共的东西是要各个模块复用。 这里就像一个请求,会被多个模块去处理。所以每个查询模块我们可以抽象为处理阀门,使用一个List将这些阀门保存起来,这样新增模块我们只需要新增一个阀门即可,实现了开闭原则,同时将一堆查验的代码解耦到不同的具体阀门中,使用抽象类提取“不变的”功能。 具体示例代码如下所示: 首先抽象我们的处理阀门,NetCheckDTO是请求信息 /***责任链模式:处理每个模块阀门*/publicinterfaceValve{/***调用*@paramnetCheckDTO*/voidinvoke(NetCheckDTOnetCheckDTO);} 定义抽象基类,复用代码。 publicabstractclassAbstractCheckValveimplementsValve{publicfinalAnalysisReportLogDOgetLatestHistoryData(NetCheckDTOnetCheckDTO,NetCheckDataTypeEnumcheckDataTypeEnum){//获取历史记录,省略代码逻辑}//获取查验数据源配置publicfinalStringgetModuleSource(StringquerySource,ModuleEnummoduleEnum){//省略代码逻辑}} 定义具体每个模块处理的业务逻辑,比如【百度负面新闻】对应的处理 @Slf4j@ServicepublicclassBaiduNegativeValveextendsAbstractCheckValve{@Overridepublicvoidinvoke(NetCheckDTOnetCheckDTO){}} 最后就是管理用户选择要查验的模块,我们通过List保存。用于触发所需要的查验模块 @Slf4j@ServicepublicclassNetCheckService{//注入所有的阀门@AutowiredprivateMapvalveMap;/***发送查验请求**@paramnetCheckDTO*/@Async("asyncExecutor")publicvoidsendCheckRequest(NetCheckDTOnetCheckDTO){//用于保存客户选择处理的模块阀门Listvalves=newArrayList<>();CheckModuleConfigDTOcheckModuleConfig=netCheckDTO.getCheckModuleConfig();//将用户选择查验的模块添加到阀门链条中if(checkModuleConfig.getBaiduNegative()){valves.add(valveMap.get("baiduNegativeValve"));}//省略部分代码.......if(CollectionUtils.isEmpty(valves)){log.info("网查查验模块为空,没有需要查验的任务");return;}//触发处理valves.forEach(valve->valve.invoke(netCheckDTO));}} 模板方法模式 需求是这样的,可根据客户录入的财报excel数据或者企业名称执行财报分析。 对于非上市的则解析excel->校验数据是否合法->执行计算。 上市企业:判断名称是否存在,不存在则发送邮件并中止计算->从数据库拉取财报数据,初始化查验日志、生成一条报告记录,触发计算->根据失败与成功修改任务状态。 重要的”变“与”不变“, 变化的是上市与非上市校验规则不一样,获取财报数据方式不一样,两种方式的财报数据需要适配 整个算法流程是固定的模板,但是需要将算法内部变化的部分具体实现延迟到不同子类实现,这正是模板方法模式的最佳场景。 最后新建两个子类继承该模板,并实现抽象方法。这样就将上市与非上市两种类型的处理逻辑解耦,同时又复用了代码。 策略模式 一种就是包含所有标准字段。 收入、支出下标是同一列,通过正负来区分收入与支出。 收入与支出是同一列,有一个交易类型的字段来区分。 特殊银行的特殊处理。 也就是我们要根据解析对应的下标找到对应的处理逻辑算法,我们可能在一个方法里面写超多ifelse的代码,整个流水处理都偶合在一起,假如未来再来一种新的流水类型,还要继续改老代码。最后可能出现“又臭又长,难以维护”的代码复杂度。 这个时候我们可以用到策略模式,将不同模板的流水使用不同的处理器处理,根据模板找到对应的策略算法去处理。即使未来再加一种类型,我们只要新加一种处理器即可,高内聚低耦合,且可拓展。 定义处理器接口,不同处理器去实现处理逻辑。将所有的处理器注入到BankFlowDataHandler的data_processor_map中,根据不同的场景取出对已经的处理器处理流水。 publicinterfaceDataProcessor{/***处理流水数据*@parambankFlowTemplateDO流水下标数据*@paramrow*@return*/BankTransactionFlowDOdoProcess(BankFlowTemplateDObankFlowTemplateDO,Listrow);/***是否支持处理该模板,不同类型的流水策略根据模板数据判断是否支持解析*@return*/booleanisSupport(BankFlowTemplateDObankFlowTemplateDO);}//处理器的上下文@Service@Slf4jpublicclassBankFlowDataContext{//将所有处理器注入到map中@AutowiredprivateListprocessors;//找对对应的处理器处理流水publicvoidprocess(){DataProcessorprocessor=getProcessor(bankFlowTemplateDO);for(DataProcessorprocessor:processors){if(processor.isSupport(bankFlowTemplateDO)){//row就是一行流水数据processor.doProcess(bankFlowTemplateDO,row);break;}}}} 定义默认处理器,处理正常模板,新增模板只要新增处理器实现DataProcessor即可。 /***默认处理器:正对规范流水模板**/@Component("defaultDataProcessor")@Slf4jpublicclassDefaultDataProcessorimplementsDataProcessor{@OverridepublicBankTransactionFlowDOdoProcess(BankFlowTemplateDObankFlowTemplateDO){//省略处理逻辑细节returnbankTransactionFlowDO;}@OverridepublicStringstrategy(BankFlowTemplateDObankFlowTemplateDO){//省略判断是否支持解析该流水booleanisDefault=true;returnisDefault;}} 通过策略模式,我们将不同处理逻辑分配到不同的处理类中,这样完全解耦,便于拓展。