Skip to content

线程池关闭与常见坑

线程池不是创建完就不用管。关闭方式、异常处理、任务依赖、ThreadLocal 清理都会影响线上稳定性。

shutdown 和 shutdownNow

mermaid
flowchart TD
    A["关闭线程池"] --> B{"调用 shutdown"}
    A --> C{"调用 shutdownNow"}
    B --> D["不再接收新任务"]
    D --> E["继续执行已提交任务"]
    E --> F["任务完成后终止"]
    C --> G["不再接收新任务"]
    G --> H["尝试中断正在执行任务"]
    H --> I["返回队列中未执行任务"]
方法行为适合场景
shutdown()平滑关闭,已提交任务继续执行应用正常停止
shutdownNow()尝试中断任务,返回未执行任务紧急停止
awaitTermination()等待线程池终止优雅停机

优雅关闭 Demo

java
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(),你可能看不到任务失败。

java
Future<?> future = pool.submit(() -> {
    throw new RuntimeException("导出失败");
});

future.get(); // 这里才能拿到 ExecutionException

商业后果:异步入库、消息发送、报表导出失败了,但业务以为已经提交成功。

坑二:无界队列导致 OOM

java
ExecutorService pool = Executors.newFixedThreadPool(10);

这行代码背后是固定线程数加近似无界队列。任务提交速度长期大于处理速度时,队列会越来越大,最后可能 OOM。

正确方向:

java
new ThreadPoolExecutor(
        10,
        20,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<Runnable>(500),
        new ThreadPoolExecutor.CallerRunsPolicy()
);

坑三:线程饥饿死锁

线程池里的任务等待同一个线程池里的其他任务,可能导致所有线程都在等待,没有线程执行被等待的任务。

mermaid
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 在队列中没人执行"]

错误示例:

java
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
T3outer 内部提交 inner 到同一个线程池active = 1,queue = 1
T4outer 调用 inner.get() 等结果唯一 Worker 被阻塞
T5inner 一直在队列中没有空闲 Worker 执行
T6outer 等不到 inner整个程序卡住
mermaid
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_WAITINGFutureTask.getCompletableFuture.join
根因持锁顺序互相等待线程池容量被等待任务占满
解决方向统一加锁顺序、缩小锁范围拆线程池、异步编排、超时、避免池内同步等待

线程饥饿死锁的生产场景

它常出现在接口聚合、批处理、报表导出和 MQ 消费中。

场景一:接口聚合里嵌套提交

java
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。如果高峰期外层任务占满线程,内层任务只能排队,外层又在等内层,最终卡死或大面积超时。

场景二:批处理任务里等待子任务

java
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

java
CompletableFuture<String> a = CompletableFuture.supplyAsync(this::queryA, pool);
String aValue = a.join(); // 过早等待,后续任务还没提交
CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> queryB(aValue), pool);

这种写法不一定形成死锁,但会把异步退化成串行。更糟的是,如果 queryA 内部又提交任务到同一池并等待,就可能和线程饥饿死锁叠加。

线程饥饿死锁怎么排查

线上表现通常是:

  1. 接口大量超时,但 CPU 不高。
  2. 线程池 active 接近最大值。
  3. 队列长度不下降。
  4. 拒绝次数可能没有明显增加,因为任务还在排队。
  5. jstack 看到大量线程卡在 FutureTask.get()CompletableFuture.join()CountDownLatch.await()

排查流程:

mermaid
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["拆池、异步编排、加超时、减少嵌套等待"]

典型线程栈关键词:

text
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,要结合上层栈帧判断到底在等什么。

正确改法一:不要在池内同步等待同池子任务

把父任务和子任务拆到不同线程池:

java
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 编排,最后统一等待

错误思路是“提交一个等一个”。更好的方式是“先把能并行的都发出去,再统一等待”。

java
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() 是线上事故放大器。

java
Future<String> future = pool.submit(this::queryRemote);
String result = future.get(500, TimeUnit.MILLISECONDS);

超时后要有策略:

场景策略
非核心展示信息返回默认值或降级文案
核心依赖快速失败,返回明确错误
可补偿任务记录补偿任务,异步重试
批处理标记该批失败,不阻塞全部批次

没有超时会导致线程长期占用;超时太长会拖慢用户请求;超时太短会误伤正常抖动。最终要根据下游 P95/P99 和业务 SLA 设定。

解决思路:

  1. 不要在同一个小线程池里同步等待子任务。
  2. 拆分不同线程池。
  3. 使用异步编排,避免阻塞等待。
  4. 设置超时时间。

坑四:ThreadLocal 不清理

线程池线程会复用。如果任务里设置了 ThreadLocal 不清理,下一个任务可能读到上一个任务的数据。

java
private static final ThreadLocal<String> USER = new ThreadLocal<String>();

pool.execute(() -> {
    try {
        USER.set("user-1");
        System.out.println(USER.get());
    } finally {
        USER.remove();
    }
});

一定要在 finallyremove()

坑五:所有业务共用一个线程池

mermaid
flowchart TD
    A["公共线程池"] --> B["订单查询"]
    A --> C["支付查询"]
    A --> D["物流查询"]
    A --> E["日志写入"]
    C --> F["支付接口慢"]
    F --> G["占满公共线程池"]
    G --> H["其他业务一起变慢"]

正确做法是按业务隔离线程池。核心业务、慢下游、日志、报表、MQ 消费最好不要混在一个池子里。

面试标准回答

问题:线程池有哪些常见坑?

标准回答:

常见坑包括使用 Executors 导致无界队列或线程数失控,submit 后不调用 Future.get() 导致异常被隐藏,线程池任务互相等待造成线程饥饿死锁,ThreadLocal 在线程池中不清理导致内存泄漏或串数据,以及所有业务共用一个线程池导致故障扩散。生产中应使用有界队列、自定义线程名、明确拒绝策略、配置监控告警,并按业务隔离线程池。

本章小结

线程池的难点不只是创建,而是生命周期和故障边界。关闭要优雅,异常要能发现,队列要有界,ThreadLocal 要清理,业务要隔离,阻塞等待要谨慎。