JAVA线程池详解(ThreadPoolExecutor)

前言

随着计算机行业的飞速发展,摩尔定律逐渐失效,多核CPU成为主流。使用多线程并行计算逐渐成为开发人员提升服务器性能的基本武器。如果你要成为一个好的工程师,还是得比较好地掌握这个知识,很多线上问题都是因为没有用好线程池导致的。即使你为了谋生,也要知道,这基本上是面试必问的题目,而且面试官很容易从被面试者的回答中捕捉到被面试者的技术水平。

一、 线程池简介

1.1 线程池是什么

线程池(Thread Pool)是一种基于池化思想管理线程的工具,经常出现在多线程服务器中,如MySQL。

线程过多会带来额外的开销,其中包括创建销毁线程的开销、调度线程的开销等等,同时也降低了计算机的整体性能。线程池维护多个线程,等待监督管理者分配可并发执行的任务。这种做法,一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀导致的过分调度问题,保证了对内核的充分利用。


“池化”(Pooling)思想:池化,顾名思义,是为了最大化收益并最小化风险,而将资源统一在一起管理的一种思想。

Pooling is the grouping together of resources (assets, equipment, personnel, effort, etc.) for the purposes of maximizing advantage or minimizing risk to the users. The term is used in finance, computing and equipment management.——wikipedia

“池化”思想不仅仅能应用在计算机领域,在金融、设备、人员管理、工作管理等领域也有相关的应用。

在计算机领域中的表现为:统一管理IT资源,包括服务器、存储、和网络资源等等。通过共享资源,使用户在低投入中获益。除去线程池,还有其他比较典型的几种使用策略包括:

  • 内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
  • 连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
  • 实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。

1.2 线程池能够带来的几个好处

  1. 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  3. 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

1.3 线程池基础使用场景

快速响应用户请求,响应速度优先。比如一个用户请求,需要通过 RPC 调用好几个服务去获取数据然后聚合返回,此场景就可以用线程池并行调用,响应时间取决于响应最慢的那个 RPC 接口的耗时;又或者一个注册请求,注册完之后要发送短信、邮件通知,为了快速返回给用户,可以将该通知操作丢到线程池里异步去执行,然后直接返回客户端成功,提高用户体验。

单位时间处理更多请求,吞吐量优先。比如接受 MQ 消息,然后去调用第三方接口查询数据,此场景并不追求快速响应,主要利用有限的资源在单位时间内尽可能多的处理任务,可以利用队列进行任务的缓冲。
JAVA线程池详解(ThreadPoolExecutor)_第1张图片

二、JAVA线程池设计与实现

了解完线程池的一些基本概念后,让我们来慢慢走近线程池相关的设计实现中。

2.1 总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。
JAVA线程池详解(ThreadPoolExecutor)_第2张图片
Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分

ExecutorService 接口继承 Executor,且扩展了生命周期管理的方法、返回 Futrue 的方法、批量提交任务的方法。

AbstractExecutorService 抽象类继承 ExecutorService 接口,对 ExecutorService 相关方法提供了默认实现,用 RunnableFuture 的实现类 FutureTask 包装 Runnable 任务,交给 execute() 方法执行,然后可以从该 FutureTask 阻塞获取执行结果,并且对批量任务的提交做了编排。

ThreadPoolExecutor 继承 AbstractExecutorService,会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务.

2.2 ThreadPoolExecutor内部运行流程JAVA线程池详解(ThreadPoolExecutor)_第3张图片

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。
任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:
(1)直接申请线程执行该任务
(2)缓冲到队列中等待线程执行
(3)拒绝该任务
线程管理部分消费者,它们被统一维护在线程池内,
(1)根据任务请求进行线程的分配线程
(2)当线程执行完任务后则会继续获取新的任务去执行
(3)当线程获取不到任务的时候,线程就会被回收

2.3 ThreadPoolExecutor入参详解

对线程池的配置,就是对ThreadPoolExecutor构造函数的参数的配置,该类共有7个可配置的参数信息,其中5个是我们需要重点了解的。

new ThreadPoolExecutor(corePoolSize, maximumPoolSize,keepAliveTime, milliseconds,runnableTaskQueue, threadFactory,handler);

2.3.1 corePoolSize(核心线程大小)

当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads方法,线程池会提前创建并启动所有基本线程。

2.3.2 runnableTaskQueue(任务队列)

用于保存等待执行的任务的阻塞队列。当所有的核心线程被占满时,新添加的任务会被添加到这个队列中等待处理,如果队列满了,则新建非核心线程执行任务
JAVA线程池详解(ThreadPoolExecutor)_第4张图片

2.3.3 maximumPoolSize(最大线程数)

线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是如果使用了无界的任务队列这个参数就没什么效果。
线程总数 = 核心线程数 + 非核心线程数

以上三个参数是线程池中最核心的信息,它们最大程度地决定了线程池的任务分配和线程分配策略

2.3.4 RejectedExecutionHandler(拒绝策略)

当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。以下是JDK提供的四种策略。
JAVA线程池详解(ThreadPoolExecutor)_第5张图片

当然也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化不能处理的任务。

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

2.3.5 keepAliveTime(线程活动保持时间)

线程池的工作线程空闲后,保持存活的时间。所以如果任务很多,并且每个任务执行的时间比较短,可以调大这个时间,提高线程的利用率。

以上五个参数就是我们最关心的参数信息,也是面试最容易问到的


2.3.6 ThreadFactory

用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字,Debug和定位问题时非常又帮助。

2.3.7 TimeUnit(线程活动保持时间的单位)

可选的单位有天(DAYS),小时(HOURS),分钟(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。
JAVA线程池详解(ThreadPoolExecutor)_第6张图片

2.4 线程池的生命周期管理

线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。
线程池内部使用一个变量 ctl 维护两个值:运行状态(runState)和线程数量 (workerCount) 其中高3位保存runState,低29位保存workerCount当前池线程数。如下代码所示:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ThreadPoolExecutor 类里的方法大量有同时需要获取或更新池状态和池当前线程数的场景,放一个原子变量里,可以很好的保证数据的一致性以及代码的简洁性。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。

  // 用此变量保存当前池状态(高3位)和当前线程数(低29位)
  private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); 
  // 结果:29,就用来表示分隔runState 和workerCount 的位数
  // 因为线程池的生命周期有 5 个状态,为了表达这 5 个状态,我们需要 3 个二进制位。
  private static final int COUNT_BITS = Integer.SIZE - 3;
  // 结果:000 1   1111 1111 1111 1111 1111 1111 1111
  private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

  // runState is stored in the high-order bits
  // 可以接受新任务提交,也会处理任务队列中的任务
  // 结果:111 00000000000000000000000000000
  private static final int RUNNING    = -1 << COUNT_BITS;
  
  // 不接受新任务提交,但会处理任务队列中的任务
  // 结果:000 00000000000000000000000000000
  private static final int SHUTDOWN   =  0 << COUNT_BITS;
  
  // 不接受新任务,不执行队列中的任务,且会中断正在执行的任务
  // 结果:001 00000000000000000000000000000
  private static final int STOP       =  1 << COUNT_BITS;
  
  // 任务队列为空,workerCount = 0,线程池的状态在转换为TIDYING状态时,会执行钩子方法terminated()
  // 结果:010 00000000000000000000000000000
  private static final int TIDYING    =  2 << COUNT_BITS;
  
  // 调用terminated()钩子方法后进入TERMINATED状态
  // 结果:011 00000000000000000000000000000
  private static final int TERMINATED =  3 << COUNT_BITS;
  //拆包函数
  // Packing and unpacking ctl 
  // 低29位变为0,得到了线程池的状态
  private static int runStateOf(int c)     { return c & ~CAPACITY; }
  // 高3位变为为0,得到了线程池中的线程数
  private static int workerCountOf(int c)  { return c & CAPACITY; }
  //打包函数
  private static int ctlOf(int rs, int wc) { return rs | wc; }

更详细说明请看:JAVA线程池 -clt设计与分析

2.5 Java线程池主要工作流程

我们已经介绍了线程池中常见的配置参数,其实线程池中所有任务的调度都是由execute()方法完成的,了解这部分就相当于了解了线程池的核心运行机制

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
        /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         */
    int c = ctl.get();
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}
  1. 首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
  2. 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
  3. 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
  4. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
  5. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。
    JAVA线程池详解(ThreadPoolExecutor)_第7张图片
    JAVA线程池详解(ThreadPoolExecutor)_第8张图片

三、配置线程池

3.1 如何合理的配置线程池

虽然我们知道了一些基础的概念信息,但是我们要如何合理的配置线程池参数呢?
这个问题本身没有固定答案,我们需要针对具体情况而具体处理,不同的任务类别应采用不同规模的线程池,设置完成后需要通过压测不断的动态调整线程池参数,观察 CPU 利用率、系统负载、GC、内存、RT、吞吐量等各种综合指标数据,来找到一个相对比较合理的值

线程池使用面临的核心的问题在于:线程池的参数并不好配置。一方面线程池的运行机制不是很好理解,配置合理需要强依赖开发人员的个人经验和知识;另一方面,线程池执行的情况和任务类型相关性较大,IO密集型和CPU密集型的任务运行起来的情况差异非常大,这导致业界并没有一些成熟的经验策略帮助开发人员参考。 —《Java线程池实现原理及其在美团业务中的实践》

以下是由我个人经验,简单对线程池相关的配置做一个划分,如果有不正确或不合适,欢迎讨论。

任务的性质: 出自《Java 并发编程实践》

  • CPU密集型任务:线程池中线程个数应尽量少,如Ncpu+1
  • IO密集型任务:由于IO操作速度远低于CPU速度,那么在运行这类任务时,CPU绝大多数时间处于空闲状态,那么线程池可以配置尽量多些的线程,以提高CPU利用率,如2*Ncpu
  • 混合型任务:可以拆分为CPU密集型任务和IO密集型任务,当这两类任务执行时间相差无几时,通过拆分再执行的吞吐率高于串行执行的吞吐率,但若这两类任务执行时间有数据级的差距,那么没有拆分的意义。

是否需要快速响应

  • 需要快速响应: 如商品详情页面,需要查询到商品相关的价格、图片、库存等信息。若需要快速响应,最重要的就是获取最大的响应速度去满足用户,所以应该不设置队列去缓冲并发任务,调高corePoolSize和maxPoolSize去尽可能创造多的线程快速执行任务。

  • 不需要:如生成报表信息,计算各个维度的价格商品,像不同商家推送不同的报表。对于这种不需要快速响应的场景,我们应该如何使用有限的资源,尽可能在单位时间内处理更多的任务,就是提高程序的吞吐量。应该设置队列去缓冲并发任务,调整合适的corePoolSize去设置处理任务的线程数。

3.2 线程池监控

因为线程池的参数的较难配置,依赖开发人员的个人经验和知识,我们对于线程池缺乏状态的观测,就对其改进无从下手,所以我们在线程池执行任务的生命周期添加监控能力,帮助开发同学了解线程池状态。

我们自己对线程池 ThreadPoolExecutor 做了一些增强,继承线程池并重写线程池的beforeExecute()afterExecute()terminated()方法,可以在任务执行前、后和线程池关闭前自定义行为。如监控任务的平均执行时间,最大执行时间和最小执行时间等。
JAVA线程池详解(ThreadPoolExecutor)_第9张图片

这边我推荐较为热门的开源项目:dynamic-tp,它已经完成了对线程池相关的兼容,我们就不用进行重复造轮子了。
JAVA线程池详解(ThreadPoolExecutor)_第10张图片

参考文章

Java线程池实现原理及其在美团业务中的实践
深度解读 java 线程池设计思想及源码实现
以面试官视角万字解读线程池10大经典面试题!

后记

因为该文章有较多的文字和源码,看起来不免枯燥乏味,所以加入了些许表情包作为调剂。

你可能感兴趣的:(SpirngBoot,java,面试,线程池,线程池详解,ThreadPool)