现象
在线上环境排查问题时,某个线程池在某个时间点新建线程达到设定的最大线程数 maximumPoolSize,后续流量降低后当前线程数仍未回落,仍然为最大线程数,阻塞队列中有任务,但是活跃线程数显著减少。
之前的认知
固有的认知中,线程池运行原理:java.util.concurrent.ThreadPoolExecutor#execute
- 线程池内部维护 corePoolSize 个线程
- 任务提交后,若核心线程都已被占用,则添加到阻塞队列
- 阻塞队列已满,则新建线程直到线程数到达 maximumPoolSize
- 若阻塞队列已满,并且线程数到达 maximumPoolSize,则执行拒绝策略
- 超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,进行销毁。
冲突
认知第五点中:超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,进行销毁。明显与现象不符。现象肯定没问题的,就是认知有问题了:超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,至少不会马上销毁。
现实与认知的问题
- 超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,会不会销毁?
- 销毁的时机是?
- 为什么线程池中大多为休眠线程?线程池的线程数仍为最大线程数?
重塑认知
答案都在源码内
ThreadPoolExecutor 执行任务流程
线程池使用 demo
1 | ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 10, 100, TimeUnit.MINUTES, new ArrayBlockingQueue<>(1000)); |
执行流程
java.util.concurrent.ThreadPoolExecutor#execute
流程就是之前认知中 1 - 4 点,在第三点中蕴含一个重要变量:java.util.concurrent.ThreadPoolExecutor#workers
,这个就是ThreadPoolExecutor 管理线程的对象
workers 移除流程
源码上看,只有以下两个方法
1 | java.util.concurrent.ThreadPoolExecutor#addWorkerFailed |
望文生义,addWorkerFailed 作用为添加 worker 后的失败补偿动作,可以忽略这个方法。
所以正常的销毁动作,肯定是在 processWorkerExit 中。
processWorkerExit 执行流程
使用场景
仅在java.util.concurrent.ThreadPoolExecutor#runWorker
中 finally 执行
而 runWorker 则是任务执行的底层方法,那么这意味着:任务执行完,满足某几个前提条件就会销毁线程。那么前提条件是什么呢?
runWorker 执行流程
- while 循环调用
java.util.concurrent.ThreadPoolExecutor#getTask
获取任务- 获取到任务后,走真实执行任务流程,beforeExecute/run/afterExecute
- 获取不到任务,则到 processWorkerExit 执行
getTask 执行流程
- 使用当前 worker 数与核心线程数关系判定变量 timed
- 根据 timed 判定
timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take()
keepAliveTime 第一次出现,并且是用于在当前 worker 数大于核心线程数情况下从阻塞队列中获取元素。
那么,控制 processWorkerExit 执行的前提条件:当前 worker 数大于核心线程数,并且从阻塞队列经过 keepAliveTime 拿不到任务。
但这个前提条件明显跟现象不符,那肯定是 workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS)
中被阻塞了,导致实际获取任务时间 > keepAliveTime。
workQueue.poll 执行流程(以 ArrayBlockingQueue 为例)
- 获取 ArrayBlockingQueue 全局锁
- 当队列元素个数 = 0, 则 await keepAliveTime 时间
- 队列元素个数 != 0,出队元素
- 释放 ArrayBlockingQueue 全局锁
1 | public E poll(long timeout, TimeUnit unit) throws InterruptedException { |
真相
从workQueue.poll 执行流程中,能明显看到线程 await 的前提是获取到队列的全局锁,并且队列元素 = 0。
整理一遍就是:
当线程获取到队列全局锁,并且当前队列为空,await keepAliveTime 后,若当前队列为空,则执行销毁方法。
1 | @startuml |
那么上述提到的两个问题
- 超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,会不会销毁?
- 销毁的时机是?
- 为什么线程池中大多为休眠线程?线程池的线程数仍为最大线程数?
就有了答案
超过 corePoolSize 部分的空闲线程,到达 keepAliveTime 后,有可能销毁,前提是拿到队列的全局锁。
销毁的时机是当前线程获取到队列全局锁,并且队列元素 = 0,并且 await 后队列元素仍然为 0
因为线上提交任务刚好够核心线程消费,并且残留少数任务在阻塞队列中。在并发情况下,大部分线程都 await,线程池只能新增 worker 处理了。
自言自语
怎么解决当前线程数 = 最大线程数,并且活跃线程较少的情况?
- 调高 corePoolSize ,使线程池不新增 corePoolSize 之外的线程。
- 调低 keepAliveTime & TimeUnit 的值,使休眠线程快速被销毁。
在商业开发的角度上,比较难精准实现。
- 业务发展速度很快, corePoolSize 在将来的一段时间内就不适合了。
- 加快休眠线程的销毁,意味着存在频繁新建线程的问题,会影响系统稳定性。
为什么 await keepAliveTime后不直接销毁?还尝试出队元素?
这就回到 java 线程与操作系统线程的映射关系。
线程模型有三种:一对一,多对一,一对多。java 在大多数平台上都是一对一。
- 如果直接销毁,核心线程处理不过来情况下,线程池会频繁销毁/新建线程,消耗系统的资源。
- 尝试出队元素,double check 线程池的负载,负载高则继续处理,负载较低则销毁线程,达到节省资源的目的。
keepAliveTime 的理解
源码中的注释
1 | when the number of threads is greater than the core, this is the maximum time that excess idle threads will wait for new tasks before terminating. |
之前以为是线程数大于 Core 数时,空闲线程的存活时间。过了 keepAliveTime 就执行销毁。
现在认识到:线程数大于 Core 数时,空闲线程的存活时间 >= keepAliveTime (没获取到队列锁的情况下),并且销毁前 double check 是否有任务,没有才执行销毁。
本文首发于cartoon的博客
转载请注明出处:https://cartoonyu.github.io