Tomcat 高并发之道原理拆解与性能调优(下篇)

大家好我是佩奇
发布于 2022-8-10 18:07
浏览
0收藏

续:Tomcat 高并发之道原理拆解与性能调优(上篇)

 

连接器之 I/O 模型与线程池设计
连接器主要功能就是接受 TCP/IP 连接,限制连接数然后读取数据,最后将请求转发到 Container 容器。所以这里必然涉及到 I/O 编程,今天带大家一起分析 Tomcat 如何运用 I/O 模型实现高并发的,一起进入 I/O 的世界。

I/O 模型主要有 5 种:同步阻塞、同步非阻塞、I/O 多路复用、信号驱动、异步 I/O。是不是很熟悉但是又傻傻分不清他们有何区别?

所谓的I/O 就是计算机内存与外部设备之间拷贝数据的过程。

CPU 是先把外部设备的数据读到内存里,然后再进行处理。请考虑一下这个场景,当程序通过 CPU 向外部设备发出一个读指令时,数据从外部设备拷贝到内存往往需要一段时间,这个时候 CPU 没事干了,程序是主动把 CPU 让给别人?还是让 CPU 不停地查:数据到了吗,数据到了吗……

这就是 I/O 模型要解决的问题。今天我会先说说各种 I/O 模型的区别,然后重点分析 Tomcat 的 NioEndpoint 组件是如何实现非阻塞 I/O 模型的。

 

I/O 模型
一个网络 I/O 通信过程,比如网络数据读取,会涉及到两个对象,分别是调用这个 I/O 操作的用户线程和操作系统内核。一个进程的地址空间分为用户空间和内核空间,用户线程不能直接访问内核空间。

网络读取主要有两个步骤:

 ● 用户线程等待内核将数据从网卡复制到内核空间。
 ● 内核将数据从内核空间复制到用户空间。
同理,将数据发送到网络也是一样的流程,将数据从用户线程复制到内核空间,内核空间将数据复制到网卡发送。

不同 I/O 模型的区别:实现这两个步骤的方式不一样。

 ● 对于同步,则指的应用程序调用一个方法是否立马返回,而不需要等待。
 ● 对于阻塞与非阻塞:主要就是数据从内核复制到用户空间的读写操作是否是阻塞等待的。


同步阻塞 I/O
用户线程发起read调用的时候,线程就阻塞了,只能让出 CPU,而内核则等待网卡数据到来,并把数据从网卡拷贝到内核空间,当内核把数据拷贝到用户空间,再把刚刚阻塞的读取用户线程唤醒,两个步骤的线程都是阻塞的。

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区同步阻塞 I/O

同步非阻塞
用户线程一直不停的调用read方法,如果数据还没有复制到内核空间则返回失败,直到数据到达内核空间。用户线程在等待数据从内核空间复制到用户空间的时间里一直是阻塞的,等数据到达用户空间才被唤醒。循环调用read方法的时候不阻塞。

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区

同步非阻塞

I/O 多路复用
用户线程的读取操作被划分为两步:

1.用户线程先发起 select 调用,主要就是询问内核数据准备好了没?当内核把数据准备好了就执行第二步。
2.用户线程再发起 read 调用,在等待内核把数据从内核空间复制到用户空间的时间里,发起 read 线程是阻塞的。
为何叫 I/O 多路复用,核心主要就是:一次 select 调用可以向内核查询多个**数据通道(Channel)**的状态,因此叫多路复用。

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区I/O 多路复用

异步 I/O
用户线程执行 read 调用的时候会注册一个回调函数, read 调用立即返回,不会阻塞线程,在等待内核将数据准备好以后,再调用刚刚注册的回调函数处理数据,在整个过程中用户线程一直没有阻塞。

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区

异步 I/O

Tomcat NioEndpoint
Tomcat 的 NioEndpoit 组件实际上就是实现了 I/O 多路复用模型,正是因为这个并发能力才足够优秀。让我们一起窥探下 Tomcat NioEndpoint 的设计原理。

对于 Java 的多路复用器的使用,无非是两步:

1.创建一个 Seletor,在它身上注册各种感兴趣的事件,然后调用 select 方法,等待感兴趣的事情发生。
2.感兴趣的事情发生了,比如可以读了,这时便创建一个新的线程从 Channel 中读数据。
Tomcat 的 NioEndpoint 组件虽然实现比较复杂,但基本原理就是上面两步。我们先来看看它有哪些组件,它一共包含 LimitLatch、Acceptor、Poller、SocketProcessor 和 Executor 共 5 个组件,它们的工作过程如下图所示:

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区

NioEndPoint

正是由于使用了 I/O 多路复用,Poller 内部本质就是持有 Java Selector 检测 channel 的 I/O 时间,当数据可读写的时候创建 SocketProcessor 任务丢到线程池执行,也就是少量线程监听读写事件,接着专属的线程池执行读写,提高性能。

 

自定义线程池模型
为了提高处理能力和并发度, Web 容器通常会把处理请求的工作放在线程池来处理, Tomcat 拓展了 Java 原生的线程池来提升并发需求,在进入 Tomcat 线程池原理之前,我们先回顾下 Java 线程池原理。

 

Java 线程池
简单的说,Java 线程池里内部维护一个线程数组和一个任务队列,当任务处理不过来的时,就把任务放到队列里慢慢处理。

 

ThreadPoolExecutor
来窥探线程池核心类的构造函数,我们需要理解每一个参数的作用,才能理解线程池的工作原理。

 public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        ......
    }

 ● corePoolSize:保留在池中的线程数,即使它们空闲,除非设置了 allowCoreThreadTimeOut,不然不会关闭。
 ● maximumPoolSize:队列满后池中允许的最大线程数。
 ● keepAliveTime、TimeUnit:如果线程数大于核心数,多余的空闲线程的保持的最长时间会被销毁。unit 是 keepAliveTime 参数的时间单位。当设置  ● allowCoreThreadTimeOut(true) 时,线程池中 corePoolSize 范围内的线程空闲时间达到 keepAliveTime 也将回收。
 ● workQueue:当线程数达到 corePoolSize 后,新增的任务就放到工作队列 workQueue 里,而线程池中的线程则努力地从 workQueue 里拉活来干,也就是调用 poll 方法来获取任务。
 ● ThreadFactory:创建线程的工厂,比如设置是否是后台线程、线程名等。
 ● RejectedExecutionHandler:拒绝策略,处理程序因为达到了线程界限和队列容量执行拒绝策略。也可以自定义拒绝策略,只要实现 RejectedExecutionHandler 即可。默认的拒绝策略:AbortPolicy 拒绝任务并抛出 RejectedExecutionException 异常;CallerRunsPolicy 提交该任务的线程执行;``
来分析下每个参数之间的关系:

提交新任务的时候,如果线程池数 < corePoolSize,则创建新的线程池执行任务,当线程数 = corePoolSize 时,新的任务就会被放到工作队列 workQueue 中,线程池中的线程尽量从队列里取任务来执行。

如果任务很多,workQueue 满了,且 当前线程数 < maximumPoolSize 时则临时创建线程执行任务,如果总线程数量超过 maximumPoolSize,则不再创建线程,而是执行拒绝策略。DiscardPolicy 什么都不做直接丢弃任务;DiscardOldestPolicy 丢弃最旧的未处理程序;

具体执行流程如下图所示:

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区

线程池执行流程

Tomcat 线程池
定制版的 ThreadPoolExecutor,继承了 java.util.concurrent.ThreadPoolExecutor。对于线程池有两个很关键的参数:

 ● 线程个数。
 ● 队列长度。
Tomcat 必然需要限定想着两个参数不然在高并发场景下可能导致 CPU 和内存有资源耗尽的风险。继承了 与 java.util.concurrent.ThreadPoolExecutor 相同,但实现的效率更高。

其构造方法如下,跟 Java 官方的如出一辙

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
        prestartAllCoreThreads();
    }

在 Tomcat 中控制线程池的组件是 StandardThreadExecutor , 也是实现了生命周期接口,下面是启动线程池的代码

 @Override
    protected void startInternal() throws LifecycleException {
        // 自定义任务队列
        taskqueue = new TaskQueue(maxQueueSize);
        // 自定义线程工厂
        TaskThreadFactory tf = new TaskThreadFactory(namePrefix,daemon,getThreadPriority());
       // 创建定制版线程池
        executor = new ThreadPoolExecutor(getMinSpareThreads(), getMaxThreads(), maxIdleTime, TimeUnit.MILLISECONDS,taskqueue, tf);
        executor.setThreadRenewalDelay(threadRenewalDelay);
        if (prestartminSpareThreads) {
            executor.prestartAllCoreThreads();
        }
        taskqueue.setParent(executor);
        // 观察者模式,发布启动事件
        setState(LifecycleState.STARTING);
    }

其中的关键点在于:

1.Tomcat 有自己的定制版任务队列和线程工厂,并且可以限制任务队列的长度,它的最大长度是 maxQueueSize。
2.Tomcat 对线程数也有限制,设置了核心线程数(minSpareThreads)和最大线程池数(maxThreads)。
除此之外, Tomcat 在官方原有基础上重新定义了自己的线程池处理流程,原生的处理流程上文已经说过。

 ● 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
 ● 还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。
 ● 线程总线数达到 maximumPoolSize ,直接执行拒绝策略。
Tomcat 线程池扩展了原生的 ThreadPoolExecutor,通过重写 execute 方法实现了自己的任务处理逻辑:

 ● 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
 ● 还有任务提交,直接放到队列,队列满了,但是没有达到最大线程池数则创建临时线程救火。
 ● 线程总线数达到 maximumPoolSize ,继续尝试把任务放到队列中。如果队列也满了,插入任务失败,才执行拒绝策略。
最大的差别在于 Tomcat 在线程总数达到最大数时,不是立即执行拒绝策略,而是再尝试向任务队列添加任务,添加失败后再执行拒绝策略。

代码如下所示:

  public void execute(Runnable command, long timeout, TimeUnit unit) {
       // 记录提交任务数 +1
        submittedCount.incrementAndGet();
        try {
            // 调用 java 原生线程池来执行任务,当原生抛出拒绝策略
            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("Queue capacity is full.");
                    }
                } catch (InterruptedException x) {
                    submittedCount.decrementAndGet();
                    throw new RejectedExecutionException(x);
                }
            } else {
              // 提交任务书 -1
                submittedCount.decrementAndGet();
                throw rx;
            }

        }
    }

Tomcat 线程池是用 submittedCount 来维护已经提交到了线程池,这跟 Tomcat 的定制版的任务队列有关。Tomcat 的任务队列 TaskQueue 扩展了 Java 中的 LinkedBlockingQueue,我们知道 LinkedBlockingQueue 默认情况下长度是没有限制的,除非给它一个 capacity。因此 Tomcat 给了它一个 capacity,TaskQueue 的构造函数中有个整型的参数 capacity,TaskQueue 将 capacity 传给父类 LinkedBlockingQueue 的构造函数,防止无限添加任务导致内存溢出。而且默认是无限制,就会导致当前线程数达到核心线程数之后,再来任务的话线程池会把任务添加到任务队列,并且总是会成功,这样永远不会有机会创建新线程了。

为了解决这个问题,TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程。

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 需要维护已提交任务数这个变量,它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。可以通过设置 maxQueueSize 参数来限制任务队列的长度。

 

性能优化
线程池调优
跟 I/O 模型紧密相关的是线程池,线程池的调优就是设置合理的线程池参数。我们先来看看 Tomcat 线程池中有哪些关键参数:

Tomcat 高并发之道原理拆解与性能调优(下篇)-鸿蒙开发者社区

这里面最核心的就是如何确定 maxThreads 的值,如果这个参数设置小了,Tomcat 会发生线程饥饿,并且请求的处理会在队列中排队等待,导致响应时间变长;如果 maxThreads 参数值过大,同样也会有问题,因为服务器的 CPU 的核数有限,线程数太多会导致线程在 CPU 上来回切换,耗费大量的切换开销。

 

线程 I/O 时间与 CPU 时间

至此我们又得到一个线程池个数的计算公式,假设服务器是单核的:

线程池大小 = (线程 I/O 阻塞时间 + 线程 CPU 时间 )/ 线程 CPU 时间

其中:线程 I/O 阻塞时间 + 线程 CPU 时间 = 平均请求处理时间。

 

Tomcat 内存溢出的原因分析及调优
JVM 在抛出 java.lang.OutOfMemoryError 时,除了会打印出一行描述信息,还会打印堆栈跟踪,因此我们可以通过这些信息来找到导致异常的原因。在寻找原因前,我们先来看看有哪些因素会导致 OutOfMemoryError,其中内存泄漏是导致 OutOfMemoryError 的一个比较常见的原因。

其实调优很多时候都是在找系统瓶颈,假如有个状况:系统响应比较慢,但 CPU 的用率不高,内存有所增加,通过分析 Heap Dump 发现大量请求堆积在线程池的队列中,请问这种情况下应该怎么办呢?可能是请求处理时间太长,去排查是不是访问数据库或者外部应用遇到了延迟。

 

java.lang.OutOfMemoryError: Java heap space
当 JVM 无法在堆中分配对象的会抛出此异常,一般有以下原因:

1.内存泄漏:本该回收的对象呗程序一直持有引用导致对象无法被回收,比如在线程池中使用 ThreadLocal、对象池、内存池。为了找到内存泄漏点,我们通过 jmap 工具生成 Heap Dump,再利用 MAT 分析找到内存泄漏点。jmap -dump:live,format=b,file=filename.bin pid
2.内存不足:我们设置的堆大小对于应用程序来说不够,修改 JVM 参数调整堆大小,比如 -Xms256m -Xmx2048m。
3.finalize 方法的过度使用。如果我们想在 Java 类实例被 GC 之前执行一些逻辑,比如清理对象持有的资源,可以在 Java 类中定义 finalize 方法,这样 JVM GC 不会立即回收这些对象实例,而是将对象实例添加到一个叫“java.lang.ref.Finalizer.ReferenceQueue”的队列中,执行对象的 finalize 方法,之后才会回收这些对象。Finalizer 线程会和主线程竞争 CPU 资源,但由于优先级低,所以处理速度跟不上主线程创建对象的速度,因此 ReferenceQueue 队列中的对象就越来越多,最终会抛出 OutOfMemoryError。解决办法是尽量不要给 Java 类定义 finalize 方法。


java.lang.OutOfMemoryError: GC overhead limit exceeded
垃圾收集器持续运行,但是效率很低几乎没有回收内存。比如 Java 进程花费超过 96%的 CPU 时间来进行一次 GC,但是回收的内存少于 3%的 JVM 堆,并且连续 5 次 GC 都是这种情况,就会抛出 OutOfMemoryError。

这个问题 IDE 解决方法就是查看 GC 日志或者生成 Heap Dump,先确认是否是内存溢出,不是的话可以尝试增加堆大小。可以通过如下 JVM 启动参数打印 GC 日志:

-verbose:gc //在控制台输出GC情况
-XX:+PrintGCDetails  //在控制台输出详细的GC情况
-Xloggc: filepath  //将GC日志输出到指定文件中

比如 可以使用 java -verbose:gc -Xloggc:gc.log -XX:+PrintGCDetails -jar xxx.jar 记录 GC 日志,通过 GCViewer 工具查看 GC 日志,用 GCViewer 打开产生的 gc.log 分析垃圾回收情况。

 

java.lang.OutOfMemoryError: Requested array size exceeds VM limit
抛出这种异常的原因是“请求的数组大小超过 JVM 限制”,应用程序尝试分配一个超大的数组。比如程序尝试分配 128M 的数组,但是堆最大 100M,一般这个也是配置问题,有可能 JVM 堆设置太小,也有可能是程序的 bug,是不是创建了超大数组。

 

java.lang.OutOfMemoryError: MetaSpace
JVM 元空间的内存在本地内存中分配,但是它的大小受参数 MaxMetaSpaceSize 的限制。当元空间大小超过 MaxMetaSpaceSize 时,JVM 将抛出带有 MetaSpace 字样的 OutOfMemoryError。解决办法是加大 MaxMetaSpaceSize 参数的值。

 

java.lang.OutOfMemoryError: Request size bytes for reason. Out of swap space
当本地堆内存分配失败或者本地内存快要耗尽时,Java HotSpot VM 代码会抛出这个异常,VM 会触发“致命错误处理机制”,它会生成“致命错误”日志文件,其中包含崩溃时线程、进程和操作系统的有用信息。如果碰到此类型的 OutOfMemoryError,你需要根据 JVM 抛出的错误信息来进行诊断;或者使用操作系统提供的 DTrace 工具来跟踪系统调用,看看是什么样的程序代码在不断地分配本地内存。

 

java.lang.OutOfMemoryError: Unable to create native threads
1.Java 程序向 JVM 请求创建一个新的 Java 线程。
2.JVM 本地代码(Native Code)代理该请求,通过调用操作系统 API 去创建一个操作系统级别的线程 Native Thread。
3.操作系统尝试创建一个新的 Native Thread,需要同时分配一些内存给该线程,每一个 Native Thread 都有一个线程栈,线程栈的大小由 JVM 参数-Xss决定。
4.由于各种原因,操作系统创建新的线程可能会失败,下面会详细谈到。
5.JVM 抛出“java.lang.OutOfMemoryError: Unable to create new native thread”错误。
这里只是概述场景,对于生产在线排查后续会陆续推出,受限于篇幅不再展开。

 

总结
回顾 Tomcat 总结架构设计,详细拆解 Tomcat 如何处理高并发连接设计。并且分享了如何高效阅读开源框架源码思路,设计模式、并发编程基础是重中之重,读者朋友可以翻阅我的主页查看更过关于「码哥字节」的文章。

 

 

 

文章转载自公众号:码哥字节

已于2022-8-10 18:07:48修改
收藏
回复
举报
回复
    相关推荐