线程池关闭与常见坑
线程池不是创建完就不用管。关闭方式、异常处理、任务依赖、ThreadLocal 清理都会影响线上稳定性。
shutdown 和 shutdownNow
flowchart TD
A["关闭线程池"] --> B{"调用 shutdown"}
A --> C{"调用 shutdownNow"}
B --> D["不再接收新任务"]
D --> E["继续执行已提交任务"]
E --> F["任务完成后终止"]
C --> G["不再接收新任务"]
G --> H["尝试中断正在执行任务"]
H --> I["返回队列中未执行任务"]| 方法 | 行为 | 适合场景 |
|---|---|---|
shutdown() | 平滑关闭,已提交任务继续执行 | 应用正常停止 |
shutdownNow() | 尝试中断任务,返回未执行任务 | 紧急停止 |
awaitTermination() | 等待线程池终止 | 优雅停机 |
优雅关闭 Demo
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class GracefulShutdownDemo {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.execute(() -> System.out.println("处理任务"));
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
}重点:捕获 InterruptedException 后要恢复中断标记,否则上层逻辑无法知道当前线程被中断过。
坑一:submit 异常被吞
submit 会把异常保存到 Future 里,如果不调用 get(),你可能看不到任务失败。
Future<?> future = pool.submit(() -> {
throw new RuntimeException("导出失败");
});
future.get(); // 这里才能拿到 ExecutionException商业后果:异步入库、消息发送、报表导出失败了,但业务以为已经提交成功。
坑二:无界队列导致 OOM
ExecutorService pool = Executors.newFixedThreadPool(10);这行代码背后是固定线程数加近似无界队列。任务提交速度长期大于处理速度时,队列会越来越大,最后可能 OOM。
正确方向:
new ThreadPoolExecutor(
10,
20,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(500),
new ThreadPoolExecutor.CallerRunsPolicy()
);坑三:线程饥饿死锁
线程池里的任务等待同一个线程池里的其他任务,可能导致所有线程都在等待,没有线程执行被等待的任务。
flowchart TD
A["线程池只有 2 个线程"] --> B["任务 A 占用线程 1"]
A --> C["任务 B 占用线程 2"]
B --> D["A 等待子任务 A1"]
C --> E["B 等待子任务 B1"]
D --> F["A1 在队列中没人执行"]
E --> G["B1 在队列中没人执行"]错误示例:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class StarvationDeadlockDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(1);
Future<String> outer = pool.submit(() -> {
Future<String> inner = pool.submit(() -> "inner result");
return inner.get();
});
System.out.println(outer.get());
pool.shutdown();
}
}这个程序可能一直等待,因为唯一线程被外层任务占住,内层任务没有线程执行。
线程饥饿死锁全过程
线程饥饿死锁不一定是传统意义上两个线程互相持有锁。它的核心是:线程池的执行线程都被“等待任务”占住,而真正能让等待结束的子任务还在队列里,没有线程可以执行。
把上面的 Demo 拆成时间线:
| 时间 | 发生什么 | 线程池状态 |
|---|---|---|
| T1 | 外层任务 outer 被提交 | 队列有 outer |
| T2 | 唯一 Worker 取出 outer 执行 | active = 1,queue = 0 |
| T3 | outer 内部提交 inner 到同一个线程池 | active = 1,queue = 1 |
| T4 | outer 调用 inner.get() 等结果 | 唯一 Worker 被阻塞 |
| T5 | inner 一直在队列中 | 没有空闲 Worker 执行 |
| T6 | outer 等不到 inner | 整个程序卡住 |
flowchart TD
A["outer 开始执行"] --> B["提交 inner 到同一线程池"]
B --> C["inner 进入队列"]
C --> D["outer 调用 inner.get"]
D --> E["Worker 被 outer 占住并等待"]
E --> F{"是否还有空闲 Worker"}
F -- "没有" --> G["inner 无法执行"]
G --> H["outer 永久等待"]为什么这不是普通锁死锁?
| 对比 | 普通死锁 | 线程饥饿死锁 |
|---|---|---|
| 等待对象 | 锁资源 | 子任务结果、队列任务 |
| 常见线程栈 | BLOCKED 等锁 | WAITING / TIMED_WAITING 在 FutureTask.get、CompletableFuture.join |
| 根因 | 持锁顺序互相等待 | 线程池容量被等待任务占满 |
| 解决方向 | 统一加锁顺序、缩小锁范围 | 拆线程池、异步编排、超时、避免池内同步等待 |
线程饥饿死锁的生产场景
它常出现在接口聚合、批处理、报表导出和 MQ 消费中。
场景一:接口聚合里嵌套提交
public OrderDetail queryOrder(Long orderId) throws Exception {
Future<Order> orderFuture = queryPool.submit(() -> orderClient.query(orderId));
Future<Pay> payFuture = queryPool.submit(() -> {
Future<PayChannel> channelFuture = queryPool.submit(() -> payClient.queryChannel(orderId));
Pay pay = payClient.queryPay(orderId);
pay.setChannel(channelFuture.get());
return pay;
});
return merge(orderFuture.get(), payFuture.get());
}风险:外层任务和内层任务都用同一个 queryPool。如果高峰期外层任务占满线程,内层任务只能排队,外层又在等内层,最终卡死或大面积超时。
场景二:批处理任务里等待子任务
pool.submit(() -> {
List<Future<?>> futures = new ArrayList<>();
for (ImportRow row : rows) {
futures.add(pool.submit(() -> importOne(row)));
}
for (Future<?> future : futures) {
future.get();
}
});风险:批处理主任务占用线程后,又把大量子任务提交到同一个池并等待。线程少、队列长、子任务慢时,很容易出现“主任务都在等,子任务没人跑”。
场景三:CompletableFuture 提前 join
CompletableFuture<String> a = CompletableFuture.supplyAsync(this::queryA, pool);
String aValue = a.join(); // 过早等待,后续任务还没提交
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> queryB(aValue), pool);这种写法不一定形成死锁,但会把异步退化成串行。更糟的是,如果 queryA 内部又提交任务到同一池并等待,就可能和线程饥饿死锁叠加。
线程饥饿死锁怎么排查
线上表现通常是:
- 接口大量超时,但 CPU 不高。
- 线程池 active 接近最大值。
- 队列长度不下降。
- 拒绝次数可能没有明显增加,因为任务还在排队。
jstack看到大量线程卡在FutureTask.get()、CompletableFuture.join()、CountDownLatch.await()。
排查流程:
flowchart TD
A["接口卡死或线程池队列不下降"] --> B["看线程池 active / queue"]
B --> C{"active 是否接近最大"}
C -- "否" --> D["查提交速度、队列、拒绝和下游耗时"]
C -- "是" --> E["jstack 看工作线程"]
E --> F{"大量线程在 Future.get/join/await"}
F -- "是" --> G["检查是否池内提交子任务并同步等待"]
F -- "否" --> H["查 socketRead、DB 连接、锁竞争、CPU"]
G --> I["拆池、异步编排、加超时、减少嵌套等待"]典型线程栈关键词:
java.util.concurrent.FutureTask.get
java.util.concurrent.CompletableFuture.join
java.util.concurrent.CountDownLatch.await
java.util.concurrent.locks.LockSupport.park看到 LockSupport.park 不要立刻认为是 AQS 锁竞争。Future.get()、CompletableFuture.join()、BlockingQueue.take()、AQS 等都会使用 park,要结合上层栈帧判断到底在等什么。
正确改法一:不要在池内同步等待同池子任务
把父任务和子任务拆到不同线程池:
ExecutorService parentPool = Executors.newFixedThreadPool(8);
ExecutorService childPool = Executors.newFixedThreadPool(32);
Future<String> outer = parentPool.submit(() -> {
Future<String> inner = childPool.submit(() -> queryRemote());
return inner.get(800, TimeUnit.MILLISECONDS);
});这样父任务等待时,不会占住子任务执行所需的同一批线程。
但这不是万能解。拆池后仍要看下游容量:如果 childPool 太大,可能把数据库、Redis 或第三方接口打爆。
正确改法二:用 CompletableFuture 编排,最后统一等待
错误思路是“提交一个等一个”。更好的方式是“先把能并行的都发出去,再统一等待”。
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(() -> orderClient.query(orderId), queryPool);
CompletableFuture<Pay> payFuture =
CompletableFuture.supplyAsync(() -> payClient.query(orderId), queryPool);
CompletableFuture<Logistics> logisticsFuture =
CompletableFuture.supplyAsync(() -> logisticsClient.query(orderId), queryPool);
OrderDetail detail = CompletableFuture
.allOf(orderFuture, payFuture, logisticsFuture)
.thenApply(v -> merge(orderFuture.join(), payFuture.join(), logisticsFuture.join()))
.get(1, TimeUnit.SECONDS);注意:这段代码仍然要控制线程池、超时和异常兜底。CompletableFuture 不是自动解决线程池问题的魔法,它只是让依赖关系表达得更清楚。
正确改法三:所有等待必须有超时
没有超时的 get() 是线上事故放大器。
Future<String> future = pool.submit(this::queryRemote);
String result = future.get(500, TimeUnit.MILLISECONDS);超时后要有策略:
| 场景 | 策略 |
|---|---|
| 非核心展示信息 | 返回默认值或降级文案 |
| 核心依赖 | 快速失败,返回明确错误 |
| 可补偿任务 | 记录补偿任务,异步重试 |
| 批处理 | 标记该批失败,不阻塞全部批次 |
没有超时会导致线程长期占用;超时太长会拖慢用户请求;超时太短会误伤正常抖动。最终要根据下游 P95/P99 和业务 SLA 设定。
解决思路:
- 不要在同一个小线程池里同步等待子任务。
- 拆分不同线程池。
- 使用异步编排,避免阻塞等待。
- 设置超时时间。
坑四:ThreadLocal 不清理
线程池线程会复用。如果任务里设置了 ThreadLocal 不清理,下一个任务可能读到上一个任务的数据。
private static final ThreadLocal<String> USER = new ThreadLocal<String>();
pool.execute(() -> {
try {
USER.set("user-1");
System.out.println(USER.get());
} finally {
USER.remove();
}
});一定要在 finally 中 remove()。
坑五:所有业务共用一个线程池
flowchart TD
A["公共线程池"] --> B["订单查询"]
A --> C["支付查询"]
A --> D["物流查询"]
A --> E["日志写入"]
C --> F["支付接口慢"]
F --> G["占满公共线程池"]
G --> H["其他业务一起变慢"]正确做法是按业务隔离线程池。核心业务、慢下游、日志、报表、MQ 消费最好不要混在一个池子里。
面试标准回答
问题:线程池有哪些常见坑?
标准回答:
常见坑包括使用 Executors 导致无界队列或线程数失控,submit 后不调用 Future.get() 导致异常被隐藏,线程池任务互相等待造成线程饥饿死锁,ThreadLocal 在线程池中不清理导致内存泄漏或串数据,以及所有业务共用一个线程池导致故障扩散。生产中应使用有界队列、自定义线程名、明确拒绝策略、配置监控告警,并按业务隔离线程池。
本章小结
线程池的难点不只是创建,而是生命周期和故障边界。关闭要优雅,异常要能发现,队列要有界,ThreadLocal 要清理,业务要隔离,阻塞等待要谨慎。
