15.线程池的设计思想
# 25.线程池的设计思想
📍 本篇位置:第 3 卷 · 并发之道 · 第 15 篇(落地三部曲之一) 🎯 核心矛盾:线程的创建开销 vs 任务的短平快 —— 用"池"解决"一次性"的浪费 🧭 设计灵魂:线程池 = 对象池 + 任务队列 + 调度策略;它是"生产者-消费者"模式在并发工程里最重要的一次具象化 🌐 跨语言覆盖:Java(ThreadPoolExecutor 七参数) · C++(手写 + std::thread + queue) · Python(concurrent.futures) · Go(goroutine 自带无需显式池) · C#(ThreadPool + Task) 🔗 延伸阅读:← 19.并发上下文切换原理 · → 26.线程池使用技巧 · → 27.线程池设计核心原理
flowchart TB
A[任务洪峰到达] --> B[线程池三件套]
B --> C1[核心线程<br/>常驻]
B --> C2[阻塞队列<br/>缓冲]
B --> C3[临时线程<br/>削峰]
C1 & C2 & C3 --> D{超出容量}
D -->|是| E[拒绝策略<br/>抛弃/回退/调用方执行]
D -->|否| F[任务被消费]
style B fill:#fff3cd
2
3
4
5
6
7
8
9
# 目录介绍
# 01.池化思想探索
# 1.1 什么是池化思想
“池化”不是个现代词。人类发明“预备几个、同一名额、谁需谁拿”这件事已经上千年。
仓库:不是谁买东西才进货、而是预备会出售的存量
出租车队:不是谁叫车才叫司机上班、而是预备一批可快速响应的司机
医院育鬼集:不是谁需要才发起、而是预存一批随时取用
2
3
软件工程哲学上的“池化”是同一个思想的代码体现:
预先准备一批可重复使用的资源、需要时从池里拿、用完不销毁、放回池里。
这个思想可以很多资源都可以被“池化”:
| 资源 | 序列化代价 | 被池化后的收益 |
|---|---|---|
| 数据库连接 | TCP 三次握手 + SSL、几十 ms | HikariCP、Druid |
| 线程 | 内核在定 + 栈划拨 + TLS、~1ms + 几 MB | ThreadPoolExecutor |
| HTTP 连接 | TCP + TLS 握手、几百 ms | OkHttp ConnectionPool |
| 对象实例 | new + GC | Netty ByteBuf Recycler、Apache Commons Pool |
| 内存页 | 页错 + 内核分配 | 内存池(jemalloc、tcmalloc) |
池化本质上是一种抵抗“高频起调”的谜语——只要某种资源 "创建贵、五粉五粉、使用频”、就有默认被池化的价值。
# 1.2 池化的优势
你可能听过“减少创建开销”这句话几百遍。但创建开销到底多高?按价取证你会发现这个优势强到震惊。
创建一个 OS 线程的底层代价:
- clone 系统调用 ~ 50,000 个 CPU 周期
- 实体存初始化 ~ 1MB 虚存 + 页表
- TLS 清零 ~ KB 级别
- 加入就绪队列 ~ 内核锁 + 调度器发烧
以一台 3GHz 机器上、单个线程创建费时约 100~500 μs
一秒内创建 10000 个线程:光创建本身吃 100% CPU 不够补
2
3
4
5
6
7
8
所以池化的四大优势不是“老生常谈”、是被“数字”逼着走这条路。
优势①|降低资源创建/销毁开销 频繁创建和销毁线程(或其它资源)会导致系统性能下降,池化通过复用线程降低开销。
优势②|提高响应速度 任务到达时,池中已有线程可立即执行,无需等待线程创建。这在高并发场景里是决定性的。
优势③|可控制资源使用 通过限制池中资源数量,防止资源过度使用导致系统崩溃。这是被上万次服务雪崩逼出来的反身——不限制资源 = 不可预测。
优势④|统一管理 便于对资源进行监控、调优和统计。这是生产环境里池化的“隐藏价值”——你能看到 activeCount、queueSize、能动态调。
# 1.3 池化具体体现
线程池通过维护一定数量的线程,避免为每个任务都创建新线程,从而减少系统开销。
# 1.4 一般池的场景
对这种频繁需要创建和销毁的对象保存在一个对象池中。每次用到该对象时,就取对象池空闲的对象,并对它进行初始化操作,从而提高框架的性能。这种对象池的设计核心代码如下所示
//采用一般意义上池化资源的设计方法
public class SimplePool<T> implements Pool<T> {
// 获取空闲线程
T acquire() {
}
// 释放线程
void release(T t){
}
}
class Test{
public void test() {
//期望的使用
SimplePool<Thread> pool = new SimplePool<>();
//使用
Thread t1 = pool.acquire();
//传入Runnable对象
t1.execute(()->{
//具体业务逻辑
});
//释放
pool.release(t1);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
为什么线程池没有采用一般意义上池化资源的设计方法呢?
如果线程池采用一般意义上池化资源的设计方法,你可以来思考一下,假设我们获取到一个空闲线程 T1,然后该如何使用 T1 呢?
你期望的可能是这样:通过调用 T1 的 execute() 方法,传入一个 Runnable 对象来执行具体业务逻辑,就像通过构造函数 Thread(Runnable target) 创建线程一样。
可惜的是,你翻遍 Thread 对象的所有方法,都不存在类似 execute(Runnable target) 这样的公共方法。
# 1.5 线程池核心设计
那线程池该如何设计呢?目前业界线程池的设计,普遍采用的都是生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。
flowchart LR
subgraph PRODUCERS["生产者、Producers"]
P1[业务线程 1]
P2[业务线程 2]
P3[业务线程 3]
end
subgraph QUEUE["任务队列、Buffer"]
Q[BlockingQueue]
end
subgraph CONSUMERS["消费者、Workers"]
W1[Worker 1]
W2[Worker 2]
Wn[Worker N]
end
P1 -->|submit| Q
P2 -->|submit| Q
P3 -->|submit| Q
Q -->|take| W1
Q -->|take| W2
Q -->|take| Wn
style QUEUE fill:#fff3cd
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在线程池中,线程池本身可以被视为生产者,它负责创建和管理线程资源。任务(或工作单元)可以被视为生产者生产的产品,线程池中的线程则是消费者,负责消费这些任务并执行相应的操作。
为什么生产者-消费者能作为线程池的骨架?
这是个被多个限制源头联动逼出来的选择:
限制①:生产者与消费者速度不一致、必须有缓冲
→ 为什么要 BlockingQueue
限制②:资源有限、不能无限堆积
→ 为什么队列需要有界 + 拒绝策略
限制③:调度必须是线程安全的、多个 Worker 不能同取一任务
→ 为什么队列是 BlockingQueue 而不是普通 Queue
限制④:Worker 要能安全退出、也要能被中断唤醒
→ 为什么需要 状态机 + Lock + Condition
2
3
4
5
6
7
8
这 4 个限制一叠加、生产者-消费者模式就是唯一能同时解决这 4 件事的设计。不是设计者的偏好、是数学上的唯一选项。
# 02.线程池设计框架
线程池的抽象架构模型,这种分层架构实现了关注点分离,每层负责特定的职责,通过标准接口协作。 一个完整的线程池包含以下核心抽象层:
┌─────────────────────────────────────────────────────────────┐
│ 应用层接口 (Application Layer) │
├─────────────────────────────────────────────────────────────┤
│ submit() ─────── execute() ─────── invoke() ─────── schedule() │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 任务调度层 (Scheduling Layer) │
├─────────────────────────────────────────────────────────────┤
│ 任务队列 ─────── 拒绝策略 ─────── 饱和策略 ─────── 优先级调度 │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 线程管理层 (Thread Management Layer) │
├─────────────────────────────────────────────────────────────┤
│ 线程创建 ─────── 线程回收 ─────── 空闲管理 ─────── 异常处理 │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ 资源监控层 (Monitoring Layer) │
├─────────────────────────────────────────────────────────────┤
│ 指标收集 ─────── 状态追踪 ─────── 性能分析 ─────── 动态调优 │
└─────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 2.1 核心原理和架构
线程池是一种管理和复用线程的机制,旨在提高多线程程序的性能和资源利用率。其核心原理是通过预先创建一定数量的线程,并将任务分配到这些线程中执行,从而避免频繁创建和销毁线程的开销。
graph TB
subgraph "客户端层"
A[任务提交者] --> B[ThreadPoolExecutor]
end
subgraph "线程池核心层"
B --> C[任务队列 BlockingQueue]
B --> D[线程管理器]
B --> E[拒绝策略处理器]
D --> F[核心线程池]
D --> G[扩展线程池]
F --> H[Worker Thread 1]
F --> I[Worker Thread 2]
F --> J[Worker Thread N]
G --> K[临时线程 1]
G --> L[临时线程 2]
end
subgraph "任务执行层"
H --> M[任务执行]
I --> M
J --> M
K --> M
L --> M
M --> N[任务完成回调]
M --> O[异常处理]
end
subgraph "监控管理层"
P[线程池监控] --> B
Q[性能统计] --> B
R[健康检查] --> B
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 2.2 线程复用思想
为什么“线程复用”是线程池的灵魂?这要从“线程本质上是昂贵的”说起。
问题推导:频繁创建和销毁线程会消耗大量系统资源,降低程序性能。不仅是 “创建贵”、还是“销毁也贵”——销毁需要 等 worker 跳出 run、释放栈空间、报 GC 、可能还需要等其它线程唤醒。
解决方案:线程池预先创建一组线程,任务到来时直接复用这些线程,避免重复创建和销毁。
这里有一个反直觉的点:你以为“同一个线程跳多件事”是个艰巨设计,实际上这是最高效的设计。原因在于上下文切换不只是保存寄存器、还会导致 L1/L2/L3 缓存全部失效。一个线程跑在同一个核上、能享受缓存心热、这是创建新线程远远达不到的优势。
线程复用的实现三步:
步骤 1|预先创建线程 在初始化时创建一组线程、并使其处于等待状态。
// JDK 原生实现
private boolean addWorker(Runnable firstTask, boolean core) {
// 创建 Worker、Worker 本身是一个 Runnable + 一个 Thread
Worker w = new Worker(firstTask);
final Thread t = w.thread;
t.start(); // 从这一刻起、线程就跑在 Worker.run() 里了
}
2
3
4
5
6
7
步骤 2|任务分配 当任务到来时、将任务分配给空闲线程执行。实际上不是“分配”、是“任务入队、Worker 主动拾”。
步骤 3|线程回收 线程执行完任务后、不销毁、而是重新进入空闲状态、等待下一个任务。心脲的是:这个“循环”是在 worker 自己的 run() 里实现的、不需要外部控制:
final void runWorker(Worker w) {
while (task != null || (task = getTask()) != null) {
task.run();
// 跑完取下一个任务、一直循环
}
}
2
3
4
5
6
这是“任务其实不是被推到线程上、是被线程拍走的”——这个反转是理解线程池的关键。
# 2.3 要有任务队列
问题推导:当任务数量超过线程池的处理能力时,需要一种机制来缓冲任务。不然业务会被丢、或者线程会被炸。
解决方案:线程池使用任务队列(如阻塞队列)来存储待执行的任务,确保任务不会丢失。
为什么一定要是“阻塞队列”?
这是个面试常问点、但讲清楚的人不多。换个“普通队列”会怎么样?
// 如果用普通队列、Worker 取任务需要这样写
while (true) {
Runnable task = queue.poll();
if (task != null) { task.run(); continue; }
Thread.sleep(10); // ⚠️ 只能忙轮询、或 sleep 一下
}
2
3
4
5
6
这不是设计陕阱、是居礻性问题。“sleep 多久”是个永远错误的选择:sleep 夭短 = CPU 被轮询烧炽;sleep 夭长 = 任务响应迟延。
阻塞队列用 Lock + Condition 优雅解决了这个问题:
队空 → Worker 调 take()、被挂起 、不耗 CPU
任务来 → 生产者 offer、Condition.signal 唤醒 Worker
队满 → 生产者 put、被挂起 (有界队列)、反压到上游
2
3
这不是“一个队列”、是“一个同步原语 + 一个缓冲区”的复合实体。生产者-消费者模式的哲学在这里被一句话实体化。
# 2.4 合理资源管理
问题推导:线程数量过多会消耗大量系统资源,甚至导致系统崩溃。这个“多”到什么程度?
JVM 默认 -Xss = 1MB、一个线程需几 MB、主要是栈
Linux 默认 ulimit -n 限制 fd 数、限制了线程最大数量
Linux 默认 pid_max = 32768、超过不能创建新线程
线程超过 1万、多数 JVM 会推入稳定堆使用不可控区
线程超过 5万、Linux 默认参数下会招不住、调度器变慢
2
3
4
5
6
解决方案:线程池通过限制线程数量(核心线程数、最大线程数)和任务队列大小,合理管理系统资源。
三道闸门限制资源:
第一道:corePoolSize → 常驻、不会被回收
第二道:workQueue 容量 → 生产-消费不一致时的缓冲、避免丢任务
第三道:maximumPoolSize → 临时、项临时雷峰、峰过后被回收
超过 → RejectedExecutionHandler 拒绝、反压到业务层
2
3
4
这个设计的高明处:使用 三个闸门 而不是 一个闸门 来限制资源——这让线程池能**同时适应“平稳负载”和“突发雷峰”**两种场景。平稳期只需要 corePoolSize 个线程;雷峰期会临时报动 maximumPoolSize 个线程、峰过后释放。弹性限流的本质。
# 2.5 任务调度思想
问题:如何高效地将任务分配给线程执行。
解决方案:线程池根据任务队列的状态和线程的可用性,动态调度任务。
# 03.线程池工作流程
线程池的工作流程一般是这样的:
- 初始化线程池:创建一组线程。
- 提交任务:当调用 execute() 或 submit() 方法提交任务时,线程池会检查当前线程数是否小于核心线程数。
- 任务入队:如果线程数已达到核心线程数,则将任务放入任务队列中等待执行。
- 拒绝任务:如果任务队列已满且线程数已达到最大线程数,则根据拒绝策略处理新提交的任务。
- 任务执行完:线程执行完任务后,返回空闲状态,等待下一个任务。
- 线程回收:当线程池关闭时,销毁所有线程。
任务提交与执行时序图
sequenceDiagram
participant Client as 客户端
participant TPE as ThreadPoolExecutor
participant Queue as 任务队列
participant Worker as 工作线程
participant Task as 任务
Note over Client,Task: 任务提交阶段
Client->>TPE: execute(task)
TPE->>TPE: 检查线程池状态
alt 核心线程数未满
TPE->>Worker: 创建新的核心线程
Worker->>Task: 直接执行任务
else 核心线程数已满
TPE->>Queue: 将任务加入队列
alt 队列未满
Queue-->>TPE: 任务入队成功
Note over Worker: 空闲线程从队列获取任务
Worker->>Queue: getTask()
Queue->>Worker: 返回任务
Worker->>Task: 执行任务
else 队列已满
alt 线程数 < 最大线程数
TPE->>Worker: 创建临时线程
Worker->>Task: 直接执行任务
else 线程数 = 最大线程数
TPE->>TPE: 执行拒绝策略
TPE-->>Client: 抛出异常或其他处理
end
end
end
Note over Client,Task: 任务执行阶段
Worker->>Task: 执行run()方法
Task-->>Worker: 任务完成
Worker->>TPE: 更新统计信息
Note over Client,Task: 线程回收阶段
alt 线程空闲时间超过keepAliveTime
Worker->>TPE: 请求销毁线程
TPE->>Worker: 销毁线程
else 继续等待新任务
Worker->>Queue: getTask()
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 3.1 提交线程任务
提交任务看似只是一句 executor.submit()、实际上背后有两个不同 API 设计:
// API 一:execute - 什么都不返回、错误会被 UncaughtExceptionHandler 报
executor.execute(() -> doWork());
// API 二:submit - 返回 Future、错误被包装在 Future.get() 里
Future<?> f = executor.submit(() -> doWork());
f.get(); // 调用才能取得错误
2
3
4
5
6
两个 API 背后最后调用的都是同一个 execute(Runnable),submit 有几个额外封装:
submit(Runnable) → 包装 RunnableFuture → execute()
submit(Callable<T>) → 包装 RunnableFuture<T> → execute()
2
execute 为什么设为 void?这是设计上反复考虑后的选择。execute 是“开火即忘”语义,不需要报任何状态。你需要状态就用 submit 。这与 Linux fork 的 “不返回”设计有极高哲学相似。
# 3.2 判断核心线程数
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) return;
}
2
3
为什么是“小于”而不是“小于等于”? 这个细节让众多人绑动反。
if (corePool < N) 创建 // ----- 这个纯是“严格小于”
if (corePool <= N) 创建 // 错误什说、会创建 N+1 个线程
2
该逻辑器仅在当前线程数 严格小于 core 时创建。然后存在 CAS 补偿:两个线程同时提交且同时看到 “少于 core”、但二者只能一个能肩负创建。其他线程 CAS 失败、会进入下一个判断。
# 3.3 判断任务队列
if (isRunning(c) && workQueue.offer(command)) {
// 二次检查、避免事件交错
if (! isRunning(recheck) && remove(command))
reject(command);
}
2
3
4
5
为什么需要二次检查? 这是多线程设计中经典的 DCL(Double-Check Locking)。场景:
T1 判断“运行中”、准备 offer
T2 同时调 shutdown()
T1 offer 成功、但线程池已不运行了
二次检查:发现 不运行 → 把任务从队列拽出、走拒绝策略
2
3
4
这个细节体现了 ThreadPoolExecutor 在并发设计上的严谨。
# 3.4 判断最大线程数
这是“雷峰响应”的最后一道闸门:队满则试图报动临时线程。
if (workerCountOf(c) < maximumPoolSize) {
if (addWorker(command, false)) return;
}
reject(command); // 最后拒绝
2
3
4
临时线程与核心线程代码层完全一样,区别仅在于创建时传入 core=false 、这个参数仅是插入判断“超过 corePoolSize 后判定走 SCHED 还是走 max”。这个设计极为精巧——不需要为“核心”和“临时”创建两种 Worker 类,只需要调用时拼接一个标签。
# 3.5 开始执行任务
任务执行不是“调 task.run()”这么简单,Worker 的 runWorker() 干了四件事:
final void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
while (task != null || (task = getTask()) != null) {
w.lock();
try {
beforeExecute(wt, task); // ① 抩子、子类可重写、打点
try { task.run(); } // ② 跑任务、唯一的业务逻辑
catch (Throwable x) { thrown = x; throw x; }
finally { afterExecute(task, thrown); } // ③ 抹尾、走费、释放
} finally {
w.unlock();
completedTasks++; // ④ 计数
task = null;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
为什么设计成 beforeExecute / afterExecute? 这是为“可观测性”量体预留的钩子。子类可重写:
- 抹除考出任务运行时间(同机上报)
- 报发 traceId 、为全链路追踪推进上下文
- 护反 ThreadLocal 清理、避免股型泄露
# 3.6 线程回收处理
线程回收什不是 “销毁线程”、是 “让 worker 从 getTask() 返回 null、自然退出 run()”。
private Runnable getTask() {
boolean timed = wc > corePoolSize || allowCoreThreadTimeOut;
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : // 有会返回 null
workQueue.take(); // 永远不会返回 null
if (r != null) return r;
// null 表示“超时了、你被回收了”
return null;
}
2
3
4
5
6
7
8
9
这里有个面试震援题:能不能让核心线程也被回收?
能。调用 executor.allowCoreThreadTimeOut(true)、所有 worker 都会参与超时回收。低负载时能进一步节省资源、但下一个任务来时会付出创建代价。这是“零资源资源”与“快响应”的权衡。
# 3.7 执行流程图
flowchart TD
A[任务提交] --> B{线程池是否运行中?}
B -->|否| C[执行拒绝策略]
B -->|是| D{当前线程数 < 核心线程数?}
D -->|是| E[创建新核心线程]
E --> F[线程直接执行任务]
D -->|否| G{任务队列是否已满?}
G -->|否| H[任务加入队列]
H --> I[空闲线程获取任务]
I --> J[执行任务]
G -->|是| K{当前线程数 < 最大线程数?}
K -->|是| L[创建临时线程]
L --> M[临时线程执行任务]
K -->|否| N[执行拒绝策略]
F --> O[任务执行完成]
J --> O
M --> O
O --> P{线程是否为临时线程?}
P -->|是| Q{空闲时间 > keepAliveTime?}
Q -->|是| R[销毁线程]
Q -->|否| S[继续等待任务]
P -->|否| T{允许核心线程超时?}
T -->|是| Q
T -->|否| S
S --> U[从队列获取下一个任务]
U --> V{获取到任务?}
V -->|是| J
V -->|否| W{线程池是否关闭?}
W -->|是| R
W -->|否| S
C --> X[结束]
N --> X
R --> X
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 04.线程复用设计
# 4.1 线程复用原理
线程复用的核心是让线程在执行完一个任务后不退出,而是继续执行下一个任务。这通过一个循环结构实现,线程不断从任务队列中获取任务并执行。
线程复用的核心是将线程创建成本分摊到多个任务执行周期。其理论基础是线程上下文切换成本远低于进程创建成本。
# 4.2 线程生命周期管理
创建:线程池初始化时创建一定数量的线程(核心线程),这些线程处于等待任务状态。 运行:线程从任务队列中获取任务并执行。 等待:当任务队列为空时,线程进入等待状态(通过条件变量或锁机制)。 销毁:当线程池决定减少线程数量时,某些线程会被终止。线程池可以设置线程的空闲时间,超过该时间没有任务执行则销毁线程(非核心线程)。
stateDiagram-v2
[*] --> NEW: 创建线程
NEW --> RUNNABLE: start()
RUNNABLE --> BLOCKED: 等待锁
RUNNABLE --> WAITING: wait()/join()
RUNNABLE --> TIMED_WAITING: sleep()/wait(timeout)
BLOCKED --> RUNNABLE: 获得锁
WAITING --> RUNNABLE: notify()/notifyAll()
TIMED_WAITING --> RUNNABLE: 超时/notify()
RUNNABLE --> TERMINATED: 任务完成/异常
TERMINATED --> [*]
2
3
4
5
6
7
8
9
10
11
一个可复用的线程包含完整的状态转换:
创建(CREATED) → 就绪(READY) ↔ 运行(RUNNING) ↔ 等待(WAITING)
↘ 终止(TERMINATED)
2
复用线程的关键是避免进入终止状态,在任务完成后回到就绪状态等待新任务。
# 4.3 线程复用实现示例
以下是一个简单的线程复用示例(C++伪代码):
class WorkerThread {
private:
ThreadPool* pool;
bool running;
public:
void run() {
while (running) {
Task task = pool->getTask(); // 从任务队列获取任务,如果队列为空则阻塞
if (task) {
task.execute();
}
}
}
void stop() { running = false; }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4.4 核心线程设计
疑惑:核心线程和非核心线程在代码层面有什么区别?是不是有两种不同的Thread对象?
答疑:核心线程和非核心线程在代码层面没有任何区别!它们是同一种Worker对象。"核心"与"非核心"仅仅是一个计数上的概念——当线程池决定回收空闲线程时,会判断当前线程数是否超过corePoolSize,超过的部分在空闲keepAliveTime后被回收。
// getTask()中的关键逻辑(简化版)
private Runnable getTask() {
for (;;) {
int wc = workerCountOf(ctl.get());
// 关键判断:当前线程数 > 核心线程数?
boolean timed = wc > corePoolSize;
// timed=true: 用poll(keepAliveTime),超时返回null → 线程退出
// timed=false: 用take(),永远等待 → 核心线程不退出
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null) return r;
// r==null说明超时了 → 这个线程被当作"非核心线程"回收
return null;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
本质:哪个线程是"核心"哪个是"非核心",取决于被回收那一刻的线程总数,而不是线程创建时的身份。这是一个非常优雅的设计——不需要给线程打标签,只需要在回收时做数量判断。
核心线程也可以被回收,通过设置allowCoreThreadTimeOut(true),所有线程在空闲超时后都会退出。这在低负载时能进一步节省资源。
# 05.线程池管理设计
# 5.1 线程池状态管理
线程池状态图
stateDiagram-v2
[*] --> RUNNING: 创建线程池
RUNNING --> SHUTDOWN: 调用shutdown()
RUNNING --> STOP: 调用shutdownNow()
SHUTDOWN --> TIDYING: 所有任务完成
STOP --> TIDYING: 所有线程停止
TIDYING --> TERMINATED: 执行terminated()
TERMINATED --> [*]
note right of RUNNING
接受新任务
处理队列中的任务
end note
note right of SHUTDOWN
不接受新任务
继续处理队列中的任务
end note
note right of STOP
不接受新任务
不处理队列中的任务
中断正在执行的任务
end note
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
线程池通常有以下状态:
| 状态 | 值 | 描述 |
|---|---|---|
| RUNNING | -1 | 接受新任务并处理队列中的任务,正常处理任务 |
| SHUTDOWN | 0 | 不接受新任务,但会处理队列中的任务 |
| STOP | 1 | 不接受新任务,不处理队列中的任务,并中断正在执行的任务 |
| TIDYING | 2 | 所有任务已终止,工作线程数为0 |
| TERMINATED | 3 | terminated()方法已完成 |
# 5.2 线程池参数配置
核心线程数(corePoolSize):线程池维护的最小线程数量,即使它们处于空闲状态。 最大线程数(maximumPoolSize):线程池允许的最大线程数量。 任务队列(workQueue):用于保存等待执行的任务的阻塞队列。 线程工厂(threadFactory):用于创建新线程。 拒绝策略(rejectedExecutionHandler):当任务队列已满且线程数达到最大值时,如何处理新任务。
public class ThreadPoolConfig {
// 核心线程数:始终保持活跃的线程数量
private int corePoolSize;
// 最大线程数:线程池允许的最大线程数量
private int maximumPoolSize;
// 线程空闲时间:非核心线程的最大空闲时间
private long keepAliveTime;
// 时间单位
private TimeUnit unit;
// 任务队列:存储待执行任务的队列
private BlockingQueue<Runnable> workQueue;
// 线程工厂:用于创建新线程
private ThreadFactory threadFactory;
// 拒绝策略:当任务无法执行时的处理策略
private RejectedExecutionHandler handler;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| 应用场景 | 核心线程数 | 最大线程数 | 队列类型 | 队列大小 |
|---|---|---|---|---|
| CPU密集型 | CPU核心数 | CPU核心数+1 | LinkedBlockingQueue | 无界 |
| IO密集型 | 2*CPU核心数 | 4*CPU核心数 | ArrayBlockingQueue | 1000-5000 |
| 混合型 | CPU核心数+1 | 2*CPU核心数 | LinkedBlockingQueue | 2000-10000 |
| 高并发短任务 | 10-50 | 100-200 | SynchronousQueue | 0 |
# 5.3 线程池管理策略
线程创建策略:当任务数小于核心线程数时,创建新线程(即使有空闲线程);当任务数大于核心线程数且任务队列已满时,创建新线程直到达到最大线程数。 线程销毁策略:当线程空闲时间超过设定的keepAliveTime,且当前线程数大于核心线程数时,销毁该线程。
# 06.任务队列设计
# 6.1 队列类型对比
任务队列用于保存等待执行的任务。当线程池中的线程都在忙碌时,新任务会被放入队列中等待。
graph LR
A[BlockingQueue接口] --> B[ArrayBlockingQueue]
A --> C[LinkedBlockingQueue]
A --> D[SynchronousQueue]
A --> E[PriorityBlockingQueue]
A --> F[DelayQueue]
B --> B1[有界数组队列<br/>固定容量<br/>FIFO顺序]
C --> C1[无界链表队列<br/>可选容量<br/>FIFO顺序]
D --> D1[同步队列<br/>容量为0<br/>直接传递]
E --> E1[优先级队列<br/>无界队列<br/>优先级顺序]
F --> F1[延迟队列<br/>无界队列<br/>延迟执行]
2
3
4
5
6
7
8
9
10
11
12
# 6.2 队列类型
队列选型其实是个“三选一”谜题:无界 / 有界 / 同步移交。三者面对同一个问题提供三种完全不同的哲学。
选择①|无界队列:“永远不拒”
new LinkedBlockingQueue<Runnable>(); // 默认 Integer.MAX_VALUE
哲学:业务”任务不能丢“最重要、内存需多少给多少。阅颈陕阱:生产-消费一旦不均衡,队列会吃光堆内存。 适用场景:生产速率 < 消费速率、以及任务必须不能丢。
选择②|有界队列:“足了拒绝”
new ArrayBlockingQueue<>(1000);
new LinkedBlockingQueue<>(1000);
2
哲学:”服务稳定“最重要、任务丢了也宁可不能 OOM。与拒绝策略配合使用、是生产环境默认选择。 该如何选容量?一般是 “core 线程数 × 任务平均耗时 / 最大允许响应时间”。例:Core=20, 任务 50ms, P99=2s → 队列 ≈ 800。
选择③|同步移交队列:“永远不会被入队”
new SynchronousQueue<Runnable>();
这个设计是反直觉的:容量 = 0、offer 决不会成功(除非有另一个线程在 take)。这意味着任务提交后立即开 max、拒绝。阅原代:最优先物反拒接受原、快热锁、低反压 → CachedThreadPool。
选择④|优先级队列:PriorityBlockingQueue、用于带优先级的任务调度。反设计:优先级队列是贴底的”不公平“、低优先级任务可能被饱餁。需同时选 “优先级起升 / 老化策略”。
选择⑤|延迟队列:DelayQueue、用于定时任务 ScheduledThreadPoolExecutor 的逆块。任务入队时带 “定期起跟点 + 执行间隔”信息、到点才被 take 。
# 6.3 队列选择策略
无界队列适用于任务提交速度不快于线程处理速度的场景,避免任务被拒绝。
有界队列可以防止资源耗尽,但需要设置合适的队列大小和拒绝策略。
| 队列类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ArrayBlockingQueue | 有界缓冲,防止内存溢出 | 内存占用可控 | 容量固定,可能阻塞 |
| LinkedBlockingQueue | 高吞吐量场景 | 吞吐量高 | 可能导致内存溢出 |
| SynchronousQueue | 直接传递,快速响应 | 响应速度快 | 需要足够多的线程 |
| PriorityBlockingQueue | 任务有优先级要求 | 支持优先级 | 排序开销 |
# 07.任务调度与设计
# 7.1 任务提交过程
任务提交的决策顺序是一个三点金字塔,随着资源压力逐步升高。
优先级 1:使用核心线程 → "资源足够、护礼处理"
优先级 2:压入任务队列 → "护资源、缓冲一下"
优先级 3:抨动临时线程 → "极限雷峰、临时抨”
优先级 4:拒绝策略 → "到黑了、送丢老智。谁退丢、怎么退丢"
2
3
4
为什么不“队列与临时线程同时动作”?
这是 Java 设计者的意识选择、与其它语言不同:
Java: core 满 → 队列 → 队满才抨动 max
.NET: core 满 → 直接抨动 max 到上限 → 才走队列
Go: 无中间者、直接起 goroutine
2
3
Java 的选择偏与“以队列为起动”。有个反后果:LinkedBlockingQueue 默认无界 → 永不抨动 max → maximumPoolSize 设多大都没用。这是 Executors.newFixedThreadPool 事故根源。
# 7.2 任务调度策略
面对不同场景、Java 提供了两种提交哲学。
哲学①|直接提交 (Direct Handoff):走 SynchronousQueue 、任务不入队、最快响应
场景:任务耗时较短、要求反转联云限低、可接受“快失败”
例子:CachedThreadPool、高并发短任务场景
2
哲学②|队列提交 (Queueing):任务优先入队、队满才抨动 max
场景:任务耗时不一、要求任务不能丢、接受轻锐延迟
例子:FixedThreadPool、订单处理、计算任务、批棅场景
2
该选哪种? 三个提问决定:
1、任务可丢吗? 不可 → 队列提交
2、任务不一、生产突发? 是 → 队列提交
3、响应要求 < 100ms? 是 → 直接提交
2
3
# 7.3 拒绝策略
当线程池和队列都饱和时,采用以下策略之一:
| 策略 | 行为 | 适用场景 | 优缺点 |
|---|---|---|---|
| AbortPolicy | 抛出RejectedExecutionException | 需要感知任务被拒绝 | 默认策略,简单直接 |
| CallerRunsPolicy | 调用者线程执行任务 | 降低任务提交速度 | 提供降级机制,但可能阻塞调用者 |
| DiscardPolicy | 静默丢弃任务 | 任务丢失可接受 | 简单,但任务会丢失 |
| DiscardOldestPolicy | 丢弃最老的任务 | 新任务优先级更高 | 保证新任务执行,但老任务丢失 |
# 08.添加线程任务
# 8.1 任务提交接口
线程池提供submit或execute方法用于提交任务。
# 8.2 任务封装
任务通常被封装为Runnable或Callable对象。Callable可以返回结果或抛出异常,而Runnable没有返回值。
# 8.3 任务提交示例
// Java示例
ExecutorService executor = Executors.newFixedThreadPool(5);
Future<String> future = executor.submit(new Callable<String>() {
public String call() {
return "任务结果";
}
});
String result = future.get(); // 获取结果
2
3
4
5
6
7
8
# 09.线程回收机制
# 9.1 线程回收时机
当线程空闲时间超过keepAliveTime,且当前线程数大于核心线程数时,该线程会被终止。
当线程池关闭时,所有线程会被中断。
# 9.2 线程回收实现
线程池通过维护线程的空闲时间,当超过keepAliveTime时,线程会从任务队列获取任务时超时,然后线程退出。
# 9.3 线程回收示例
public void runWorker(Worker w) {
Thread wt = Thread.currentThread();
Runnable task = w.firstTask;
w.firstTask = null;
while (task != null || (task = getTask()) != null) {
// 执行任务
task.run();
task = null;
}
// 如果没有任务,线程退出
}
private Runnable getTask() {
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
try {
Runnable r = timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take();
if (r != null) return r;
} catch (InterruptedException retry) {
// 处理中断
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 9.4 线程池监控设计
线程池在生产环境中需要完善的监控机制,确保运行状态可观测:
核心监控指标
| 指标 | 含义 | 告警阈值参考 |
|---|---|---|
| activeCount | 当前活跃线程数 | 接近maximumPoolSize |
| poolSize | 当前线程池大小 | 异常增长 |
| queueSize | 等待队列长度 | 超过容量的80% |
| completedTaskCount | 已完成任务数 | 增长停滞 |
| rejectedCount | 拒绝任务数 | 大于0 |
监控实现方案
public class ThreadPoolMonitor {
private final ThreadPoolExecutor executor;
public void printStats() {
System.out.println("活跃线程: " + executor.getActiveCount());
System.out.println("池大小: " + executor.getPoolSize());
System.out.println("队列长度: " + executor.getQueue().size());
System.out.println("完成任务: " + executor.getCompletedTaskCount());
}
}
2
3
4
5
6
7
8
9
10
# 9.5 线程池设计总结
线程池的核心价值
- 资源复用:避免频繁创建销毁线程的开销
- 流量控制:通过队列和最大线程数限制并发量
- 统一管理:集中管理线程的生命周期
设计原则
- 根据任务类型(CPU密集/IO密集)选择合适的线程数
- 选择合适的队列类型和容量
- 配置合理的拒绝策略
- 不同业务使用独立的线程池,实现故障隔离
- 完善监控和告警,及时发现异常
常见陷阱
- 使用无界队列导致OOM
- 线程池过大导致上下文切换开销
- 共用线程池导致业务互相影响
- 忽略异常处理导致任务静默失败
# 10.跨语言设计哲学对比
线程池看似是个“全语言都一样”的东西,但实际上不同语言的实现哲学差别巨大。
# 10.1 Java:7 参数设计的哲学
new ThreadPoolExecutor(
corePoolSize, // 1、核心线程数
maximumPoolSize, // 2、最大线程数
keepAliveTime, // 3、空闲存活时间
TimeUnit.SECONDS, // 4、时间单位
workQueue, // 5、任务队列
threadFactory, // 6、线程工厂
rejectedHandler // 7、拒绝策略
);
2
3
4
5
6
7
8
9
Java 的哲学是“提供全部旋钮、主动权交给使用者”。七个参数能组合出几十种不同线程池表现:
corePoolSize=N、maxPoolSize=N、无界队列 → FixedThreadPool
corePoolSize=0、maxPoolSize=∞、SynchronousQueue → CachedThreadPool
corePoolSize=N、maxPoolSize=N、DelayQueue → ScheduledThreadPool
2
3
三个 Executors 工厂方法其实是这几个参数组合的快捷方式。阿里 Java 开发规范明令禁用这些快捷方法——原因是它们有隐藏陕阱:LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE(实质无界)、CachedThreadPool 的 max 也是 Integer.MAX_VALUE。
# 10.2 Go:为什么不需要线程池?
// Go 中不需要线程池、直接起 goroutine
for task := range tasks {
go process(task) // 100 万任务、100 万 goroutine、轻量可控
}
2
3
4
Go 的哲学是“goroutine 本身就是“轻量任务””:
goroutine 初始栈 2KB、动态增长 → 100 万 goroutine 仅几 GB
GMP 调度器抽象 G/M/P、在用户态调度 → 上下文切换不陷内核
调度器本身就是“线程池 + 任务队列” → 不需业务层再加一层
2
3
但 Go 仍然在某些场景需要“goroutine 池”:
// 场景:限制并发、起 100 万 goroutine 有问题、需要 worker pool
sem := make(chan struct{}, 100) // 并发限 100
for task := range tasks {
sem <- struct{}{} // 获取名额
go func(t Task) {
defer func() { <-sem }() // 释放名额
process(t)
}(task)
}
2
3
4
5
6
7
8
9
这不是线程池、是“信号量限并”——Go 用 channel 实现了过去要用线程池才能做的事。
# 10.3 Python:GIL下的两种线程池
# CPU 密集 → 进程池
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=8) as executor:
results = executor.map(heavy_compute, tasks)
# IO 密集 → 线程池
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=20) as executor:
results = executor.map(http_request, urls)
2
3
4
5
6
7
8
9
Python 独有现象:GIL 使得多线程不能同时在多核上跳动。所以 CPU 密集任务用 ThreadPool 纯属白费、必须上 ProcessPoolExecutor。这是 Java/Go/C# 都不需要考虑的问题。
# 10.4 C#/.NET:Task+ThreadPool分层
// .NET 使用者几乎不直接接触线程池、只面向 Task
await Task.Run(() => HeavyCompute());
await Task.WhenAll(urls.Select(url => HttpClient.GetAsync(url)));
2
3
.NET 的哲学是“Task 是使用者接口、ThreadPool 是运行时隐藏”:
Task ←— 使用者面向
↓
TaskScheduler ←— 选择怎么调度
ThreadPool ←— 运行时负责、使用者不需直接调用
2
3
4
这是最高抽象、也是最“不易出错”的设计——使用者根本遇不到 “FixedThreadPool vs CachedThreadPool” 的选型问题。
# 10.5 跨语言设计哲学表
| 语言 | 是否需要显式线程池 | 并发单元 | 调度器位置 | 设计哲学 |
|---|---|---|---|---|
| Java | 是 | OS 线程 | 应用层 | 使用者选参数、高面向可控 |
| C# | 隐含(Task) | OS 线程 | 运行时 | 隐藏、使用者只看 Task |
| Go | 不需要 | goroutine | 运行时(GMP) | 并发是语言一等公民 |
| Python | 是(且需区分进程/线程) | OS 线程 | 应用层 | GIL 下的补偿 |
| JavaScript | 不需要 | 事件循环 | 运行时 | 单线程 + IO 多路复用 |
| Rust | 是(tokio Runtime) | 任务 | 库 | 零成本抽象 |
这是个三层设计选择谱:应用层、运行时、语言。越向上、使用者越轻松;越向下、使用者越需要理解。
# 11.经典陕阱与事故复盘
线程池是“看似简单、用错事故不断”的经典。这里集中拆解五个生产事故级别的陕阱。
# 11.1 事故①|newFixedThreadPool导致OOM
事故场景:某互联网公司的订单服务用 Executors.newFixedThreadPool(50) 接收订单,双十一 2000 并发量突增、处理不过来、队列不断增长、JVM OOM。
// 看 Executors.newFixedThreadPool 的实现
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads, 0L, MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // 默认容量为 Integer.MAX_VALUE!
}
2
3
4
5
根因:LinkedBlockingQueue 默认容量是 21 亿、实际上是无界队列。任务涌入速率 > 处理速率 → 队列不受控地增长 → 吃光堆内存 → OOM。
对策:遵守阿里规范、永远使用 new ThreadPoolExecutor() 原始构造器,明确指定有界队列:
// ✅ 明确限制
new ThreadPoolExecutor(50, 100, 60L, SECONDS,
new ArrayBlockingQueue<>(1000), // 明确容量
new ThreadFactoryBuilder()
.setNameFormat("order-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()); // 拒绝后调用者跑、向上反压到业务层
2
3
4
5
6
# 11.2 事故②|共用线程池导致死锁
事故场景:某金融公司、主服务提交任务 A 到公共线程池、A 里面又提交任务 B 到同一个线程池并 .get() 等 B。高峰期、线程池被 A 充满占完、所有 B 任务在队列里、A 在等 B、服务雪崩。
// ❌ 嵌套提交同一线程池、且同步等待
Future<B> b = pool.submit(() -> taskB());
pool.submit(() -> {
Future<C> c = pool.submit(() -> taskC()); // 递归提交
c.get(); // 等 c 运行、但 c 还在队列里、发生死锁
});
2
3
4
5
6
根因:线程池本质是个“有限资源”。在“同一个线程池里递归提交 + 同步等待”是决定性的死锁陕阱。
对策:
- 不同业务使用独立线程池、避免互相影响;
- 避免“提交 + 同步等待”模式、使用
CompletableFuture.thenCompose等异步组合; - 进行压测验证最差场景。
# 11.3 事故③|任务异常被吞掉
// ❌ 调 submit、异常被包装到 Future、你不调 future.get() 就永远看不见
executor.submit(() -> {
throw new RuntimeException("隐藏错误");
});
// 某公司事故:后台任务全静默失败、告警不出、进而事故
2
3
4
5
根因:submit 返回 Future、异常被包装进去了、你不调 .get() 你什么都不知道。这与 CompletableFuture 的隐藏错误事故同源。
对策:使用 execute 代替 submit(错误会被 UncaughtExceptionHandler 捕获)、或包装一层 try/catch:
executor.execute(() -> {
try { actualWork(); }
catch (Exception e) { log.error("任务失败", e); metrics.fail(); }
});
2
3
4
# 11.4 事故④|ThreadLocal不清理泄露
// ❌ ThreadLocal 不清理 + 线程池复用 = 内存泄露
static ThreadLocal<UserContext> CURRENT_USER = new ThreadLocal<>();
executor.execute(() -> {
CURRENT_USER.set(new UserContext("Alice"));
doWork();
// 未 remove!线程下一个任务 set Bob 前、CURRENT_USER 仍然是 Alice
// 更严重的是:Alice 对象不被 GC、内存泄露
});
2
3
4
5
6
7
8
9
根因:线程复用!不清理 ThreadLocal 会跨不同任务。生产环境会表现为老年代永不释放、Full GC 频繁。
对策:使用 try/finally 重点释放 ThreadLocal、或使用 InheritableThreadLocal、或 TransmittableThreadLocal(阿里开源)。
# 11.5 事故⑤|CallerRunsPolicy反锁主线程
事故场景:服务中报着拒绝策略 = CallerRunsPolicy、认为“调用者跑”是反压。但调用者是 Tomcat 处理请求的线程、这里被锁在业务逻辑上、后面全部请求被压。
根因:CallerRunsPolicy 的本质是“把拒绝代价付出去调用者”——调用者是谁决定这个策略是否适用:
调用者是 Tomcat 请求线程 → 不适用!会锁住全部请求
调用者是定时任务线程 → 不适用!会拖住后面定时
调用者是主任务独立的 worker → 适用、是反压的理想场景
2
3
对策:默认使用 AbortPolicy + 反压上报、或自定义拒绝策略:
new RejectedExecutionHandler() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.warn("任务被拒、走降级策略");
metrics.rejected();
try { fallbackQueue.offer(r, 100, MILLISECONDS); } // 动态滑量
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
}
};
2
3
4
5
6
7
8
9
# 12.一句话总结
线程池本质上是“生产者-消费者”模式在资源有限下的商业营备营制哲学、提供一个可控、可观测、可恢复的并发边界。
三层认知:
表层抽象:一句话 —— "预创建一批线程、任务来了拾起跑"
中层抽象:三件套 —— "核心线程 + 任务队列 + 临时线程 + 拒绝策略"
底层抽象:五原则 —— "业务隔离、有界队列保护、反压上报、异常不吞、运行可视化"
2
3
终极建议:
- 拒用 Executors 快捷方法——使用原始构造器、所有参数明确;
- 业务隔离——不同业务独立线程池、故障不互相冲击;
- 必须可观测——同机上报 activeCount/queueSize/rejectedCount、超过阈值报警;
- 考虑虚拟线程——JDK 21+ 的 Loom 让 "一任务一线程" 成为可能、线程池在虚拟线程场景中逐步退出主要应用。
# 📎 延伸阅读
- 前一篇:24.Actor与CSP并发模型(并发范式之边界)
- 并发同源:19.并发上下文切换原理(为什么资源复用能节省这么高的开销)
- 设计思想:23.协程核心设计思想(虚拟线程与协程让十万任务不需要"池")
- 性能考虑:12.线程通信设计思想(为什么任务队列是同步起点)