线程池
线程池核心参数与执行流程
1. 我对线程池的理解
我把线程池理解成一个 任务调度容器。
它内部提前准备或按需创建一些线程,用来反复执行提交进来的任务。
这样做的核心价值有三点:
- 复用线程,减少频繁创建和销毁线程的开销
- 控制并发数量,避免线程无限增长
- 通过阻塞队列缓存任务,削峰填谷
Java 里最核心的线程池实现类是 ThreadPoolExecutor。
2. 核心构造参数
ThreadPoolExecutor 最常见的构造方法如下:
1 | public ThreadPoolExecutor( |
我可以把这 7 个参数理解成:
corePoolSize:线程池平时保留多少个核心线程maximumPoolSize:线程池最多允许多少个线程keepAliveTime:多出来的空闲线程最多活多久unit:上面这个时间的单位workQueue:任务太多时先排队到哪里threadFactory:线程要怎么被创建handler:线程和队列都满了之后怎么办
3. 每个参数的具体含义
3.1 corePoolSize
corePoolSize 表示 核心线程数。
我把核心线程理解成线程池里的常驻员工。
任务刚提交进来时,线程池会优先创建核心线程来处理任务。
即使这些核心线程后面空闲了,默认也会保留,不会立刻销毁。
例如:
corePoolSize = 5
我的理解是:
- 线程池优先维持 5 个核心线程
- 前 5 个任务到来时,大概率会直接创建线程执行
- 这 5 个线程之后会尽量长期存在,反复处理后续任务
补充一点:
如果调用了下面这个方法:
executor.allowCoreThreadTimeOut(true);
那核心线程也允许因空闲超时而被回收。
也就是说,核心线程默认常驻,但也可以改成不常驻。
3.2 maximumPoolSize
maximumPoolSize 表示 线程池允许存在的最大线程数。
这里说的是总线程数,不只是非核心线程数。
例如:
corePoolSize = 5
maximumPoolSize = 10
我的理解是:
- 前 5 个是核心线程
- 当核心线程都忙,任务队列也满了,线程池才会继续创建新线程
- 新增出来的这部分线程属于非核心线程
- 总线程数最多只能到 10
所以我可以记成:
corePoolSize决定平时保底有多少线程maximumPoolSize决定高峰期最多能扩到多少线程
3.3 keepAliveTime
keepAliveTime 表示 空闲线程的存活时间。
默认情况下,它主要作用于 非核心线程。
也就是说,一个非核心线程如果空闲时间超过这个值,就可能被销毁。
例如:
keepAliveTime = 60
unit = TimeUnit.SECONDS
我的理解是:
- 一个非核心线程如果连续 60 秒没有拿到任务
- 线程池就可以把它回收掉
- 这样高峰期扩出来的线程,不会一直占着资源
所以它的作用,本质上是控制线程池的“弹性收缩”。
3.4 unit
unit 就是 keepAliveTime 的时间单位。
常见写法有:
TimeUnit.SECONDS
TimeUnit.MILLISECONDS
TimeUnit.MINUTES
这个参数本身没什么复杂逻辑,它只是告诉线程池应该按什么单位解释 keepAliveTime。
3.5 workQueue
workQueue 表示 任务阻塞队列。
当核心线程都在忙时,新任务通常不会立刻创建新线程,而是先尝试进入这个队列等待。
我把它理解成“候车区”或者“排队区”。
常见队列类型
1)ArrayBlockingQueue
new ArrayBlockingQueue<>(100)
这是一个有界队列,底层是数组,容量固定。
我的理解是:
- 队列长度明确可控
- 不容易因为无限堆积任务导致内存问题
- 生产环境中比较常用
2)LinkedBlockingQueue
new LinkedBlockingQueue<>()
这是链表结构的阻塞队列。
如果不手动指定容量,默认容量非常大,接近 Integer.MAX_VALUE。
我的理解是:
- 好处是很能装
- 风险是任务堆积太多时,容易撑爆内存
- 因为队列太大,线程池也不容易扩容到
maximumPoolSize
这是一个很容易忽略的点。
因为很多人以为最大线程数一定会用上,其实未必。
如果队列特别大,任务会一直排队,线程池反而懒得扩容。
3)SynchronousQueue
new SynchronousQueue<>()
这个队列几乎不存储元素。
我可以把它理解成“直接交接”。
它的特点是:
- 任务进来后不能真正排队
- 必须立刻交给某个线程处理
- 如果当前没有空闲线程接手,就倾向于新建线程
所以使用它的线程池,扩容通常会更激进。
我对 workQueue 的一个核心理解
workQueue 不只是存任务这么简单,它会直接影响线程池的扩容策略。
因为线程池的执行逻辑通常是:
- 先用核心线程
- 再尝试入队
- 队列满了才扩线程
所以:
- 队列越大,越不容易创建更多线程
- 队列越小,越容易触发扩容
也就是说,队列容量和线程数量是联动关系。
3.6 threadFactory
threadFactory 表示 线程工厂,负责定义线程是怎么创建出来的。
我可以通过它控制:
- 线程名称
- 是否是守护线程
- 线程优先级
- 线程组
- 未捕获异常处理器
例如自定义线程名:
ThreadFactory factory = r -> new Thread(r, “order-pool-thread”);
这样做的意义很大,因为排查问题时,我在日志、监控、线程 dump 里能一眼看出来这些线程是干什么的。
所以我对它的理解是:
threadFactory决定线程的出生方式和身份信息
3.7 handler
handler 表示 拒绝策略。
当下面三个条件同时满足时,线程池就处理不了了:
- 核心线程满了
- 队列满了
- 最大线程数也到了
这时候新提交的任务就只能被拒绝,拒绝时怎么处理,由 handler 决定。
JDK 常见的 4 种策略如下。
1)AbortPolicy
直接抛异常:
RejectedExecutionException
这是默认策略。
我的理解是,线程池明确告诉调用方:我处理不了了。
2)CallerRunsPolicy
谁提交任务,谁自己执行这个任务。
我的理解是:
- 原来调用方只是负责提交
- 现在线程池忙不过来了,就让调用方自己干
- 这样会拖慢调用方的速度,形成一种天然的反压
3)DiscardPolicy
直接丢弃任务,不抛异常。
我的理解是:
- 任务被悄悄扔掉了
- 如果业务不允许丢任务,这个策略风险很大
4)DiscardOldestPolicy
丢弃队列里最早的任务,再尝试提交当前任务。
我的理解是:
- 更偏向保留新任务
- 适合某些对实时性要求更高的场景
4. 参数之间的关系
这几个参数不是独立的,我需要把它们联动起来理解。
4.1 corePoolSize 和 maximumPoolSize
corePoolSize决定平时线程池至少维持多少个线程maximumPoolSize决定高峰期最多能扩到多少个线程
4.2 workQueue 和 maximumPoolSize
这个关系很关键。
只有在 队列满了 的前提下,线程池才会考虑继续创建非核心线程。
所以如果队列特别大,大多数任务都先排队了,线程池根本不急着扩容。
也就是说:
队列大小会影响最大线程数是否真的有机会生效
4.3 keepAliveTime 和非核心线程
高峰期线程池可能扩容出很多非核心线程。
任务高峰过去后,这些多出来的线程不能永久保留,所以要靠 keepAliveTime 控制它们逐步回收。
5. 线程池的执行流程
这一部分是线程池最核心的运行逻辑。
我重点看的是 execute(Runnable command) 这个方法。
当我提交一个任务时,线程池大体会按下面的顺序处理。
第一步:如果运行中的线程数小于 corePoolSize
线程池会优先创建一个核心线程来执行这个任务。
也就是说:
- 不是先排队
- 也不是先看最大线程数
- 而是先尽量把核心线程补齐
我的理解是:
核心线程是线程池的基本工作班底,所以一开始优先建立它们
第二步:如果核心线程都满了,就尝试让任务进入 workQueue
这时线程池不会马上扩容,而是先把任务放进阻塞队列里等待。
我的理解是:
线程池的设计思路不是一上来就疯狂加线程,而是先让现有线程消化,同时让任务排队缓冲
第三步:如果队列也满了,再判断当前线程数是否小于 maximumPoolSize
如果还没达到最大线程数,线程池就会继续创建非核心线程来执行任务。
我的理解是:
- 说明当前负载已经超过了“核心线程 + 队列缓冲”的承载能力
- 这时线程池开始进入高峰扩容模式
第四步:如果队列满了,而且线程数也达到 maximumPoolSize
这时线程池彻底处理不过来了,就会执行拒绝策略 handler。
也就是说,拒绝任务并不是第一时间发生的,而是在所有容量都耗尽之后才发生。
6. 我把整个执行流程写成一句话
当一个任务提交到线程池时:
- 先看核心线程是否已满
- 没满就创建核心线程执行任务
- 满了就尝试让任务进入阻塞队列
- 如果队列满了,再尝试创建非核心线程
- 如果线程数也达到最大值,就执行拒绝策略
7. 从源码角度看线程池内部是怎么跑的
如果再往底层看,我会发现线程池内部不是简单地“线程执行一次任务就结束”,而是通过 Worker 机制反复复用线程。
7.1 Worker 的作用
线程池内部会把线程封装成 Worker 对象。
我可以把一个 Worker 理解成:
- 持有一个真正的线程
- 负责不断取任务并执行
- 在线程池中作为最小工作单元存在
所以线程池管理的不是一堆裸线程,而是一堆 Worker。
7.2 线程为什么能复用
线程执行完一个任务后不会立刻销毁。
它会继续尝试从队列中获取下一个任务。
可以抽象成下面这种逻辑:
1 | while (task != null || (task = getTask()) != null) { |
我的理解是:
- 第一次任务可能是提交时直接分配给它的
- 后续任务可能是它从队列中不断取出来的
- 只要还能取到任务,这个线程就会继续工作
这就是线程池“复用线程”的本质。
7.3 getTask() 的作用
getTask() 可以理解成线程去线程池里“领活”。
它的行为大概取决于:
- 当前线程是否应该继续存活
- 当前队列里有没有任务
- 当前线程是不是非核心线程
- 是否已经空闲超时
如果一个非核心线程长时间拿不到任务,就可能在这里返回 null,然后退出循环,最终被销毁。
8. 为什么线程池不是先扩到最大线程数再说
这是线程池设计里的一个重要思想。
如果一来任务就立刻疯狂建线程,会带来很多问题:
- 线程创建本身有成本
- 线程过多会导致上下文切换严重
- CPU 会在大量线程之间来回切换,真正干活的效率反而下降
- 线程太多还会占用更多内存资源
所以线程池采用的是一种更稳的策略:
- 先建立核心线程
- 再用队列缓冲
- 真的扛不住了才扩容
- 最后再拒绝
这是一种“优先稳态,再逐步弹性扩展”的设计。
9. 一个完整例子
import java.util.concurrent.*;
1 | public class ThreadPoolDemo { |
2,
4,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
1 | for (int i = 1; i <= 7; i++) { |
1 | executor.shutdown(); |
10. 结合上面的例子理解执行过程
参数是:
- 核心线程数 = 2
- 最大线程数 = 4
- 队列容量 = 2
假设 7 个任务几乎同时提交,执行过程可以这样理解:
第 1 个任务
当前线程数是 0,小于核心线程数 2
直接创建核心线程执行
第 2 个任务
当前线程数是 1,小于核心线程数 2
再创建一个核心线程执行
第 3 个任务
核心线程满了
尝试进入队列,成功
第 4 个任务
继续进入队列,成功
此时队列也满了
第 5 个任务
核心线程满,队列满
当前线程总数 2,小于最大线程数 4
创建一个非核心线程执行
第 6 个任务
同理,再创建一个非核心线程执行
此时线程总数达到 4
第 7 个任务
核心线程满
队列满
最大线程数也满
执行拒绝策略
11. 我对线程池整体运行方式的总结
我可以把线程池理解成一个四层缓冲结构:
第一层:核心线程
优先直接执行任务
第二层:阻塞队列
核心线程忙时,任务先排队等待
第三层:非核心线程
队列满了以后,线程池再临时扩容
第四层:拒绝策略
所有资源都用尽后,对新任务做最终处理
12. 一段我自己会放进笔记里的总结
线程池的本质是复用线程和控制并发。ThreadPoolExecutor 的核心参数包括核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂和拒绝策略。
任务提交后,线程池会先尝试创建核心线程执行;核心线程满了,任务进入阻塞队列;队列满了,再创建非核心线程;如果最大线程数也达到上限,就执行拒绝策略。
线程池内部通过 Worker 持有线程,并让线程不断从队列中获取任务执行,因此线程可以被反复复用,而不是执行一次就销毁。
13. 极简版笔记
参数
corePoolSize:核心线程数maximumPoolSize:最大线程数keepAliveTime:非核心线程空闲存活时间unit:时间单位workQueue:任务阻塞队列threadFactory:线程创建工厂handler:拒绝策略
执行流程
- 线程数小于核心线程数,直接创建核心线程执行
- 核心线程满了,任务进入队列
- 队列满了且线程数小于最大线程数,创建非核心线程执行
- 队列和线程都满了,执行拒绝策略