本文收录在TechBeacon
最近发生了什么
最近的服务因请求突然飙升,导致 Tomcat 创建大量的线程来处理请求,在请求逐渐减少之后,通过监控发现存在大量的空闲线程没有回收,监控图:
存在大量的空闲线程,会占用大量的 JVM 内存,导致新生代的内存不足,从而导致频繁 Young GC,甚至 Full GC,对响应时间敏感的服务来说,这就是一次故障。由于对 Tomcat 线程池缺乏理解,也不能单凭直觉来修改线上的 Tomcat 参数,最后,只能通过重启来暂时解决这个问题。事后,了解了 Tomcat 线程池的原理,通过修改 Tomcat 线程池的参数来优化掉这个问题,因此,在这里分享下我所理解的 Tomcat 线程池。
Tomcat 是如何处理请求的
Tomcat 是通过连接器来处理 HTTP 请求,连接器的主要作用是负责接收网络请求,解析 HTTP 协议,最终将请求调用到具体的 Java 服务,如下:
在连接器内部就要有一个类(Endpoint)来完成 TCP 请求的监听、连接、读写以及 HTTP 协议解析等操作,最后将解析后的请求统一交给容器来处理,容器其实就是会帮我们把请求传递到对应的 Java 服务。 对于高版本的 Tomcat 默认使用了 JDK NIO 类库来实现 Endpoint,也就是 NioEndpoint,我们所需要了解的线程池也包含在 NioEnpoint 内,以下通过 NioEndpoint 的处理图来进行说明:
LimitLatch 的主要作用是对请求进行限流,控制 TCP 连接数,默认是 10000,可以通过 maxConnection 参数进行修改,达到阈值之后、连接请求会被拒绝。
Acceptor 的主要作用是接收连接,实现了 Runnale 接口,因此可以运行多个 Acceptor 线程来接收连接,在 Acceptor 接收连接后,会得到一个 socketChannel(通道,可简单理解为新的 NIO 库使用通道来读写数据)。然后,再将 socketChannel 封装成一个 PollerEvent (简单理解为 Acceptor 与 Poller 通信的 DTO) 存入到队列中,等待 Poller 进行消费。
Poller 的主要作用是从队列中获取 PollerEvent,并使用 Selector(I/O 多路复用)来轮询 socketChannel 的状态,一旦 socketChannel 可连接、可读、可写,就生成一个 SocketProcessor 任务对象交给 Executor 进行处理。Poller 的另一个重要任务是循环遍历检查自己所管理的 SocketChannel 是否已经超时,如果有超时就关闭这个 SocketChannel。
Executor 就是 Tomcat 比较重要的线程池了,主要作用是负责读写网络请求,解析 HTTP 协议。Executor 线程池主要是执行 Poller 生成的 SocketProcessor 任务(它实现了 Runnale 接口,实现了 run 方法),SocketProcessor 会调用 HTTP1Processor(HTTP 解析处理器)来进行 HTTP 请求读取,如下:
最后将读取的数据,转换成统一的对象并传递给容器进行处理。
Tomcat 线程池是如何扩展JAVA线程的
通过 Java 线程的构造函数来了解构建线程池的一些参数:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
- 当向线程池提交一个任务时,若线程池线程数未超过
corePoolSize,线程池就会创建线程来处理任务。 - 当核心线程数满了,并且持续有任务提交,若
workQueue未满,那么就把任务提交到workQueue排队处理。 - 当
workQueue已满,并且线程数未达到maximumPoolSize,那么就创建临时线程来进行处理。 - 线程池里的线程处理完当前的任务之后,还会阻塞队列读取任务来执行,阻塞的时间受
keepAliveTime控制,若超过这个时间控制没有读取到任务,那么线程就会被回收。
Tomcat 的线程池实现大体与 Java 线程池实现一致,Tomcat 主要针对线程池创建线程和入队这块进行了扩展,差别如下:
- 当线程池线程数量达到最大线程池时,JAVA 线程池会直接执行拒绝策略,而 Tomcat 会尝试将任务提交到队列中,若提交失败,再执行拒绝策略。
- 如果 Java 线程池使用了无界队列,那么 Java 线程池是永远无法创建临时线程的,而 Tomcat 通过定制化
TaskQueue队列来解决了这个问题,即使队列未满,仍然有机会创建临时线程。TaskQueue继承了 JavaLinkedBlockingQueue,并重写了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 通过自定义的 TaskQueue 来解决无界队列带来的问题,那么在高峰的时候,Tomcat 线程池还是会创建临时线程来处理任务,由于线上线程池的 keepAliveTime 的时间采用默认的配置,也即是 1 分钟,那么在请求高峰过后,这些线程都阻塞获取任务,阻塞线程的状态为 TIME_WAITING(通过分析堆栈,确实比较多线程处于该状态)。只要超过 1 分钟没有获取到的任务,那么该线程就会被回收,一旦能够读取到这个任务,那么处理完还得重新阻塞 1 分钟,从而导致空闲线程没有被回收。
如何优化
Tomcat 空闲线程数没有释放往往都是现象,我们应该关注服务遇到高并发的请求,为什么会造成 Tomcat 线程数的激增,甚至打满,这个要从服务里面的去优化,尽可能的降低接口的响应时间,另外,再通过线程池配置将 Tomcat 线程池线程的阻塞时间适当的降低,让空闲的线程得到回收。