背景
最近在优化代码(把一个大任务拆成使用多线程分批执行的小任务),使用多线程首当其冲就是使用线程池。一般比较常用的就是 Executors.newFixedThreadPool,毕竟有现成的就用是菜鸟的一贯风格,然而却不知道已经掉入坑中了。
现象
优化完代码后,自测是一个好的开发必做的事情,毕竟好的开发不应该让测试太劳累。首先来常规的边界值测试:把每一批执行的量设置为 1,尽可能模拟多批次。在某个界面操作完后,再次点击其中特定的界面,发现卡在那里,前端一直等待后端响应。(内心 OS:我可真是个写 bug 小能手)
排查
一般前端卡住,基本就是两种情况:数据库连接池不够用,或者 Java 线程池被占满。
- 首先排查是否是数据库连接池的问题。连接数据库
show processlist一下,你想看的不想看的都能看到,发现全是 sleep,睡得可香呢,不打扰,不打扰。 - 接下来,矛头直指 Java 线程池。为了方便排查,作为写外挂小能手的我,二话不说写了个接口,用于查看线程池里线程的情况,主要看
activeCount和completedTaskCount。
复现
万事俱备,只等再来一次——解决问题的前提是复现问题。按照流程又来了一次,果不其然,又卡在那里了。看了一下我的接口,activeCount 被占得死死的,小样,你的锅跑不掉了。
分析
Executors 是 Java 中的一个工具类,提供工厂方法来创建不同类型的线程池。newFixedThreadPool(int nThreads) 用于创建固定数目线程的线程池。
乍一看好像没毛病:JDK 自身提供的构建线程池的方式,又用到了工厂模式、又有比较强的扩展性,重要的是用起来还比较方便,多重光环加身,让你无法说不。
lazy val threadPool = Executors.newFixedThreadPool(20)
创建一个固定大小的线程池就这么简单,省时省力省心。
但是知人知面不知心,让我们进一步查看源码:
/**
* Creates a thread pool that reuses a fixed number of threads
* operating off a shared unbounded queue. At any point, at most
* {@code nThreads} threads will be active processing tasks.
* ...
* @param nThreads the number of threads in the pool
* @return the newly created thread pool
* @throws IllegalArgumentException if {@code nThreads <= 0}
*/
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
LinkedBlockingQueue 成功引起了我的注意,这是个啥玩意,点进去一看:
/**
* Creates a {@code LinkedBlockingQueue} with a capacity of
* {@link Integer#MAX_VALUE}.
*/
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
Java 中的 BlockingQueue 主要有两种实现,分别是 ArrayBlockingQueue 和 LinkedBlockingQueue:
ArrayBlockingQueue是用数组实现的有界阻塞队列,必须设置容量。LinkedBlockingQueue是用链表实现的有界阻塞队列,容量可以选择进行设置,不设置的话将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。
问题就出在这里:newFixedThreadPool 创建 LinkedBlockingQueue 时并未指定容量,此时它就是一个无边界队列。对于无边界队列,可以不断向其中加入任务,这种情况下就有可能因为任务过多而导致内存溢出(OOM)。
以上是大坑,以下是小坑。
-
任务提交后长时间没有执行
任务进入了队列,线程还在执行之前的任务。本质原因是对线程和队列的优先级认识不深刻,有一种错觉以为是所有线程都忙的时候才进入任务队列。实际上相反,是队列满的时候才会新建线程(线程数大于 core size 时)。
-
线程池中线程执行任务时无故消失(从日志可以看出,任务并未完成,也没有抛出异常)
一般情况下,代码中只会去捕捉
RuntimeException,如果抛出Error则会导致线程退出,而异常信息又没有拿到。最佳解决办法是给线程池设置UncaughtExceptionHandler。
解决
再来看其他三种创建线程池的方式:newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool——要不就是无边界队列导致 OOM,要不就是最大线程数导致 OOM,都有坑,只能自定义创建线程池了。写个方法方便使用:
/**
* 不使用系统自带的四个 Executors,有坑,
* 轻则线程中任务没有执行,重则 OOM 导致系统崩掉。
* 使用有边界队列,超出队列的情况返回给调用者执行。
*
* @param nThreads 线程数
*/
def newFixedThreadPool(nThreads: Int): ThreadPoolExecutor = {
new ThreadPoolExecutor(nThreads, nThreads, 3L, TimeUnit.SECONDS,
new ArrayBlockingQueue(nThreads),
Executors.defaultThreadFactory,
new ThreadPoolExecutor.CallerRunsPolicy)
}
几个核心参数的含义:
| 参数 | 说明 |
|---|---|
corePoolSize |
核心池大小。需要注意的是,初创建线程池时线程不会立即启动,直到有任务提交才开始启动线程,并逐渐使线程数目达到 corePoolSize。若想一开始就创建所有核心线程,需调用 prestartAllCoreThreads 方法。 |
maximumPoolSize |
池中允许的最大线程数。需要注意的是,当核心线程满且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,并决定是否创建新线程。 |
keepAliveTime |
当线程数大于核心数时,多余的空闲线程最多存活的时间。 |
unit |
keepAliveTime 参数的时间单位。 |
workQueue |
当线程数目超过核心线程数时用于保存任务的队列。主要有 3 种类型的 BlockingQueue 可供选择:无界队列、有界队列和同步移交。此队列仅保存实现 Runnable 接口的任务。别看这个参数位置很靠后,但是真的很重要,有的坑就因这个参数而起,这些细节有必要仔细了解清楚。 |
threadFactory |
执行程序创建新线程时使用的工厂。 |
handler |
阻塞队列已满且线程数达到最大值时所采取的饱和策略。Java 默认提供了 4 种实现:中止、抛弃、抛弃最旧的、调用者运行。 |
总结
不要使用系统自带的四个 Executors,有坑:轻则线程中任务没有执行,重则 OOM 导致系统崩掉。使用自定义创建线程池,参数根据实际的场景设置。