背景
最近在优化代码(把一个大任务变成使用多线程分批执行小任务),使用多线程首当其冲就是使用线程池,一般比较常用的就是Executors.newFixedThreadPool,毕竟有现成的就用是菜鸟的一贯风格,然而却不知道已经掉入坑中了。
现象
优化完代码后,自测是一个好的开发必做的事情,毕竟好的开发不应该让测试太劳累,首先来常规的边界值测试,把每一批执行的量设置为1,尽可能的模拟多批次,在某个界面操作完后,再次点其中操作特定的界面,发现卡在那里,前端等待后端响应。(内心os:我可真是个写bug小能手)
排查
一般前端卡在那里,基本就是数据库连接池不用够用,java线程池被占满。
- 首先排查是否是数据库连接池的问题,连接数据库show processlist一下,你想看的不想看的都能看到,发现都是sleep,睡得可香呢,不打扰,不打扰。
- 接下来,矛头直指java线程池,为了方便排查,作为写外挂小能手的我,二话不说就写一个接口,用于查看线程池里的线程的情况,主要就是看activeCount和completedTaskCount。
复现
万事俱备,只等再来一次,解决问题的前提是复现问题,按照流程又来了一次,果不其然,又卡在那里了,看了一下我的接口,activeCount被占得死死的,小样你的锅跑不掉了。
分析
Executors 是一个Java中的工具类。提供工厂方法来创建不同类型的线程池。newFiexedThreadPool(int Threads):创建固定数目线程的线程池。
咋一看好像没毛病,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.
* If additional tasks are submitted when all threads are active,
* they will wait in the queue until a thread is available.
* If any thread terminates due to a failure during execution
* prior to shutdown, a new one will take its place if needed to
* execute subsequent tasks. The threads in the pool will exist
* until it is explicitly {@link ExecutorService#shutdown shutdown}.
*
* @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());
}
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。
这里的问题就出在:不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE。也就是说,如果我们不设置LinkedBlockingQueue的容量的话,其默认容量将会是Integer.MAX_VALUE。
而newFixedThreadPool中创建LinkedBlockingQueue时,并未指定容量。此时,LinkedBlockingQueue就是一个无边界队列,对于一个无边界队列来说,是可以不断的向队列中加入任务的,这种情况下就有可能因为任务过多而导致内存溢出问题。
以上的是大坑,以下的是小坑
- 任务提交后长时间没有执行
任务进入了队列,线程还在执行之前的任务。本质原因是对线程和队列的优先级认识不深刻,有一种错觉以为是所有线程都忙的时候才进入任务队列。实际上相反,是队列满的时候才会新建线程(线程数大于core size时)。 - 线程池中线程执行任务中无故消失(从日志可以看出,任务并未完成,也没有抛出异常)
一般情况下,代码中只会去捕捉RuntimeException,如果抛出Error则会导致线程退出,而异常信息又没有拿到。最佳的解决办法是给线程池设置UncaughtExceptionHandler
解决
首先让我看其他三种创建线程池的方式:newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool,要不就是无边界队列导致OOM,要不就是创建的最大线程数导致OOM,都有坑,只能自定义创建线程池了。写个方法方便使用。
/**
* 不使用系统自带的四个Executors,有坑,轻则线程中任务没有执行,重则OOM导致系统崩掉
* 使用有边界队列,超出队列的情况返回给调用者执行
*
* @param nThreads
* @return
*/
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导致系统崩掉,使用自定义创建线程池,参数根据实际的场景设置。
参考资料
Java线程池使用的注意事项
深入源码分析Java线程池的实现原理
一次Java线程池误用引发的血案和总结
Java中线程池,你真的会用吗?