Skip to content

线程池与 ThreadPoolExecutor ​

S6-004建议学高级约 90 分钟JDK 21Java 高级与架构 · 并发编程

1. 本节定位 ​

线程池是并发编程的第一生产工具,但它的行为有一个反直觉的顺序:

text
core=2, queue=2, max=4,提交 7 个任务(各 500ms)实测走向:
  任务 1、2 → 两个核心线程 ✓
  任务 3、4 → **先入队**(哪怕 max 还没用满)✗←反直觉
  任务 5、6 → 队列满了才扩容到 max(t3/t4)✓
  任务 7   → 队列满 + 到 max → **拒绝** ✗✓

★ 顺序是「core → 队列 → max → 拒绝」,不是「core → max → 队列」 ✓✓✓

2. 学习目标 ​

  • 背出任务走向顺序,并能推演任意参数组合 ★★★;
  • 知道四种拒绝策略与选型 ★★★;
  • 知道为什么禁用 Executors ★★★;
  • 会正确命名线程与选队列 ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

★ 一个真实事故模式 ✗✗:

队列设成无界(LinkedBlockingQueue)→ 依赖变慢 → 任务堆积 → OOM ✗✗

而 max 设得再大也永远不生效(队列不满)✗✗✗

5. 核心概念 ​

5.1 任务走向(实测)★★★ ​

任务去向原因
1、2核心线程 t1/t2 ✓core 未满
3、4入队 ✗←反直觉core 满先入队
5、6扩容线程 t3/t4 ✓队列满才扩容
7拒绝 ✗队列满 + 到 max

★ 推论 ✓✓✓:无界队列下 max 永远不生效 ✗✗

5.2 四种拒绝策略 ★★★ ​

策略行为选型
Abort(默认)✓提交抛异常调用方能感知 ✓
CallerRuns ✓✓提交者自己跑 → 背压降速不能丢且想自动降速 ✓✓✓
Discard ✗静默丢能丢的(日志/埋点)✓
DiscardOldest ✗丢队首最老少用 ⚠️

★★★ 铁律:能丢的用 Discard;不能丢的必须 CallerRuns 或自定义落库重试 ✓✓✓

5.3 禁用 Executors 的原因 ★★★ ​

工厂坑
newFixedThreadPool(n)无界队列 → OOM ✗✗
newCachedThreadPool()max = Integer.MAX_VALUE → 线程爆炸 ✗✗

★ 正确姿势:手动 new ThreadPoolExecutor 显式四参数 ✓✓✓

5.4 四条实践纪律 ★★★ ​

#纪律
①队列必须有界 ✓✓
②拒绝策略必须显式选 ✓✓
③线程必须有名字(排查全靠它,《Arthas 与线上问题诊断》)✓✓✓
④线程数按 IO/CPU 定:CPU 密集 = 核数;IO 密集 = 核数 × (1 + 等待/计算) ✗✓

★★★ 线程数的最后一句 ✓✓:虚拟线程时代(《虚拟线程》),IO 密集的池化将成为历史 ✓✓✓

6. 递进示例 ​

6.1 标准创建 ​

java
// WORKER_SEQ 是静态 AtomicInteger;labs 的 ThreadPoolLesson.java 用同款写法(变量名 seq)
new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    r -> new Thread(r, "order-worker-" + WORKER_SEQ.incrementAndGet()),
    new ThreadPoolExecutor.CallerRunsPolicy());

6.2 反例:无界队列 + 静默丢弃 ​

java
new ThreadPoolExecutor(8, 8, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(),        // ✗✗ 无界
    r -> new Thread(r),                 // ✗✗ 无名字
    new ThreadPoolExecutor.DiscardPolicy());   // ✗✗ 丢任务无感知

7. 完整可运行代码与实测 ​

代码:labs/java-deep/src/main/java/cn/kb/learn/deep/concurrency/ThreadPoolLesson.java

实测输出(某次运行;线程领取顺序不固定):

text
task-7 REJECTED
task-2 by t2
task-5 by t3
task-1 by t1
task-6 by t4
task-4 by t2
task-3 by t4
routing: 1,2->core; 3,4->queue; 5,6->grow; 7->reject

任务 7 的拒绝发生在提交阶段,通常最先打印;任务 3、4 是队列中的任务,由先空闲的核心线程领取,因此执行线程名会浮动;5、6 由扩容线程 t3/t4 执行。

8. Debug 观察点 ​

  1. 把队列改成无界:观察 max 失效、第 7 个任务也入队 ✗✓;
  2. jstack 看线程名:有名字的排查快 10 倍 ✓✓;
  3. CallerRuns 下观察提交线程被拖慢(背压)✓✓。

9. 常见错误 ​

#错误说明
1Executors 创建 ✗✗✗OOM 双坑 ✓✓✓
2无界队列 ✗✗堆积 OOM ✓✓
3线程无名 ✗✗jstack 一团 thread-N ✓
4Discard 丢关键任务 ✗✗✗静默丢 ✓✓
5IO 密集用 CPU 数量池 ✗等待比公式 ✓
6以为 max 先于队列 ✗✗✗本节实测反直觉点 ✓✓✓

10. 动手实验 ​

  1. 跑任务走向实验,改参数推演再验证 ✓✓✓;
  2. 四种拒绝策略各试一次 ✓✓;
  3. 用 CallerRuns 观察"提交者降速" ✓✓;
  4. 把项目 A 的借书接口改成线程池处理,选参数并说明理由 ★★★。

验收方式:给出任意参数组合,能推演 10 个任务的走向。

扩展任务:实现带超时的批量并行调用(invokeAll + 超时 + 部分结果处理)✓✓

11. 练习题 ​

  1. core=4, queue=10, max=8:第 12 个任务去哪?(提示:4 核心 + 10 队列)
  2. CallerRunsPolicy 的副作用?
  3. 为什么线程要有名字?
  4. IO 密集(等待/计算 = 9)8 核机器的池大小?
  5. 虚拟线程后还需要池吗?
参考答案(要点)
  1. 入队 ✗✓(4 核心忙 + 队列容量 10 → 第 5~14 个都入队,第 15 个才扩容)★
  2. 提交者线程被占用 → 提交方变慢(正是背压)⚠️✓ ——但 Web 提交者 = Tomcat 线程,拖慢的是接收能力 ⚠️★
  3. jstack/日志里定位问题线程全靠名字 ✗✗★
  4. 8 × (1+9) ≈ 80(理论),实际上限受下游与内存约束,压测定 ✓★
  5. IO 密集不再需要池(虚拟线程一请求一线程)✓✓;CPU 密集仍要池(= 核数)✓★

12. 掌握标准 ​

  • [ ] 背出任务走向顺序并能推演 ★★★;
  • [ ] 知道四种拒绝策略与选型 ★★★;
  • [ ] 知道禁用 Executors 的原因 ★★★;
  • [ ] 会正确命名线程与选队列 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《JUC 工具类与并发容器》。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引