如发现错误,请留言或者发送邮件到[email protected]。原创作品,未经授权,请勿转载。
Java程序员使用最多的并发工具就是线程池。在业务开发过程中,我们会遇到某些任务执行的太慢了,需要多线程加速执行。程序员一般会第一时间想到使用线程池。
使用哪个线程池呢?
在JDK中,最通用的线程池是:ThreadPoolExecutor
。但是因为配置配置参数较多,使用会麻烦一些。所以很多人会使用Executors
这个工具类的两个方法来创建线程池:newFixedThreadPool(int nThreads)
(后面会省略nThreads这个参数)和newCachedThreadPool()
。大家可以在自己开发的应用中查找一下,我相信使用Executors
会比使用ThreadPoolExecutor
多很多。
在我掉进坑里之前,其实我也是一直使用Executors
来快速创建线程池,毕竟时间就是金钱(其实真相是:程序员都很懒)。
线程池工作原理
在探寻Executors.newFixedThreadPool()
和Executors.newCachedThreadPool()
创建的线程池会有什么坑之前,我们还是需要先了解一下线程池的工作原理。
JDK的ThreadPoolExecutor
是一个功能完备的线程池实现,其构造器声明如下:
public ThreadPoolExecutor(int corePoolSize, //核心线程数大小
int maximumPoolSize, //最大线程数
long keepAliveTime, //线程空闲时存活的时间
TimeUnit unit, //keepAliveTime参数的单位
BlockingQueue workQueue, //工作队列
ThreadFactory threadFactory, //创建线程的工厂类
RejectedExecutionHandler handler) //拒绝策略
这里的每个参数都会影响线程池行为,本文重点讨论:corePoolSize
、maximumPoolSize
和workQueue
。
整个线程池的工作原理可以总结为如下步骤(这里假设应用提交任务的速度远大于线程池执行任务的速度,方便理解):
- 创建线程池,活跃线程数为零,
workQueue
中的任务数为零(有时候为了避免提交任务才创建线程产生的偶尔超时情况,会调用线程池的prestartCoreThread()
方法提前创建corePoolSize
个线程。); - 应用给线程池提交任务,此时活跃线程数小于
corePoolSize
,线程池会创建新的线程来执行提交的任务(这里假设ThreadFactory的行为是创建新的线程); - 应用继续给线程池提交任务,这时活跃线程数大于
corePoolSize
但是小于maximumPoolSize
。线程池会把任务放入workQueue
中; - 应用继续给线程池提交任务,会导致
workQueue
满了(无界队列不会满),线程池会创建新的线程来执行提交的任务(准确的过程是:任务会被添加到workQueue
,然后新创建的线程会从workQueue
中获取任务来执行); - 应用继续给线程池提交任务,这时活跃线程数大于等于
maximumPoolSize
。线程池将不会再创建新的线程,对新提交的任务执行相应的拒绝策略。
为了方便大家理解,我虚构一个生活中的场景来模拟线程池的工作:
- 糖糖和果果两人都会理发,合伙开了一家理发店。生意兴隆,她俩雇了4个都叫Tony的员工,她俩当收银员;
- 早上10点开门营业,还没有顾客上门,所以四个Tony老师很空闲,就开始联机打王者荣耀;
- 早上10点半,来了第一个客户,分配给了Tony老师1号,他开始理发;
- 后面陆陆续续又来了三位顾客,这样四个Tony老师都忙了起来;
- 这时又来了一个顾客,糖糖和果果让其在门口沙发下坐一坐等待一会;
- 顾客越来越多,沙发上等待人数也越来越多。Tony老师们做完手上的顾客,就会让排队等待的第一个人过来理发;
- 但是生意实在太好了,沙发已经满了,为了不让顾客站着等,糖糖和果果也开始给顾客理发;
- 又有新顾客来了,沙发也坐满了,糖糖和果果也在给顾客理发,为了客户体验,她俩给这个新顾客一张9折券,让他下次再来。
上面这个虚构的例子可以大体上模拟线程池的工作过程,会有一些偏差,请谅解。其中四个Tony是corePoolSize
对应的线程,糖糖和果果是多于corePoolSize
但小于maximumPoolSize
时产生的临时线程,顾客等待时坐得沙发是workQueue
。
坑在哪里?
先看一下Executors.newFixedThreadPool()
和Executors.newCachedThreadPool()
内部逻辑到底是什么:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue());
}
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue());
}
从代码中我们很容易看出来:newFixedThreadPool()
和newCacheThreadPool()
都是直接调用ThreadPoolExecutor
的构造器来创建线程池。但是参数不相同,也就导致了它们各自不同的特性。
首先看一下调用newFixedThreadPool()
生成的线程池。它的corePoolSize
和maximumPoolSize
相同,workQueue
是一个无界队列。这会导致线程池原理说的第四、五步永远不会发生。在系统压力不大时,线程池执行任务的速度超出应用向其提交任务的速度,一切都很完美。但是当系统压力上升时,提交给线程池的任务数量会急剧增加,由于雪崩效应可能还会导致每个任务执行时间变长,最终导致workQueue
长度不断增加。最终内存耗尽,老朋友OOM又来了。同时线程池的拒绝策略永远也不会执行,fail fast失效。
再来看newCacheThreadPool()
创建的线程池。它的corePoolSize
为零,maximumPoolSize
为整数最大值(21亿多一点),workQueue
是我们日常很少用的SynchronousQueue
队列。因为corePoolSize
为零,所以其不会走到第二步。提交的每个任务都会直接执行第三步,放入workQueue
中。但是这里的workQueue
类型是SynchronousQueue
,它其实并不算是一个真正的队列,因为其容量是零,对其中添加元素会立刻失败。由于线程池中的活跃线程数一直小于maximumPoolSize
,所以线程池会创建新的线程。只要任务提交足够多且提交速度大于执行速度,那线程池会无限(最多21亿个)增加线程数。线程虽然比进程轻量,但是JVM会给每个线程分配一块内存,64位系统中默认是1MB(可以通过-Xss设置)。1024个线程就会占用1GB的内存,线上服务器大部分是8GB的,留给JVM使用的差不多4GB,所以只要4000个线程(只要4000个慢任务就可以做到)就会OOM。线上环境中我遇到过一次,最后导致线上机器SSH都连接不上,最后只能找PE重启。
总结
不要在线上环境使用:Executors.newFixedThreadPool()
和Executors.newCachedThreadPool()
,直接使用ThreadPoolExecutor类,并使用大小合适的有界队列和设置适当的线程数(具体的线程数根据你的QPS和任务类型(计算密集型还是io密集型)来确定)。不然OOM总会在不远处等待你,不离不弃。如果你真的一定要用,请首先确保系统的QPS不大(最好是内部系统),并且做好线程数和工作队列的监控(超过阈值报警),及时发现问题。