线程池

63 阅读8分钟

今天来介绍一下java中的一个知识点——线程池

线程池(Thread Pool)它是一种并发编程中常用的技术,用于管理和重用线程。它由线程池管理器、工作队列和线程池线程组成。基本的概念是,在应用程序启动时创建一定数量的线程,并将它们保存在线程池中。当需要执行任务时,从线程池中获取一个空闲的线程,将任务分配给该线程执行。当任务执行完毕后,线程将返回到线程池,可以被其他任务复用,所以他的设计思想就是为了避免频繁地创建和销毁线程的开销,和控制并发执行的线程数量提高系统的性能以及资源的利用率。

首先先来介绍一下线程池的核心参数:包括最大线程数(maximumPoolSize),核心线程数(corePoolSize),空闲超时时间(KeepAliveTime),线程工厂(threadFactory),超时时间单位(unit),阻塞队列(workQueue)以及拒绝策略(RejectedExecutionHandler)。而举个现实生活的例子,来理解一下线程池的概念。

我们可以想象成现实中的银行,在大厅中一共有5个前台柜,而目前只有3个柜台存在员工,且大厅目前只可容纳除柜台前处理事务的客户外,再多两个等待处理的客户,假设目前来了3个客户,则可以随机选择一个柜台处理事务;而如果来了四个客户,则有一个客户会进入到大厅,等待某个柜台处理完当前客户的事务后再处理其事务;而如果此时来了6个客户时,由于并发量的提高,银行大厅的等待空间已满,银行会呼唤空着的两个柜台的其中一个柜台员工上班,则此时一共有4个柜台同时处理这些客户;而当客户数量到达8位时,由于最大柜台数为5,最多等待大厅为2人,则当第8位客户来的时候,银行会拒绝此客户;等到备用的柜台处理完客户且空闲时间到达5分钟时,既会让此柜台员工继续休息保留3个员工来处理事务。

在这段介绍中,3位员工既对应着我们的核心线程数,5个柜台对应着最大线程数,等待的大厅对应着阻塞队列,而空闲的5分钟为空闲超时时间,分钟对应超时时间单位,而对于线程工厂既为给每个柜台编号(如 “核心柜台 - 1”“备用柜台 - 2”),方便经理排查 “哪个柜台出了问题”,最后的拒绝策略5 个柜台全忙 + 等待区 2 个座位坐满,第 8 个客户来了,银行的处理方式。而线程池的运行逻辑为核心线程满 → 任务入队列 → 队列满 → 创建非核心线程 → 非核心线程到最大数 → 触发拒绝策略。

在实际开发中我们可以通过手动设置7个参数的值再实例化ThreadPoolExecutor来创建线程池,相比较于Executors提供的方法来说,手动创建会更灵活一点能手动设置参数值会更适配,所以优先考虑这种方式

1.12.png

Executors内置了 5 种常用线程池我来提供一下它们的创建方式以及优点和适用场景: 1固定线程池(FixedThreadPool):

固定.png 优点为线程数固定,避免频繁创建和销毁线程的开销且全部为核心线程,空闲时不会销毁,响应速度。快适用于任务量稳定、执行时间较长的场景(如后台定时任务、数据库批量处理)。 2可缓存线程池(CachedThreadPool)

可缓存.png 优点为有任务就创建线程,空闲 60 秒销毁线程,几乎不占用空闲资源任务,提交后立刻执行,响应速度极快。适用于任务量多但执行时间极短的场景(如短时间内的大量轻量 RPC 调用)

  1. 单线程线程池(SingleThreadExecutor)

单线程.png 优点为保证任务串行执行(按提交顺序),避免多线程并发问题,线程异常后会自动创建新线程替代,保证线程池始终可用。适用于任务需要严格按顺序执行的场景(如日志写入、订单状态更新)

  1. 定时线程池(ScheduledThreadPool)

定时.png 优点为专门处理定时 / 周期性任务,比Timer更安全(Timer 单线程,任务异常会导致整个定时器挂掉)支持多核心线程,任务执行异常不影响其他定时任务。适用于定时任务、周期性任务(如每分钟统计接口调用量、每天凌晨清理日志)

5单线程定时线程池(SingleThreadScheduledExecutor)

最后.png 优点为单线程串行执行定时任务,避免并发问题,比 Timer 更稳定,线程异常会自动重建。适用于简单的单线程定时任务(如单节点的心跳检测、简单的定时告警)

介绍完线程池的概念和创建以后来说说实际开发中应该注意什么。

<1>应该我们应当优先考虑手动new ThreadPoolExecutor来创建线程池,像刚刚提到的手动创建的灵活性来说,Executors 类提供的一些快捷声明线程池的方法虽然简单,但隐藏了线程池的参数细节。因此,使用线程池时,我们一定要根据场景和需求配置合理的线程数、任务队列、拒绝策略、线程回收策略,并对线程进行明确的命名方便排查问题;

<2>既然使用了线程池就需要确保线程池是在复用的,每次 new 一个线程池出来可能比不用线程池还糟糕。如果你没有直接声明线程池而是使用其他同学提供的类库来获得一个线程池,请务必查看源码,以确认线程池的实例化方式和配置是符合预期的;

<3>复用线程池不代表应用程序始终使用同一个线程池,我们应该根据任务的性质来选用不同的线程池。特别注意 IO 绑定的任务和 CPU 绑定的任务对于线程池属性的偏好,如果希望减少任务间的相互干扰,考虑按需使用隔离的线程池。

虚拟线程

在JDK19提出了一个虚拟线程的概念并在JDK21中进一步完善,它是一种轻量化线程,旨在解决传统资源占用较大、调度开销高的问题。对比于传统的线程,虚拟线程通过管理在JVM内部的状态,可以避免昂贵的上下文切换过程(它是JVM层面的用户态线程,不直接绑定内核线程。JVM会把多个虚拟线程“挂载”到少量内核线程上执行,当某个虚拟线程遇到IO阻塞比如读文件、调接口时,JVM会把它从内核线程上“卸载”,让其他虚拟线程继续使用这个内核线程。等阻塞结束,再把这个虚拟线程重新挂载到空闲的内核线程上执行。),且它的初始栈内存为几KB还可根据不同的需求自动扩缩容,使得即使百万虚拟线程也不会对系统资源造成较大的压力。

简单实现

虚拟线程的使用与普通线程类似,使用Thread.ofVirtual()来创建虚拟线程

1.13.png 还可以使用Executors.newVirtualThreadPerTaskExecutor()来创建一个虚拟线程执行器

public class VirtualThreadPoolExample {
public static void main(String[] args) throws InterruptedException {
    ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

    for (int i = 0; i < 10; i++) {
        final int taskNumber = i;
        executor.submit(() -> {
            try {
                Thread.sleep(1000); // 模拟工作
                System.out.println("Task " + taskNumber + " completed in virtual thread: " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }

    executor.shutdown(); // 关闭执行器
}
}

而使用虚拟线程要注意

虚拟线程并不是万能的不能什么场景都用这个虚拟线程比如CPU密集型任务因为CPU密集型任务不会有IO阻塞,JVM无法对虚拟线程进行“卸载-挂载”的切换优化,此时虚拟线程的优势发挥不出来,甚至不如传统线程池,所以在出现IO密集型任务(如接口调用、文件读写、数据库操作)优先用虚拟线程;CPU密集型任务(如大量计算)仍可保留传统线程池。最后虚拟线程的底层实现原理是一种将轻量级线程与传统线程调度分离的设计,利用协作式多任务处理、轻量级上下文切换和灵活的任务队列管理,提供了一种高效的高并发编程模型。随着Java虚拟线程的不断发展,它有望成为未来Java并发编程的重要基石。