聊聊线程池那些事

2,410 阅读9分钟

前言

平时开发过程中,我们会经常和线程池打交道,有时还会根据不同的业务进行线程池隔离,那么了解线程池的工作原理和参数设置就是非常必要的,所以今天的主题就是探究线程池的那些事儿。

为什么使用线程池

在使用一项技术之前,了解 「why」 是至关重要的,即我们为什么要使用线程池?线程池有什么好处?

线程池是一种池化技术,使用线程池可以减少线程创建时的资源消耗,同时也可以提高响应速度,即当有任务到达时,如果线程池中有空闲可用的线程,那么直接拿线程去执行任务,节省了重新创建线程的时间开销。

线程池 API

这部分阐述如何使用线程池,也就是介绍 Executor,ExecutorService,ThreadPoolExecutor 和 Executors 之间的区别和联系,它们都是 Executor 框架的核心组成部分。

Executor

Executor 是 抽象层面的核心接口:

public interface Executor {
    void execute(Runnable command);
}
  • 定义单一的 execute 方法,用于提交 Runnable 任务,即将任务和执行分离开来

ExecutorService

ExecutorService 接口对 Executor 进行扩展,提供了异步执行和关闭线程池等方法,下面是部分代码:

public interface ExecutorService extends Executor {
    void shutdown();
    boolean isTerminated();
    boolean awaitTermination(long timeout, TimeUnit unit);
    <T> Future<T> submit(Callable<T> task);
}
  • 使用 submit 方法,不需要等待任务完成,它会直接返回 Future 对象,调用 Future 的 get() 方法可以查询任务执行结果,如果任务还没有完成,调用 get() 方法会被阻塞

ThreadPoolExecutor

ThreadPoolExecutor 是 ExecutorService 接口的实现类,即线程池的实现类,具体继承结构如下:

ThreadPoolExecutor

ThreadPoolExecutor 有几个核心参数,需要在手动创建时进行设置:

  • corePoolSize : 核心线程数

  • maximumPoolSize: 线程池中允许的最大线程数

  • keepAliveTime: 非核心线程存活时间。即当线程数大于corePoolSize,多余的空闲线程在终止之前,等待新任务的最长存活时间。

  • unit: keepAliveTime 的单位

  • workQueue: 用来暂时保存任务的阻塞队列

  • handler: 线程的拒绝策略。当线程池已经饱和,即达到了最大线程池大小,且阻塞队列也已经满了,线程池选择一种拒绝策略来处理新来的任务。ThreadPoolExecutor 内部已经提供了以下 4 种策略:

    • CallerRunsPolicy : 提交任务的线程自己去执行任务
    • AbortPolicy: 默认的拒绝策略,抛出 RejectedExecutionException 异常
    • DiscardPolicy: 直接丢弃任务,没有任何异常抛出
    • DiscardOldestPolicy:丢弃最老的任务,其实就是把最早进入工作队列的任务丢弃,然后把新任务加入到工作队列。
  • threadFactory: 创建线程的工厂,可以为线程池中的线程设置名字,便于以后定位问题。常见的设置线程名称的做法有:

    • 自定义实现 ThreadFactory 设置线程名称
    • 使用 Google Guava 的 ThreadFactoryBuilder 设置线程名称
    • 使用 Executors 工具类提供的默认线程池工厂 DefaultThreadFactory

Executors

Executors 实际上是一个工具类,提供一系列工厂方法创建不同类型的线程池,部分代码如下:

public class Executors {
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
    }
    
    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
    }
    
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
    }
}

可以看出,使用 Executors 工具类创建线程池是非常简便的,比如它创建了4种类型的 ThreadPoolExecutor :

  • FixThreadPool: 固定线程数大小的线程池
  • SingleThreadExecutor: 创建一个单线程的线程池,用单线程来执行任务
  • CachedThreadPool: 可缓存的线程池,corePoolSize 为0,maximumPoolSize 是Integer.MAX_VALUE,对线程个数不做限制,可无限创建临时线程。
  • ScheduledThreadPoolExecutor: 定长的线程池,支持定时及周期性的任务

但是在 《阿里巴巴Java开发手册》中指出不要使用 Executors工具类来创建线程池,原因如下:

Executors_Java

所以,生产环境下还是通过手动创建 ThreadPoolExecutor ,结合实际场景来设置线程池的核心参数,同时为线程池的线程设置有意义的名称,便于定位问题。而 Executors 工具类可以用于平时写一些简单的 Demo 代码,这还是很方便的。

线程池的工作原理

在熟悉了线程池的相关 API 以后,我们来看一下线程池的核心工作原理。

往简单的讲,就是:

  • 如果当前线程数 < corePoolSize 时,直接创建线程执行任务。
  • 后面再来任务,就把任务放到阻塞队列中,如果阻塞队列满了就创建临时线程。
  • 如果总线程数达到 maximumPoolSize,就执行拒绝策略。

往复杂的讲,那就得从 ThreadPoolExecutor 的 execute 方法入手,源码如下:

public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    // 1. 首先获取线程池的状态控制变量
    int c = ctl.get(); 
    //2. workerCountOf(c) 获取当前工作的线程数,如果工作线程数小于核心线程数
    if (workerCountOf(c) < corePoolSize) {
        //3. 创建核心线程
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    //4. 检查线程池状态是否正在运行,并尝试将任务放入阻塞队列中
    if (isRunning(c) && workQueue.offer(command)) {
        
        int recheck = ctl.get();
        //5. recheck再次检查线程池的状态,主要是为了判断加入到阻塞队列中的线程是否可以被执行
        if (! isRunning(recheck) && remove(command))
            reject(command);
        //6. 验证当前线程池中的工作线程的个数,如果为0,创建一个空任务的线程来执行刚才添加的任务
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    //7. 如果加入阻塞队列失败,说明队列已经满了,则创建新线程,如果创建失败,说明已达到最大线程数,则执行拒绝策略
    else if (!addWorker(command, false))
        reject(command);
}

根据 execute 的源码,向线程池提交任务的主要步骤如下:

  • 首先获取线程池的状态控制变量 ctl,说明一下:线程池的 ctl 是一个原子的 AtomicInteger,包含两部分
    • workCount 表示工作的线程数,由 ctl 的低29位保存
    • runState 表示当前线程池的状态,由 ctl 的高3位保存
  • 然后利用 workerCountOf(c) 获取当前工作的线程数,如果当前工作线程数小于 corePoolSize,则创建线程。
  • 如果当前工作线程数大于等于 corePoolSize,则首先检查线程池状态是否正在运行。
  • 如果线程池处于 Running 状态,那么尝试将任务加入 BlockingQueue
    • 如果加入 BlockingzQueue 成功, 再次检查(recheck)线程池的状态,主要是为了判断加入到阻塞队列中的线程是否可以被执行
      • 如果线程池没有 Running,则移除之前添加的任务,然后拒绝该任务
      • 如果线程池处于 Running 状态,再次检查(recheck)当前线程池中的工作线程的个数,如果为0,则主动创建一个空任务的线程来执行任务
    • 如果加入 BlockingQueue失败,则说明队列已经满了,则创建新的线程来执行任务
  • 如果线程池处于非运行状态,尝试创建线程,如果创建失败,说明当前线程数已经达到最大线程数 maximunPoolSize,然后执行拒绝策略

上面就是 execute 方法的整个工作原理,同时我也画了一张不算标准的流程图来帮助理解,如图所示:

ThreadPoolExecutor_Executor_Working

线程池的监控

如果在系统中大量使用线程池,则有必要对线程池的运行状况进行监控,这样当发生问题时,才便于排查。

一般有以下几种方法来监控线程池:

  • 利用继承的思想。通过继承线程池来自定义线程池,重写线程池的 beforeExecute,afterExecute 和 terminated 方法收集数据,想要可视化就依靠 JMX 来做。
  • 利用 SheduledExecutorService 执行定时任务去监控线程池的运行状况
  • 利用 Metrics + JMX 的方式对线程池进行监控
  • 在 SpringBoot 项目中利用 actuator 组件来做线程池的监控
  • 在一些大型系统中,利用 Micrometer + Prometheus 等监控组件去监控线程池的各项指标

就如同探讨 「回」有几种写法一样,方法有很多。对于线程池监控,我们需要根据自己的实际场景,选择恰当的方法。下面打个样,写了一个利用 SheduledExecutorService 执行定时任务去监控线程池的例子 :

@Slf4j
public class MonitorThreadPoolStats {
    public static void main(String[] args) {
        ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
        printThreadPoolStats(threadPool);
        for (int i = 0; i <10 ; i++) {
            threadPool.execute(()->{
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
    }

    private static void printThreadPoolStats(ThreadPoolExecutor threadPool) {
        Executors.newSingleThreadScheduledExecutor()
                .scheduleAtFixedRate(
                        () -> log.info("Thread pool monitors metrics : poolSize:{}, activeThreads:{}, completedTasks:{}, QueueTasks: {}",
                        threadPool.getPoolSize(), threadPool.getActiveCount(), threadPool.getCompletedTaskCount(), threadPool.getQueue().size()),
      0, 1, TimeUnit.SECONDS);
    }
}

如何设置线程池的大小

我们在一开始使用线程池,就要面对如何设置线程池大小的问题。线程池不宜设置得过大或过小:

  • 如果设置过大,会导致大量线程在相对较少的CPU和内存资源上发生竞争。
  • 如果设置过小,会导致系统无法充分利用系统资源。

参考网上的资料以及 《Java并发编程实战》的说法,可根据任务类型来确定:

  • CPU 密集型任务: 这种任务主要消耗 CPU 资源,可分配少量的线程,比如一般设置为 ( CPU 核心数 + 1 )
  • IO 密集型任务:这种任务主要处理I/O交互,可配置些线程,比如 CPU 核心数 * 2

当然在实际场景中,我们需要先评估业务并发量、机器配置等因素,再去设置一个合理的大小。

小结

这篇文章总结了一些线程池的知识点,比如线程池的核心 API、详细的工作原理等,当然还有很多细节地方没有深入下去,待后续有机会继续分享。


pjmike

参考资料 & 鸣谢