[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?

文章目录

  • 前言
  • ThreadPoolExecutor类是什么?
  • ThreadPoolExecutor的三大核心属性
    • 1. 核心线程数(corePoolSize)属性
    • 2. 任务队列(workQueue)属性
    • 3. 最大线程数(maximumPoolSize)属性
    • 总结:ThreadPoolExecutor执行任务的流程
  • 任务拒绝策略
    • 自定义任务拒绝策略
  • 附录
    • 样例代码1:allowCoreThreadTimeOut导致应用退出
    • 样例代码2:扩展非核心线程逻辑导致的插队问题
  • 结语

前言


ThreadPoolExecutor类是JDK提供给开发者的一个比较常用的多线程任务执行器。也因为比较常用,所以笔者将用本文一文去结合源码梳理掌握ThreadPoolExecutor类的几个核心知识点,相信看过本文之后,你将会更好地掌握ThreadPoolExecutor类。

ThreadPoolExecutor类是什么?


首先要明确地是ThreadPoolExecutor不是线程池(ThreadPool),而是一个基于线程池的任务执行器服务(Executor Service)主要作用是帮助开发者去利用多个线程去异步地并行执行多个计算任务

虽然ThreadPoolExecutor在其名称中省略了Service,但ThreadPoolExecutor是AbstractExecutorService的子类,是个货真价实的ExecutorService。

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第1张图片

服务(Service)有诸多定义,要精准描述也比较难。这里的ExecutorService不同于我们Spring里面的Service层,而是更类似于我们操作系统或者说企业级系统层面的服务(进程),这类长时间运行地服务的特点就是有启动(start up)关闭(shut down) 的概念。而ThreadPoolExecutor也是如此,会在我们应用(进程)内部开启一个常驻服务用于接收计算任务(compute task)去执行(execute),当开发者想要退出应用时,要记得主动关闭已开启的ThreadPoolExecutor,否则你的应用不会主动退出,这是初学者使用ThreadPoolExecutor时比较容易遇到的一个问题。

你可以在下面的源码摘要里看到,ExecutorService接口所定义的shutdown相关的接口。

public class ThreadPoolExecutor extends AbstractExecutorService { /* 略 */ }
public abstract class AbstractExecutorService implements ExecutorService { /* 略 */ }
public interface ExecutorService extends Executor { 
	void shutdown();
	List<Runnable> shutdownNow();
	boolean isShutdown();
	boolean isTerminated();
	/* 略 */
}

ThreadPoolExecutor的三大核心属性


ThreadPoolExecutor作为一个工具类,其开发者为我们提供了诸多控制其行为的属性,本章会讲解其中最重要的三个核心属性,你通常会在构造器看到这些属性。

  1. 核心线程数(corePoolSize)
  2. 任务队列(workQueue)
  3. 最大线程数(maximumPoolSize)

1. 核心线程数(corePoolSize)属性

核心线程数(corePoolSize)这个属性是用于指示ThreadPoolExecutor设置常驻(核心)工作线程的个数。意味着任务数量在比较平稳情况下,最多有corePoolSize个线程用于执行任务。

在ThreadPoolExecutor创建之初,不会立即创建corePoolSize个线程当做核心工作线程。会在前corePoolSize个任务被要求执行时,一个一个被创建,直到创建满corePoolSize个核心工作线程。源码摘要如下:

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第2张图片

默认设置下,核心工作线程一旦被创建,即使没有新任务执行也会一直存在。不过如果你通过allowCoreThreadTimeOut(boolean)方法,设置允许核心线程的超时回收特性的话,核心工作线程的数量在没有任务执行时会被逐步回收,如果没有其他运行中的线程保护,核心线程归零时会导致应用直接退出

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第3张图片
如果想尝试导致应用退出的,你可以在文末《样例代码1:allowCoreThreadTimeOut导致应用退出》章节看到相关样例。

2. 任务队列(workQueue)属性

任务队列(workQueue)属性,不难看出其是用于存放待处理(积压中)的任务的一个属性。也就是说假设我们有5个核心工作线程,那么同一时间只能并行处理5个任务,新到来的任务就需要被存储在某个地方管理起来,这个地方就是我们的任务队列(workQueue)属性了。

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第4张图片
根据ThreadPoolExecutor的定义呢,workQueue是一个BlockingQueue(阻塞队列),选阻塞队列呢,也是因为当各个工作线程去workQueue获取不到接下来要执行的任务时,能够方便地阻塞工作线程直到获取到新任务。

任务队列通常是有界的,这意味着 能够积压在任务队列里的任务数量是有上限的当积压任务达到上限,这意味着任务过于繁重,当前工作线程数量不足以支持消化如此数量的任务,因此需要寻求额外的线程资源去做计算。

此时ThreadPoolExecutor就会尝试去增加新的非核心工作线程(worker thread),此种情况下工作线程数就会突破核心线程数。与核心线程的增加一样,每一次积压任务达到上限值仅会触发一次新增工作线程的处理。源码摘要和解释如下:

  1. 任务积压达到上限 workQueue.offer(command)返回 false。
  2. 触发一次尝试增加工作线程的处理 addWork(command, false),如果失败返回false则会触发任务拒绝reject(command),任务拒绝这个我们放到后面讲。

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第5张图片

3. 最大线程数(maximumPoolSize)属性

线程是计算机中珍贵的计算资源,不能无限制申请。ThreadPoolExecutor的开发者也为我们提供了相关的配置属性,那就是本章节要讲的最大线程数(maximumPoolSize)属性

这个属性用于指示ThreadPoolExecutor至多创建多少个线程用于执行任务。前面一章我们将任务队列属性时提到了任务过渡积压导致爆仓时,会触发 尝试新增工作线程的处理逻辑(上图的addWorker(command, false)),这个尝试会在工作线程数量达到上限(maximumPoolSize)时判定失败而返回false,即无法再新增非核心工作线程。源码摘要如下:

addWorker 方法摘要

总结:ThreadPoolExecutor执行任务的流程

  1. 多个线程可以向同一个ThreadPoolExecutor并发地提交新任务。
  2. 核心工作线程数小于corePoolSize时,ThreadPoolExecutor会新增一个核心工作线程直接执行被提交的任务,并退出。否则转到3。
  3. 任务队列workQueue还有剩余空间时,把被提交的任务压入任务队列,等待未来某一时刻被工作线程取走处理。并退出,否则转到4。
  4. 工作线程总数小于最大线程数(maximumPoolSize)时,新增一个非核心工作线程直接执行被提交的任务。并退出,否则转到5。
  5. 由于计算任务过于繁重,ThreadPoolExecutor会拒绝执行任务。被拒绝的任务何去何从(如何处理)是可以配置的。这个在后面章节任务拒绝策略会讲。

需要补充一点的是,相信看得仔细的读者会发现在 4的处理时,会直接新增一个非核心工作线程去执被提交的任务,这里会有个任务插队的问题,因为你的workQueue里面还是满任务的情况下就先处理了后提交的任务。你可以在文末《样例代码2:扩展非核心线程逻辑导致的插队问题》看到相关测试代码。

任务拒绝策略


对于ThreadPoolExecutor来说,任务过于繁重以至于无法处理时,会因任务无法及时被消化而积压起来,导致爆仓(工作线程全开的情况下workQueue爆满)。这个时候如何处理就成了问题。不过问题不大,ThreadPoolExecutor为我们提供了下图四种预制策略(RejectedExecutionHandler接口四种实现)去应对。

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第6张图片

主要内容参考下表:

拒绝策略 说明
AbortPolicy 通过抛出RejectedExecutionException这个RuntimeException子类的方式来告知调用者出错。
CallerRunsPolicy 这个任务拒绝策略会当前调用者的线程上直接执行计算任务,直接 征用当前提交异步任务的生产者线程来充当worker线程,这会降低任务的生产效率缓解ThreadPoolExecutor所有worker线程的压力。
DiscardOldestPolicy 这个策略会把workQueue中第一个任务给废弃掉,会一直重新尝试提交该任务提交直至成功。会保证最新的任务会进到任务队列,但不保证会被执行。
DiscardPolicy 朴实无华的拒绝策略,啥也不干,丢弃这个任务,直接开摆,实现代码部分都是0行。

自定义任务拒绝策略

主章节中我们提到的四种拒绝策略都是 预制(Predefined) 的,源码中也很清晰地能看到Predefined RejectedExecutionHandlers这个注释:

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第7张图片

那么可以不可以自己实现一个RejectedExecutionHandler呢?答案自然是可以的。比如我们可以自定义一个带重试的任务拒绝策略。

public static class RetryThenDiscardPolicy implements RejectedExecutionHandler {
    
    final int retryCount;
    /** 
     * RejectedExecutionHandler代码的执行是在任务生产者线程。
     * 任务生产者线程可能是多线程的,所以需要用到线程安全的ConcurrentHashMap。
     */
    final ConcurrentHashMap<Runnable, Integer> map;
    
    public RetryThenDiscardPolicy(int retryCount) { 
        if (retryCount <= 1) throw new IllegalArgumentException("无意义的retryCount");
        this.retryCount = retryCount;
        map = new ConcurrentHashMap<>();
    }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        
        if (!map.containsKey(r)) {
            final Runnable wrapperR = RunnableWrapper.wrapRunnable(r, this); 
            map.put(wrapperR, retryCount);
            e.execute(wrapperR);
        } else if (map.get(r) > 0) {
            System.out.println("重试任务:" + r);
            map.computeIfPresent(r, (key, cnt) -> --cnt);         
            e.execute(r);
        } else {
            // 重试次数耗尽,丢弃。
            System.out.println("丢弃任务:" + r);
            map.remove(r);
            return;
        }
    }
}

public static class RunnableWrapper implements Runnable {

    final Runnable r;
    final RetryThenDiscardPolicy policy;

    RunnableWrapper(Runnable r, RetryThenDiscardPolicy policy) { 
        this.r = r; 
        this.policy = policy;
    }

    @Override
    public void run() {
        r.run();
        policy.map.remove(this);
    }
    
    public static RunnableWrapper wrapRunnable(Runnable r, RetryThenDiscardPolicy policy) {
        Objects.nonNull(r);
        Objects.nonNull(policy);
        final RunnableWrapper wrapper = new RunnableWrapper(r, policy);
        return wrapper;
    }
}

简单执行一下可以看到命令行的输出。

[Java] 如何理解和设置ThreadPoolExecutor三大核心属性?什么情况下工作线程数会突破核心线程数?任务拒绝策略都有哪些?_第8张图片

附录


样例代码1:allowCoreThreadTimeOut导致应用退出

/**
 * 

Case - 核心线程归零时会导致应用直接退出

*
 *  不过如果你通过allowCoreThreadTimeOut(boolean)方法,设置允许超时回收的话,
 *  核心工作线程的数量在没有任务执行时逐步被回收,如果没有其他运行中的线程保护,核心线程归零时会导致应用直接退出。
 * 
*/
private static void testSetAllowCoreThreadTimeOut2TrueCauseAppExit() { final ThreadPoolExecutor tpExecutor = new ThreadPoolExecutor(5, 10, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10_0000)); tpExecutor.allowCoreThreadTimeOut(true); tpExecutor.execute(() -> { System.out.println(Thread.currentThread().getName()); }); }

样例代码2:扩展非核心线程逻辑导致的插队问题

/**
 * 

Case - 扩展非核心线程逻辑导致的插队问题

*
 *  在workQueue满任务时,新提交的任务会直接交予新增非核心线程执行,导致插队。
 * 
*/
private static void testCutInLine() { final ThreadPoolExecutor tpExecutor = new ThreadPoolExecutor( 5, 10, 10, TimeUnit.SECONDS, new ArrayBlockingQueue<>(5)); for (int i = 0; i < (5 + 5 + 1); i++) { final int idx = i; tpExecutor.submit(() -> { System.out.println("第" + idx + "个任务执行开始"); try { Thread.sleep(1_000L); } catch (InterruptedException e) { } System.out.println("第" + idx + "个任务执行结束。"); }); } }

结语


ThreadPoolExecutor是多线程编程中比较常用的一种工具类,熟练掌握其基本工作原理是非常重要的。希望通过本文你能了解到ThreadPoolExecutor是如何去把一个任务添加到其内部管理起来、同时在任务积压时又有哪几种基本的任务拒绝策略。

我是虎猫,希望本文对你有帮助。(=・ω・=)

你可能感兴趣的:(#,Java,java,开发语言,后端)