使用错误的线程池就是给应用埋了一颗炸弹

如发现错误,请留言或者发送邮件到[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)     //拒绝策略

这里的每个参数都会影响线程池行为,本文重点讨论:corePoolSizemaximumPoolSizeworkQueue

整个线程池的工作原理可以总结为如下步骤(这里假设应用提交任务的速度远大于线程池执行任务的速度,方便理解):

  1. 创建线程池,活跃线程数为零,workQueue中的任务数为零(有时候为了避免提交任务才创建线程产生的偶尔超时情况,会调用线程池的prestartCoreThread()方法提前创建corePoolSize个线程。);
  2. 应用给线程池提交任务,此时活跃线程数小于corePoolSize,线程池会创建新的线程来执行提交的任务(这里假设ThreadFactory的行为是创建新的线程);
  3. 应用继续给线程池提交任务,这时活跃线程数大于corePoolSize但是小于maximumPoolSize。线程池会把任务放入workQueue中;
  4. 应用继续给线程池提交任务,会导致workQueue满了(无界队列不会满),线程池会创建新的线程来执行提交的任务(准确的过程是:任务会被添加到workQueue,然后新创建的线程会从workQueue中获取任务来执行);
  5. 应用继续给线程池提交任务,这时活跃线程数大于等于maximumPoolSize。线程池将不会再创建新的线程,对新提交的任务执行相应的拒绝策略。

为了方便大家理解,我虚构一个生活中的场景来模拟线程池的工作:

  1. 糖糖和果果两人都会理发,合伙开了一家理发店。生意兴隆,她俩雇了4个都叫Tony的员工,她俩当收银员;
  2. 早上10点开门营业,还没有顾客上门,所以四个Tony老师很空闲,就开始联机打王者荣耀;
  3. 早上10点半,来了第一个客户,分配给了Tony老师1号,他开始理发;
  4. 后面陆陆续续又来了三位顾客,这样四个Tony老师都忙了起来;
  5. 这时又来了一个顾客,糖糖和果果让其在门口沙发下坐一坐等待一会;
  6. 顾客越来越多,沙发上等待人数也越来越多。Tony老师们做完手上的顾客,就会让排队等待的第一个人过来理发;
  7. 但是生意实在太好了,沙发已经满了,为了不让顾客站着等,糖糖和果果也开始给顾客理发;
  8. 又有新顾客来了,沙发也坐满了,糖糖和果果也在给顾客理发,为了客户体验,她俩给这个新顾客一张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()生成的线程池。它的corePoolSizemaximumPoolSize相同,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不大(最好是内部系统),并且做好线程数和工作队列的监控(超过阈值报警),及时发现问题。

你可能感兴趣的:(使用错误的线程池就是给应用埋了一颗炸弹)