参考 深入拆解Tomcat & Jetty 

1.Tomcat总体架构

Tomcat要实现2个核心功能:

  • 处理Socket连接,负责网络字节流与Request和Response对象的转化。

  • 加载和管理Servlet,以及具体处理Request请求。

Tomcat设计了两个核心组件连接器(Connector)和容器(Container)来分别做这两件事情。连接器负责对外交流,容器负责内部处理。

 

2.连接器有三个组件组成

Endpoint负责提供字节流给Processor,Processor负责提供Tomcat Request对象给Adapter,Adapter负责提供ServletRequest对象给容器。

Endpoint是通信端点,即通信监听的接口,是具体的Socket接收和发送处理器,是对传输层的抽象,因此Endpoint是用来实现TCP/IP协议的。(有两个重要的子组件:Acceptor和SocketProcessor。其中Acceptor用于监听Socket连接请求。SocketProcessor用于处理接收到的Socket请求,它实现Runnable接口,在run方法里调用协议处理组件Processor进行处理。为了)

Processor用来实现HTTP协议,Processor接收来自Endpoint的Socket,读取字节流解析成Tomcat Request和Response对象,并通过Adapter将其提交到容器处理,Processor是对应用层协议的抽象。

ProtocolHandler接口负责解析请求并生成Tomcat Request类。但是这个Request对象不是标准的ServletRequest,也就意味着,不能用Tomcat Request作为参数来调用容器。Tomcat设计者的解决方案是引入CoyoteAdapter,这是适配器模式的经典运用,连接器调用CoyoteAdapter的sevice方法,传入的是Tomcat Request对象,CoyoteAdapter负责将Tomcat Request转成ServletRequest,再调用容器的service方法。

6.容器

Tomcat设计了4种容器,分别是Engine、Host、Context和Wrapper。这4种容器不是平行关系,而是父子关系

连接器中的Adapter会调用容器的Service方法来执行Servlet,最先拿到请求的是Engine容器,Engine容器对请求做一些处理后,会把请求传给自己子容器Host继续处理,依次类推,最后这个请求会传给Wrapper容器,Wrapper会调用最终的Servlet来处理。那么这个调用过程具体是怎么实现的呢?答案是使用Pipeline-Valve管道。

 

7. 一键式启停:Lifecycle接口,使用观察者模式,

 在父组件的init方法里需要创建子组件并调用子组件的init方法。同样,在父组件的start方法里也需要调用子组件的start方法,因此调用者可以无差别的调用各组件的init方法和start方法,这就是组合模式的使用,并且只要调用最顶层组件,也就是Server组件的init和start方法,整个Tomcat就被启动起来了。

 

8. Tomcat的“高层们”都负责做什么

十一、Spring Bean的生命周期过程

 

十二 优化并提高Tomcat启动速度

清理tomcat (1. 清理不必要的Web应用;2. 清理XML配置文件;3. 清理JAR文件)

禁止Tomcat TLD扫描

关闭WebSocket支持

禁止Servlet注解扫描关闭JSP支持

随机数熵源优化(换非阻塞式)并行启动多个Web应用(artStopThreads的值表示你想用多少个线程来启动你的Web应用,如果设成0表示你要并行启动Web应用)

 

十四  IO模型

 I/O模型是为了解决内存和外部设备速度差异的问题。我们平时说的阻塞或非阻塞是指应用程序在发起I/O操作时,是立即返回还是等待。而同步和异步,是指应用程序在与内核通信时,数据从内核空间到应用空间的拷贝,是由内核主动发起还是由应用程序来触发。

高并发思路。

在Tomcat中,Endpoint组件的主要工作就是处理I/O,而NioEndpoint利用Java NIO API实现了多路复用I/O模型。其中关键的一点是,读写数据的线程自己不会阻塞在I/O等待上,而是把这个工作交给Selector。同时Tomcat在这个过程中运用到了很多Java并发编程技术,比如AQS、原子类、并发容器,线程池等,都值得我们去细细品味。

Tomcat的NioEndpoint组件虽然实现比较复杂,但基本原理就是上面两步。我们先来看看它有哪些组件,它一共包含LimitLatch、Acceptor、Poller、SocketProcessor和Executor共5个组件,它们的工作过程如下图所示。

如何高并发

在弄清楚NioEndpoint的实现原理后,我们来考虑一个重要的问题,怎么把这个过程做到高并发呢?

高并发就是能快速地处理大量的请求,需要合理设计线程模型让CPU忙起来,尽量不要让线程阻塞,因为一阻塞,CPU就闲下来了。另外就是有多少任务,就用相应规模的线程数去处理。我们注意到NioEndpoint要完成三件事情:接收连接、检测I/O事件以及处理请求,那么最核心的就是把这三件事情分开,用不同规模的线程数去处理,比如用专门的线程组去跑Acceptor,并且Acceptor的个数可以配置;用专门的线程组去跑Poller,Poller的个数也可以配置;最后具体任务的执行也由专门的线程池来处理,也可以配置线程池的大小。

 十六:Tomcat APR提高I_O性能的秘密

 APR(Apache Portable Runtime Libraries)是Apache可移植运行时库,它是用C语言实现的,其目的是向上层应用程序提供一个跨平台的操作系统接口库。Tomcat可以用它来处理包括文件和网络I/O,从而提升性能。我在专栏前面提到过,Tomcat支持的连接器有NIO、NIO.2和APR。跟NioEndpoint一样,AprEndpoint也实现了非阻塞I/O,它们的区别是:NioEndpoint通过调用Java的NIO API来实现非阻塞I/O,而AprEndpoint是通过JNI调用APR本地库而实现非阻塞I/O的。

APR提升性能的秘密还有:通过DirectByteBuffer避免了JVM堆与本地内存之间的内存拷贝;通过sendfile特性避免了内核与应用之间的内存拷贝以及用户态和内核态的切换。

 十七:Executor组件:Tomcat如何扩展Java线程池?

Tomcat扩展了Java线程池的核心类ThreadPoolExecutor,并重写了它的execute方法,定制了自己的任务处理流程。同时Tomcat还实现了定制版的任务队列,重写了offer方法,使得在任务队列长度无限制的情况下,线程池仍然有机会创建新的线程。

 十八:Tomcat如何支持WebSocket?

Tomcat的WebSocket加载是通过SCI机制完成的。SCI全称ServletContainerInitializer,是Servlet 3.0规范中定义的用来接收Web应用启动事件的接口。那为什么要监听Servlet容器的启动事件呢?因为这样我们有机会在Web应用启动时做一些初始化工作,比如WebSocket需要扫描和加载Endpoint类。SCI的使用也比较简单,将实现ServletContainerInitializer接口的类增加HandlesTypes注解,并且在注解内指定的一系列类和接口集合。Tomcat启动时通过SCI技术来扫描和加载WebSocket的处理类ServerEndpoint,并且建立起了URL到ServerEndpoint的映射关系。

当第一个WebSocket请求到达时,Tomcat将HTTP协议升级成WebSocket协议,并将该Socket连接的Processor替换成UpgradeProcessor。这个Socket不会立即关闭,对接下来的请求,Tomcat通过UpgradeProcessor直接调用相应的ServerEndpoint来处理。

今天我讲了可以通过两种方式来开发WebSocket应用,一种是继承javax.websocket.Endpoint,另一种通过WebSocket相关的注解。其实你还可以通过Spring来实现WebSocket应用,有兴趣的话你可以去研究一下Spring WebSocket的原理。

二十:Tomcat和Jetty中的对象池技术

 Tomcat和Jetty都用到了对象池技术,这是因为处理一次HTTP请求的时间比较短,但是这个过程中又需要创建大量复杂对象。

对象池技术可以减少频繁创建和销毁对象带来的成本,实现对象的缓存和复用。如果你的系统需要频繁的创建和销毁对象,并且对象的创建代价比较大,这种情况下,一般来说你会观察到GC的压力比较大,占用CPU率比较高,这个时候你就可以考虑使用对象池了。

还有一种情况是你需要对资源的使用做限制,比如数据库连接,不能无限制地创建数据库连接,因此就有了数据库连接池,你也可以考虑把一些关键的资源池化,对它们进行统一管理,防止滥用。

二十一 总结:Tomcat和Jetty的高性能、高并发之道

I/O和线程模型

 Tomcat和Jetty都已经抛弃了传统的同步阻塞I/O,采用了非阻塞I/O或者异步I/O,目的是业务线程不需要阻塞在I/O等待上。

除了I/O模型,线程模型也是影响性能和并发的关键点。Tomcat和Jetty的总体处理原则是:

  • 连接请求由专门的Acceptor线程组处理。
  • I/O事件侦测也由专门的Selector线程组来处理。
  • 具体的协议解析和业务处理可能交给线程池(Tomcat),或者交给Selector线程来处理(Jetty)

减少系统调用

 另外系统调用会导致用户态和内核态切换的过程,Tomcat和Jetty通过缓存和延迟解析尽量减少系统调用,另外还通过零拷贝技术避免多余的数据拷贝。

池化、零拷贝

 其实池化的本质就是用内存换CPU;而零拷贝就是不做无用功,减少资源浪费

高效的并发编程

 因此作为程序员,要有意识的尽量避免锁的使用,比如可以使用原子类CAS或者并发集合来代替。如果万不得已需要用到锁,也要尽量缩小锁的范围和锁的强度。

并发容器的使用

CopyOnWriteArrayList适用于读多写少的场景,比如Tomcat用它来“存放”事件监听器,这是因为监听器一般在初始化过程中确定后就基本不会改变,当事件触发时需要遍历这个监听器列表,所以这个场景符合读多写少的特征。

volatile关键字的使用

 再拿Tomcat中的LifecycleBase作为例子,它里面的生命状态就是用volatile关键字修饰的。volatile的目的是为了保证一个线程修改了变量,另一个线程能够读到这种变化。对于生命状态来说,需要在各个线程中保持是最新的值,因此采用了volatile修饰。
 

 二十三  Host容器:Tomcat如何实现热部署和热加载?

  • 热加载的实现方式是Web容器启动一个后台线程,定期检测类文件的变化,如果有变化,就重新加载类,在这个过程中不会清空Session ,一般用在开发环境。
  • 热部署原理类似,也是由后台线程定时检测Web应用的变化,但它会重新加载整个Web应用。这种方式会清空Session,比热加载更加干净、彻底,一般用在生产环境。
  • 热加载和热部署的实现都离不开后台线程的周期性检查,Tomcat在基类ContainerBase中统一实现了后台线程的处理逻辑,并在顶层容器Engine启动后台线程,这样子容器组件甚至各种通用组件都不需要自己去创建后台线程,这样的设计显得优雅整洁。

要说开启后台线程做周期性的任务,有经验的同学马上会想到线程池中的ScheduledThreadPoolExecutor,它除了具有线程池的功能,还能够执行周期性的任务。Tomcat就是通过它来开启后台线程的:

bgFuture = exec.scheduleWithFixedDelay(
              new ContainerBackgroundProcessor(),//要执行的Runnable
              backgroundProcessorDelay, //第一次执行延迟多久
              backgroundProcessorDelay, //之后每次执行间隔多久
              TimeUnit.SECONDS);        //时间单位

上面的代码调用了scheduleWithFixedDelay方法,传入了四个参数,第一个参数就是要周期性执行的任务类ContainerBackgroundProcessor,它是一个Runnable,同时也是ContainerBase的内部类,ContainerBase是所有容器组件的基类,我们来回忆一下容器组件有哪些,有Engine、Host、Context和Wrapper等,它们具有父子关系。

ContainerBase的backgroundProcess方法实现如下:

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);
}

24 Context容器(上):Tomcat如何打破双亲委托机制?

 今天我介绍了JVM的类加载器原理和源码剖析,以及Tomcat的类加载器是如何打破双亲委托机制的,目的是为了优先加载Web应用目录下的类,然后再加载其他目录下的类,这也是Servlet规范的推荐做法。

要打破双亲委托机制,需要继承ClassLoader抽象类,并且需要重写它的loadClass方法,因为ClassLoader的默认实现就是双亲委托。

 Web应用是通过Class.forName调用交给系统类加载器的,因为Class.forName的默认加载器就是系统类加载器(AppClassLoader)
 

25 Context容器(中):Tomcat如何隔离Web应用?

 今天我介绍了JVM的类加载器原理并剖析了源码,以及Tomcat的类加载器的设计。重点需要你理解的是,Tomcat的Context组件为每个Web应用创建一个WebAppClassLoader类加载器,由于不同类加载器实例加载的类是互相隔离的,因此达到了隔离Web应用的目的,同时通过CommonClassLoader等父加载器来共享第三方JAR包。而共享的第三方JAR包怎么加载特定Web应用的类呢?可以通过设置线程上下文加载器来解决。而作为Java程序员,我们应该牢记的是:
  • 每个Web应用自己的Java类文件和依赖的JAR包,分别放在WEB-INF/classesWEB-INF/lib目录下面。
  • 多个应用共享的Java类文件和JAR包,分别放在Web容器指定的共享目录下。
  • 当出现ClassNotFound错误时,应该检查你的类加载器是否正确。

线程上下文加载器不仅仅可以用在Tomcat和Spring类加载的场景里,核心框架类需要加载具体实现类时都可以用到它,比如我们熟悉的JDBC就是通过上下文类加载器来加载不同的数据库驱动的

 

26 Context容器(下):Tomcat如何实现Servlet规范?

 Servlet规范中最重要的就是Servlet、Filter和Listener“三兄弟”。Web容器最重要的职能就是把它们创建出来,并在适当的时候调用它们的方法。

Tomcat通过Wrapper容器来管理Servlet,Wrapper包装了Servlet本身以及相应的参数,这体现了面向对象中“封装”的设计原则。

Tomcat会给每个请求生成一个Filter链,Filter链中的最后一个Filter会负责调用Servlet的service方法。

对于Listener来说,我们可以定制自己的监听器来监听Tomcat内部发生的各种事件:包括Web应用级别的、Session级别的和请求级别的。Tomcat中的Context容器统一维护了这些监听器,并负责触发。

 

27 新特性:Tomcat如何支持异步Servlet?

 @WebServlet(urlPatterns = {"/async"}, asyncSupported = true) public class AsyncServlet extends HttpServlet

非阻塞I/O模型可以利用很少的线程处理大量的连接,提高了并发度,本质就是通过一个Selector线程查询多个Socket的I/O事件,减少了线程的阻塞等待。

同样,异步Servlet机制也是减少了线程的阻塞等待,将Tomcat线程和业务线程分开,Tomcat线程不再等待业务代码的执行。

那什么样的场景适合异步Servlet呢?适合的场景有很多,最主要的还是根据你的实际情况,如果你拿不准是否适合异步Servlet,就看一条:如果你发现Tomcat的线程不够了,大量线程阻塞在等待Web应用的处理上,而Web应用又没有优化的空间了,确实需要长时间处理,这个时候你不妨尝试一下异步Servlet。

参考: Servlet- 技术专题 -Servlet3 异步原理与实践

28 新特性:Spring Boot如何使用内嵌式的Tomcat和Jetty?

在Spring Boot启动类上加上@ServletComponentScan注解后,使用@WebServlet、@WebFilter、@WebListener标记的Servlet、Filter、Listener就可以自动注册到Servlet容器中,无需其他代码,

 

30 Spring框架中的设计模式

Spring的两大核心功能IOC和AOP中用到的一些设计模式,主要有简单工厂模式、工厂方法模式、单例模式和代理模式。而代理模式又分为静态代理和动态代理。JDK提供实现动态代理的机制,除此之外,还可以通过CGLIB来实现。

JDK的动态代理存在限制,那就是被代理的类必须是一个实现了接口的类,代理类需要实现相同的接口,代理接口中声明的方法。若需要代理的类没有实现接口,此时JDK的动态代理将没有办法使用,于是Spring会使用CGLib的动态代理来生成代理对象。CGLib直接操作字节码,生成类的子类,重写类的方法完成代理。

浅析Spring中AOP的实现原理——动态代理

 SpringBoot使用AOP(动态代理)

 Spring IoC 容器和 bean 简介

 

31 Logger组件:Tomcat的日志框架及实战

 默认情况下,Tomcat的日志模板叫作JULI,JULI的日志门面采用了JCL,而具体实现是基于Java默认的日志框架Java Util Logging,Tomcat在Java Util Logging基础上进行了改造,使得它自身的日志框架不会影响Web应用,并且可以分模板配置日志的输出文件和格式。
 

32 Manager组件:Tomcat的Session管理机制解析

 Tomcat中主要由每个Context容器内的一个Manager对象来管理Session。默认实现类为StandardManager
 在Tomcat热加载和热部署的文章里,我讲到容器组件会开启一个ContainerBackgroundProcessor后台线程,调用自己以及子容器的backgroundProcess进行一些后台逻辑的处理,和Lifecycle一样,这个动作也是具有传递性的,也就是说子容器还会把这个动作传递给自己的子容器。其中父容器会遍历所有的子容器并调用其backgroundProcess方法,而StandardContext重写了该方法,它会调用StandardManager的backgroundProcess进而完成Session的清理工作

Servlet规范中定义了HttpServletRequest和HttpSession接口,Tomcat实现了这些接口,但具体实现细节并没有暴露给开发者,因此定义了两个包装类,RequestFacade和StandardSessionFacade。

Tomcat是通过Manager来管理Session的,默认实现是StandardManager。StandardContext持有StandardManager的实例,并存放了HttpSessionListener集合,Session在创建和销毁时,会通知监听器。

 

33 Cluster组件:Tomcat的集群通信原理

 要实现集群通信,首先要知道集群中都有哪些成员。Tomcat是通过组播(Multicast)来实现的。
 组播是一台主机向指定的一组主机发送数据报包,组播通信的过程是这样的:每一个Tomcat节点在启动时和运行时都会周期性(默认500毫秒)发送组播心跳包,同一个集群内的节点都在相同的组播地址和端口监听这些信息;在一定的时间内(默认3秒)不发送组播报文的节点就会被认为已经崩溃了,会从集群中删去。因此通过组播,集群中每个成员都能维护一个集群成员列表。
有了集群成员的列表,集群中的节点就能通过TCP连接向其他节点传输Session数据。Tomcat通过SimpleTcpCluster类来进行会话复制(In-Memory Replication)。要开启集群功能,只需要将server.xml里的这一行的注释去掉就行:

 

Tomcat集群对Session的拷贝支持两种方式:DeltaManager和BackupManager。

当集群中节点比较少时,可以采用DeltaManager,因为Session数据在集群中各个节点都有备份,任何一个节点崩溃都不会对整体造成影响,可靠性比较高。

当集群中节点数比较多时,可以采用BackupManager,这是因为一个节点的Session只会拷贝到另一个节点,数据拷贝的开销比较少,同时只要这两个节点不同时崩溃,Session数据就不会丢失。

 

 34 JVM GC原理及调优的基本思路

 对于CMS来说,我们要合理设置年轻代和年老代的大小。你可能会问该如何确定它们的大小呢?这是一个迭代的过程,可以先采用JVM的默认值,然后通过压测分析GC日志。

如果我们看年轻代的内存使用率处在高位,导致频繁的Minor GC,而频繁GC的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。

如果我们看年老代的内存使用率处在高位,导致频繁的Full GC,这样分两种情况:如果每次Full GC后年老代的内存占用率没有下来,可以怀疑是内存泄漏;如果Full GC后年老代的内存占用率下来了,说明不是内存泄漏,我们要考虑调大年老代。

对于G1收集器来说,我们可以适当调大Java堆,因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。

 

35 如何监控Tomcat的性能?

Tomcat的关键指标有吞吐量、响应时间、错误数、线程池、CPU以及JVM内存。

如果我们监控到CPU上升,这时我们可以看看吞吐量是不是也上升了,如果是那说明正常;如果不是的话,可以看看GC的活动,如果GC活动频繁,并且内存居高不下,基本可以断定是内存泄漏。

对于一个Web应用来说,下游服务的延迟越大,Tomcat所需要的线程数越多,但是CPU保持稳定。所以如果你在实际工作碰到线程数飙升但是CPU没有增加的情况,这个时候你需要怀疑,你的Web应用所依赖的下游服务是不是出了问题,响应时间是否变长了。

 

36 Tomcat I_O和线程池的并发调优

今天我们学习了I/O调优,也就是如何选择连接器的类型,以及在选择过程中有哪些需要注意的地方。

后面还聊到Tomcat线程池的各种参数,其中最重要的参数是最大线程数maxThreads。理论上我们可以通过利特尔法则或者CPU时间与I/O时间的比率,计算出一个理想值,这个值只具有指导意义,因为它受到各种资源的限制,实际场景中,我们需要在理想值的基础上进行压测,来获得最佳线程数。

 

37 Tomcat内存溢出的原因分析及调优

 今天我讲解了常见的OutOfMemoryError的场景以及解决办法,我们在实际工作中要根据具体的错误信息去分析背后的原因,尤其是Java堆内存不够时,需要生成Heap Dump来分析,看是不是内存泄漏;排除内存泄漏之后,我们再调整各种JVM参数,否则根本的问题原因没有解决的话,调整JVM参数也无济于事
 

38 Tomcat拒绝连接原因分析及网络优化

 在Socket网络通信过程中,我们不可避免地会碰到各种Java异常,了解这些异常产生的原因非常关键,通过这些信息我们大概知道问题出在哪里,如果一时找不到问题代码,我们还可以通过网络抓包工具来分析数据包。

在这个基础上,我们还分析了Tomcat中两个比较重要的参数:acceptCount和maxConnections。acceptCount用来控制内核的TCP连接队列长度,maxConnections用于控制Tomcat层面的最大连接数。在实战环节,我们通过调整acceptCount和相关的内核参数somaxconn,增加了系统的并发度。

39 Tomcat进程占用CPU过高怎么办?

 当我们遇到CPU过高的问题时,首先要定位是哪个进程的导致的,之后可以通过top -H -p pid命令定位到具体的线程。其次还要通jstack查看线程的状态,看看线程的个数或者线程的状态,如果线程数过多,可以怀疑是线程上下文切换的开销,我们可以通过vmstat和pidstat这两个工具进行确认
 

 

posted on 2024-01-31 15:08  yuluoxingkong  阅读(22)  评论(0编辑  收藏  举报