第 22 章:线程池
学习目标
- 理解为什么"别每次现招工人",要用线程池
- 用"团队 + 排队大厅"的模型看懂七大参数
- 会套用企业级配置模板
- 分清线程池管什么、不管什么
一、为什么必须用线程池?
上一章我们用 new Thread() 叫工人,但这样有个大问题:每次有活就现场招一个临时工,太贵了。
// ❌ 每来一个请求就 new 一个线程(现招临时工)
public void handle() {
new Thread(() -> process()).start(); // 建线程要 1ms + 1MB 栈内存
}现招工人的三个致命问题:
- 贵:创建/销毁一个线程有开销(约 1ms + 1MB 栈),请求一多全花在招人上
- 失控:没法限量,请求暴涨就无限招人 → 内存爆掉(OOM)
- 没法管:不归你管,没法监控、没法优雅关闭
阿里规约:线程资源必须通过线程池提供,不允许显式
new Thread。
二、线程池 = 固定的团队 + 排队大厅
线程池就是提前招好固定的几个工人,让他们排队干活:
任务来了 → 丢进【排队大厅】(队列) → 哪个工人空闲谁拿去干 → 干完继续等下一个- 工人不辞退,一直复用 → 又快又省
- 能限量(最多招 N 个) → 不会爆内存
- 归你管 → 能监控、能优雅关闭
对应关系:
| 术语 | 团队比喻 | 实际含义 |
|---|---|---|
| 核心线程数 corePoolSize | 正式编制的老员工 | 平时常驻的工人 |
| 最大线程数 maximumPoolSize | 编制 + 允许临时外聘的人数上限 | 最多能有几个工人 |
| 任务队列 workQueue | 排队大厅(等着干活的单子) | 装等待任务的队列 |
| 空闲存活时间 keepAliveTime | 临时工闲置多久被辞退 | 非核心线程空闲多久回收 |
// 一个"5 人固定团队 + 排队大厅"的线程池
ExecutorService pool = Executors.newFixedThreadPool(5);
pool.execute(() -> System.out.println("丢给团队,谁有空谁干")); // 派活三、这个团队的规矩:七大参数
手动创建线程池最灵活,但参数多。用团队类比记:
new ThreadPoolExecutor(
8, // ① corePoolSize:正式工 8 个
16, // ② maximumPoolSize:最多 16 个(8 正式 + 8 临时)
60, TimeUnit.SECONDS, // ③ ④ 临时工闲置 60 秒就辞退
new ArrayBlockingQueue<>(1000), // ⑤ 排队大厅能装 1000 个任务(有界!)
namedThreadFactory(), // ⑥ 给工人起名,方便排查(见下)
new ThreadPoolExecutor.CallerRunsPolicy() // ⑦ 大厅也满了怎么办(见第五节)
);⑥ 给工人起名(排查线上问题全靠它):
private static ThreadFactory namedThreadFactory() {
return new ThreadFactory() {
private final AtomicInteger seq = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "biz-thread-" + seq.getAndIncrement());
}
};
}四、任务来了怎么分配(提交流程)
当一个任务提交进来,团队按这个顺序处理:
任务来了
↓
正式工没干满?(当前线程 < corePoolSize)
├─ 是 → 开一个新工人去干
└─ 否 → 排队大厅(队列)满了没?
├─ 没满 → 丢进大厅排队
└─ 满了 → 还能招临时工?(当前线程 < maximumPoolSize)
├─ 是 → 招临时工去干
└─ 否 → 大厅和人都满了 → 走【拒绝策略】⚠️ 坑 1:大厅满了才会招临时工。如果你用了无界队列(
LinkedBlockingQueue()默认容量Integer.MAX_VALUE),大厅永远装不满,临时工就永远招不出来,maximumPoolSize形同虚设,任务堆积可能 OOM。java// ❌ 无界队列:大厅无限大,任务无限堆 → 内存爆 new ThreadPoolExecutor(8, 16, 60, SECONDS, new LinkedBlockingQueue<>()); // ✅ 有界队列:大厅限 1000,满了才招临时工 new ThreadPoolExecutor(8, 16, 60, SECONDS, new ArrayBlockingQueue<>(1000));
临时工招多少、闲置多久?
| 任务类型 | 线程数公式 | 为什么 |
|---|---|---|
| CPU 密集(纯计算) | CPU 核数 + 1 | 多开也抢不到 CPU,反而切换浪费 |
| IO 密集(网络/磁盘) | 2 × CPU 核数 或更多 | 线程大多在等 IO,多开能填满空闲 |
拿核数:Runtime.getRuntime().availableProcessors()
五、团队满了怎么办:拒绝策略
大厅满了 + 人也满了,新任务没地儿去,触发拒绝策略:
| 策略 | 行为 | 推荐度 |
|---|---|---|
| AbortPolicy(默认) | 直接抛异常,任务丢掉 | ⭐⭐⭐ |
| CallerRunsPolicy | 让提交任务的线程自己去干(天然限流) | ⭐⭐⭐⭐⭐ |
| DiscardPolicy | 静默丢弃(丢了都不知道) | ❌ |
| DiscardOldestPolicy | 丢大厅里最老的任务,再试一次 | ⭐⭐ |
生产推荐
CallerRunsPolicy:高峰期时,提交方(比如主线程)被迫自己干 → 它变慢 → 新请求进来得排队,自动"减速限流",不丢任务。
六、关键澄清:线程池管什么、不管什么
新手最容易混淆的是这里。
线程池保证的是:
- 一个线程同一时刻只干一个任务(干完才取下一个)
- 所以同一个请求,绝不会同时被两个线程执行——一个请求分配一个线程,全程跑完
线程池不保证的是:
- 不保证"两个不同的请求不碰同一份数据"。用户 A 和 B 同时改同一行,是两个请求 → 两个线程 → 同时执行同一句 SQL。线程池不管这个。
那怎么办?回上一章说的:改数据库有行锁兜底,你写普通 CRUD 根本不用管。线程池 + 数据库行锁,各管一段。
七、submit vs execute
pool.execute(() -> doWork()); // ① Runnable,没返回值
Future<String> f = pool.submit(() -> "result"); // ② Callable,要返回值
String result = f.get(); // 阻塞等结果⚠️ 坑 2:
pool.submit()里抛的异常会被吞,只有Future.get()时才抛出来。javaFuture<?> f = pool.submit(() -> { throw new RuntimeException("炸了"); }); // 前面的代码看不到异常,只有 f.get() 才会抛
八、Executors 工厂方法(慎用)
JDK 自带简便方法,但多数有坑:
Executors.newFixedThreadPool(4); // 固定团队 —— ⚠️ 无界队列
Executors.newCachedThreadPool(); // 自动伸缩 —— ⚠️ 无界队列
Executors.newSingleThreadExecutor(); // 单线程 —— ⚠️ 无界队列
Executors.newScheduledThreadPool(4); // 定时任务⚠️ 坑 3:
newFixedThreadPool/newCachedThreadPool内部都用无界队列,任务一多可能 OOM。生产建议自己new ThreadPoolExecutor+ 有界队列(见第三节模板)。
九、本章小结
| 概念 | 团队比喻 | 关键 |
|---|---|---|
| 为什么用 | 别每次现招人 | 贵 / 失控 / 没法管 |
| corePoolSize | 正式工人数 | 常驻 |
| maximumPoolSize | 最多工人数 | 大厅满了才招临时工 |
| workQueue | 排队大厅 | 必须有界防 OOM |
| 拒绝策略 | 大厅人也满了 | 生产推荐 CallerRunsPolicy |
| 线程数 | — | CPU 密集=核数+1,IO 密集=2×核数 |
| 管什么 | — | 一个请求一个线程 |
| 不管什么 | — | 数据冲突靠数据库行锁 |
动手练习
- 建团队:用
ThreadPoolExecutor建一个 5 人团队 + 有界队列,提交 10 个任务观察 - 大厅实验:把队列设成 2,一次提交 20 个任务,看拒绝策略怎么触发
- Future 取结果:submit 10 个 Callable,用
invokeAll等全部完成 - 数工人:自定义 ThreadFactory 起名后,观察打印的线程名
下一章:第 23 章:并发容器与原子类 →