$walker
Java · 并发

Java 线程池的坑

从一次线上卡死事故出发,拆解 Executors.newFixedThreadPool 因无界队列导致 OOM 的源码级原因,并给出自定义线程池的正确写法。

发表于 2020-06-09约 5 分钟更新于 2023-08-01Java并发

背景

最近在优化代码(把一个大任务拆成使用多线程分批执行的小任务),使用多线程首当其冲就是使用线程池。一般比较常用的就是 Executors.newFixedThreadPool,毕竟有现成的就用是菜鸟的一贯风格,然而却不知道已经掉入坑中了。

现象

优化完代码后,自测是一个好的开发必做的事情,毕竟好的开发不应该让测试太劳累。首先来常规的边界值测试:把每一批执行的量设置为 1,尽可能模拟多批次。在某个界面操作完后,再次点击其中特定的界面,发现卡在那里,前端一直等待后端响应。(内心 OS:我可真是个写 bug 小能手)

排查

一般前端卡住,基本就是两种情况:数据库连接池不够用,或者 Java 线程池被占满。

  1. 首先排查是否是数据库连接池的问题。连接数据库 show processlist 一下,你想看的不想看的都能看到,发现全是 sleep,睡得可香呢,不打扰,不打扰。
  2. 接下来,矛头直指 Java 线程池。为了方便排查,作为写外挂小能手的我,二话不说写了个接口,用于查看线程池里线程的情况,主要看 activeCountcompletedTaskCount

复现

万事俱备,只等再来一次——解决问题的前提是复现问题。按照流程又来了一次,果不其然,又卡在那里了。看了一下我的接口,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 主要有两种实现,分别是 ArrayBlockingQueueLinkedBlockingQueue

问题就出在这里:newFixedThreadPool 创建 LinkedBlockingQueue 时并未指定容量,此时它就是一个无边界队列。对于无边界队列,可以不断向其中加入任务,这种情况下就有可能因为任务过多而导致内存溢出(OOM)

以上是大坑,以下是小坑。

解决

再来看其他三种创建线程池的方式:newSingleThreadExecutornewCachedThreadPoolnewScheduledThreadPool——要不就是无边界队列导致 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 导致系统崩掉。使用自定义创建线程池,参数根据实际的场景设置。

参考资料