背景与问题
线程池几乎是 Java 服务端开发的“基础设施”。但也正因为太常用,很多问题会被误判成“偶发抖动”“数据库慢”“GC 抽风”甚至“机器不行”。
我自己排查过几次线上线程池问题,最深的感受是:线程池出问题时,现象往往不在线程池本身,而是表现为接口超时、消息堆积、CPU 飙高、内存上涨、日志刷屏、上下游雪崩。
这篇文章不打算泛泛讲 ExecutorService API,而是聚焦 8 个最容易被忽视、也最容易在线上造成事故的误用场景:
- 使用
Executors默认工厂,导致无界队列/线程数失控 - 线程池大小拍脑袋配置,CPU 型和 IO 型任务混用
- 提交任务后不处理异常,问题被“吃掉”
- 队列积压严重,只盯着活跃线程却忽略等待时间
- 线程池里执行长阻塞任务,导致“假死”
- 线程池嵌套提交并相互等待,引发饥饿死锁
ThreadLocal在线程池复用下产生脏数据/内存泄漏- 关闭方式错误,服务停机慢、任务丢失或无法优雅退出
文章会按“现象复现 → 核心原理 → 排查路径 → 修复方案”来展开,并给出一套可运行示例。
核心原理
先把线程池最关键的工作模型捋顺。很多坑,本质上都来自对这套模型理解不完整。
ThreadPoolExecutor 的执行流程
flowchart TD
A[提交任务 execute/submit] --> B{运行线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{工作队列可入队?}
D -- 是 --> E[任务进入阻塞队列等待]
D -- 否 --> F{运行线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略 RejectedExecutionHandler]
理解上有 4 个最重要的点:
corePoolSize:核心线程数,优先保留maximumPoolSize:线程池允许扩容到的最大线程数workQueue:任务等待队列RejectedExecutionHandler:队列满且线程到上限时的拒绝策略
为什么“最大线程数”经常不生效?
因为是否先入队取决于队列类型。
LinkedBlockingQueue默认可非常大,常见现象是:任务一直排队,maximumPoolSize几乎没机会生效SynchronousQueue不存储任务,倾向于直接扩线程ArrayBlockingQueue有界,行为更可控,适合生产环境做容量治理
这也是为什么很多人明明把 maximumPoolSize 配成 200,结果线上永远只有 20 个线程在跑,剩下的任务都在队列里排队。
线程池问题的典型传播路径
sequenceDiagram
participant Client as 调用方
participant App as 业务服务
participant Pool as 线程池
participant DB as 数据库/远程服务
Client->>App: 请求进入
App->>Pool: 提交异步任务
alt 线程池繁忙
Pool-->>App: 入队等待/拒绝
App-->>Client: 超时/降级/报错
else 线程可执行
Pool->>DB: 发起 IO/查询
DB-->>Pool: 响应变慢
Pool-->>App: 线程占满
App-->>Client: RT 抖动、吞吐下降
end
一句话总结:线程池不是隔离问题的防火墙,配置错了,它会放大问题。
现象复现
先准备一个可运行的示例工程,集中演示几个典型坑位。你可以直接用 JDK 8+ 编译运行。
实战代码(可运行)
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolPitfallDemo {
public static void main(String[] args) throws Exception {
System.out.println("选择要演示的场景:");
System.out.println("1 - 无界队列堆积");
System.out.println("2 - submit 吃掉异常");
System.out.println("3 - 线程池嵌套等待导致卡死");
System.out.println("4 - ThreadLocal 脏数据");
System.out.println("5 - 优雅关闭示例");
int scene = args.length == 0 ? 1 : Integer.parseInt(args[0]);
switch (scene) {
case 1:
unboundedQueueDemo();
break;
case 2:
swallowExceptionDemo();
break;
case 3:
starvationDeadlockDemo();
break;
case 4:
threadLocalLeakDemo();
break;
case 5:
gracefulShutdownDemo();
break;
default:
System.out.println("未知场景");
}
}
// 场景1:无界队列导致堆积
static void unboundedQueueDemo() throws InterruptedException {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2,
8,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(), // 注意:近似无界
namedFactory("unbounded"),
new ThreadPoolExecutor.AbortPolicy()
);
for (int i = 0; i < 10000; i++) {
final int taskId = i;
executor.submit(() -> {
Thread.sleep(1000);
if (taskId % 1000 == 0) {
System.out.println("task " + taskId + " done");
}
return null;
});
}
for (int i = 0; i < 10; i++) {
System.out.printf("poolSize=%d, active=%d, queue=%d, completed=%d%n",
executor.getPoolSize(),
executor.getActiveCount(),
executor.getQueue().size(),
executor.getCompletedTaskCount());
Thread.sleep(1000);
}
executor.shutdownNow();
}
// 场景2:submit 吃掉异常
static void swallowExceptionDemo() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.submit(() -> {
System.out.println("task by submit");
throw new RuntimeException("submit exception");
});
executor.execute(() -> {
System.out.println("task by execute");
throw new RuntimeException("execute exception");
});
Thread.sleep(1000);
executor.shutdown();
}
// 场景3:线程池嵌套提交并等待
static void starvationDeadlockDemo() throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<String> outerTask = () -> {
Future<String> inner = executor.submit(() -> {
Thread.sleep(2000);
return "inner done";
});
return "outer -> " + inner.get();
};
List<Future<String>> list = new ArrayList<>();
list.add(executor.submit(outerTask));
list.add(executor.submit(outerTask));
for (Future<String> future : list) {
System.out.println(future.get());
}
executor.shutdown();
}
// 场景4:ThreadLocal 未清理
static void threadLocalLeakDemo() throws InterruptedException {
ThreadLocal<String> context = new ThreadLocal<>();
ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
namedFactory("tl"),
new ThreadPoolExecutor.AbortPolicy()
);
executor.execute(() -> {
context.set("userA");
System.out.println("task1 set userA");
// 故意不 remove
});
Thread.sleep(500);
executor.execute(() -> {
System.out.println("task2 read context = " + context.get());
context.remove();
});
Thread.sleep(1000);
executor.shutdown();
}
// 场景5:优雅关闭
static void gracefulShutdownDemo() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
namedFactory("graceful"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i < 10; i++) {
final int id = i;
executor.execute(() -> {
try {
System.out.println("running task " + id);
Thread.sleep(1500);
} catch (InterruptedException e) {
System.out.println("task " + id + " interrupted");
Thread.currentThread().interrupt();
}
});
}
shutdownGracefully(executor, 3, TimeUnit.SECONDS);
}
static void shutdownGracefully(ExecutorService pool, long timeout, TimeUnit unit) {
pool.shutdown();
try {
if (!pool.awaitTermination(timeout, unit)) {
List<Runnable> dropped = pool.shutdownNow();
System.out.println("force shutdown, dropped tasks = " + dropped.size());
if (!pool.awaitTermination(timeout, unit)) {
System.err.println("pool did not terminate");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
static ThreadFactory namedFactory(String prefix) {
AtomicInteger counter = new AtomicInteger(1);
return r -> {
Thread t = new Thread(r);
t.setName(prefix + "-" + counter.getAndIncrement());
return t;
};
}
}
8 个最容易被忽视的线程池误用场景与修复方案
1. 直接使用 Executors 默认工厂
典型现象
- 内存慢慢上涨,最后 OOM
- 请求没有报错,但 RT 越来越高
- 线程数异常多,甚至机器 load 飙升
为什么会这样
Executors 提供的几个快捷工厂看起来很方便,但有明显风险:
newFixedThreadPool():使用无界LinkedBlockingQueuenewSingleThreadExecutor():也是无界队列newCachedThreadPool():最大线程数接近无限,使用SynchronousQueue
这两个方向都危险:
- 要么任务无限排队,堆内存被吃掉
- 要么线程无限膨胀,上下文切换把 CPU 打爆
错误示例
ExecutorService pool = Executors.newFixedThreadPool(10);
修复方案
显式使用 ThreadPoolExecutor,把容量边界写清楚。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
排查重点
- 看队列长度是否持续上升
- 看
jstack是否线程不多但请求大量超时 - 看 JVM 堆里是否有大量待执行任务对象
2. 线程池参数拍脑袋,CPU 型和 IO 型任务混用
典型现象
- CPU 打满但吞吐没提升
- 少量慢 SQL/慢 RPC 把整个线程池拖死
- 某个模块高峰期时,另一个模块也跟着超时
核心问题
不同任务模型适合不同线程数:
- CPU 密集型:线程数接近 CPU 核数
- IO 密集型:线程数可以更高,但必须结合外部依赖延迟评估
如果把“本地计算任务”和“远程调用任务”塞进同一个线程池,IO 阻塞会占住工作线程,CPU 任务就没机会执行。
修复方案
按职责拆池,而不是全项目共用一个“大池子”。
ExecutorService cpuPool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors(),
0,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
ExecutorService ioPool = new ThreadPoolExecutor(
16,
64,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
一个实用判断法
如果任务里出现这些操作,就别轻易归类成 CPU 型:
- 数据库查询
- HTTP/RPC 调用
- 文件读写
- Redis 阻塞等待
- 锁竞争明显的临界区
3. 用 submit() 提交任务,却不获取 Future,异常被悄悄吞掉
典型现象
- 明明业务失败了,日志里没有异常
- 任务没有生效,但线程池看起来“正常工作”
- 排查半天发现是异步任务内部抛异常,调用方完全不知道
原理
execute():任务异常通常会直接交给线程的未捕获异常处理器submit():异常会被封装进Future,如果你不get(),它就像没发生过一样
错误示例
executor.submit(() -> {
throw new IllegalStateException("业务失败");
});
修复方案 1:必须消费 Future
Future<?> future = executor.submit(() -> {
throw new IllegalStateException("业务失败");
});
try {
future.get();
} catch (ExecutionException e) {
System.err.println("task failed: " + e.getCause().getMessage());
}
修复方案 2:统一封装异步任务
executor.execute(() -> {
try {
doBiz();
} catch (Exception e) {
// 记录日志、埋点、告警
e.printStackTrace();
}
});
排查重点
- 查是否大量使用
submit但从不get - 查关键异步链路有没有失败计数和异常日志
- 查监控中“成功提交数”是否被误当成“成功执行数”
4. 只看活跃线程数,不看队列等待时间
典型现象
activeCount不高,但接口越来越慢- 线程池“看起来不忙”,用户却一直超时
- 平均 RT 还行,P99 特别差
根因
很多任务不是执行慢,而是排队久。
也就是说:
- 真正的问题可能不是线程跑不动
- 而是任务在队列里等太久才开始执行
修复方案
除了监控线程池大小,还要补这几类指标:
- 队列长度
- 任务排队等待时间
- 任务执行时间
- 拒绝次数
- 完成任务数增速
建议埋点方式
long submitTime = System.nanoTime();
executor.execute(() -> {
long startTime = System.nanoTime();
long waitMs = TimeUnit.NANOSECONDS.toMillis(startTime - submitTime);
try {
// 业务逻辑
} finally {
long costMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime);
System.out.println("waitMs=" + waitMs + ", costMs=" + costMs);
}
});
排查经验
如果排队时间远高于执行时间,优先考虑:
- 池太小
- 队列太大,导致问题被隐藏
- 某些慢任务把通道堵住
5. 在线程池里执行长阻塞任务,造成“假死”
典型现象
- 线程池没挂,但新任务迟迟不执行
- 线程 dump 中大量线程卡在
WAITING/TIMED_WAITING - 上游一个依赖抖动,整个服务吞吐腰斩
常见阻塞来源
- 没超时的 RPC/HTTP 调用
- 长时间锁等待
CountDownLatch.await()无保护等待Future.get()不带超时- 文件/网络 IO 卡住
修复方案
第一原则:任何阻塞等待都要有超时。
Future<String> future = executor.submit(() -> remoteCall());
try {
String result = future.get(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
}
如果是外部依赖调用,建议同时设置:
- 客户端超时
- 线程池隔离
- 限流/熔断
定位路径
jstack看线程状态和栈帧- 找出是否集中卡在某个远程调用、锁、队列等待
- 核对该业务是否与其他任务共用线程池
- 看超时配置是否缺失或过大
6. 线程池内部嵌套提交并等待,触发饥饿死锁
这是我见过最隐蔽的一类问题之一。
典型现象
- 程序没有报死锁,但就是卡住不动
- 线程数不多,CPU 也不高
- 每个线程都“在等别人完成”
复现场景
固定大小线程池,外层任务占满所有工作线程;每个外层任务又往同一个线程池提交内层任务,并 get() 等待。
这时内层任务根本没有线程执行,于是外层任务永远等不到结果。
flowchart LR
A[固定线程池 2 个线程] --> B[外层任务1 占用线程1]
A --> C[外层任务2 占用线程2]
B --> D[提交内层任务1并等待]
C --> E[提交内层任务2并等待]
D -. 无空闲线程执行 .-> A
E -. 无空闲线程执行 .-> A
修复方案
几种可选思路:
方案 1:内外任务拆分线程池
ExecutorService outerPool = Executors.newFixedThreadPool(2);
ExecutorService innerPool = Executors.newFixedThreadPool(4);
方案 2:避免同步等待,改成异步编排
CompletableFuture.supplyAsync(() -> step1(), executor)
.thenApply(result -> step2(result))
.thenAccept(System.out::println);
方案 3:增加线程数不是根治
很多人会说“把线程池调大”。这只能缓解,不是治本。只要并发高起来,依然可能重现。
7. ThreadLocal 在线程池复用下产生脏数据和内存泄漏
典型现象
- A 用户的数据出现在 B 用户日志里
- 链路追踪 traceId 串线
- 老年代对象增多,排查不出明显业务引用
根因
线程池里的线程会复用。ThreadLocal 绑定的是线程,不是任务。
如果任务执行完不清理,下一个任务复用了同一个线程,就可能读到上一个任务遗留的数据。
错误示例
static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();
executor.execute(() -> {
TRACE_ID.set("trace-123");
doBiz();
// 忘记 remove
});
修复方案
一定要放在 finally 里清理。
executor.execute(() -> {
try {
TRACE_ID.set("trace-123");
doBiz();
} finally {
TRACE_ID.remove();
}
});
补充建议
如果你在用:
- 日志 MDC
- 用户上下文
- 租户信息
- 安全认证信息
也都要考虑线程池切换后的传播与清理,不能只 set 不 remove。
8. 线程池关闭方式粗暴,导致任务丢失或停机卡住
典型现象
- 服务发布/重启时卡很久
- 程序退出后还有后台线程不结束
- 一部分任务执行到一半中断,数据状态不一致
常见误用
- 不调用
shutdown() - 直接
shutdownNow() - 捕获
InterruptedException后什么都不做 - 业务代码不响应中断
正确姿势
- 先
shutdown(),拒绝新任务 - 等待一段时间
- 超时后
shutdownNow() - 正确处理
InterruptedException - 对重要任务做好幂等和补偿
推荐模板
public static void shutdownGracefully(ExecutorService pool) {
pool.shutdown();
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
System.err.println("线程池未能正常终止");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
常见坑与排查
这一节我按“线上排障”的顺序给一个更实用的定位路径。
先看现象,不要先改参数
很多人第一反应是:
- 核心线程数加大
- 最大线程数加大
- 队列加大
这很危险。因为如果根因是慢 SQL、下游超时、嵌套等待、锁竞争,盲目扩线程只会把问题放大。
一条比较稳的定位路径
flowchart TD
A[接口超时/消息堆积/吞吐下降] --> B[看线程池监控]
B --> C{队列长度上涨?}
C -- 是 --> D[看任务等待时间/拒绝数]
C -- 否 --> E[看活跃线程/线程状态]
D --> F[判断是池太小还是任务太慢]
E --> G[用 jstack 看是否阻塞/锁等待/嵌套 Future.get]
F --> H[拆池、限流、设置超时、优化慢任务]
G --> H
关键排查项清单
1)线程池运行态指标
重点看:
poolSizeactiveCountqueue.sizecompletedTaskCountlargestPoolSizetaskCountrejectCount(需自行埋点)
2)线程 dump
重点看线程卡在哪:
java.netsun.nioFutureTask.getCountDownLatch.awaitReentrantLock- 数据库驱动调用栈
3)任务类型分布
确认是否存在:
- 一个池服务多个业务域
- 既跑定时任务又跑接口异步任务
- 既跑短任务又跑长任务
4)异常可见性
检查:
- 是否用
submit()提交 - 是否消费
Future - 是否有统一异常日志和告警
5)上下文污染
检查:
ThreadLocal- MDC
- 安全上下文
- 租户上下文
止血方案
线上问题不一定能一步修漂亮,很多时候先止血,再重构。
场景 A:队列爆满,接口大量超时
优先动作:
- 临时限流,避免继续把任务灌入队列
- 打开拒绝监控,确认丢弃规模
- 对非核心异步任务做降级
- 缩小无意义的大队列,避免“慢性窒息”
- 把慢任务从主线程池隔离出去
场景 B:下游依赖变慢,线程全卡住
优先动作:
- 缩短下游超时
- 加熔断/隔离池
- 取消无上限重试
- 排查是否有同步等待链
场景 C:服务停机卡住
优先动作:
- 检查是否存在永不返回的阻塞任务
- 核对业务代码是否处理中断
- 强制退出前记录未完成任务数
- 对关键任务建立补偿机制
安全/性能最佳实践
这里给一份偏生产环境的建议清单,不追求“银弹”,但很实用。
1. 永远显式创建线程池
不要直接依赖 Executors 默认工厂。
推荐做法:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
coreSize,
maxSize,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
namedThreadFactory,
new ThreadPoolExecutor.CallerRunsPolicy()
);
2. 队列一定要有界
无界队列的问题不是“会不会炸”,而是“什么时候炸”。
边界建议:
- 在线接口:队列宁可小一点
- 离线任务:可以稍大,但也要有上限
- 消息消费:要结合消费超时与堆积上限评估
3. 拒绝策略要按业务语义选
AbortPolicy:快速失败,适合核心业务及时暴露问题CallerRunsPolicy:把压力反传给调用线程,适合轻量降速- 自定义策略:记录日志、埋点、做降级补偿
不要只会默认策略。
4. 线程池按业务隔离
至少要区分:
- 接口异步任务池
- IO 调用池
- 定时任务池
- 批处理/离线任务池
隔离的意义不是“优雅”,而是防止相互拖垮。
5. 给每个池起可识别的线程名
这在 jstack 和日志里特别有价值。
Thread t = new Thread(r);
t.setName("order-async-" + counter.getAndIncrement());
6. 每个阻塞操作都要有超时
包括但不限于:
- RPC
- HTTP
- DB
- Redis
Future.get- 锁等待
没有超时的等待,在生产环境里几乎都是隐患。
7. 对线程池做监控,不只看线程数
建议至少接入这些指标:
- 核心线程数/最大线程数
- 当前线程数/活跃线程数
- 队列长度
- 拒绝次数
- 任务等待时间
- 任务执行时间
- 异常数/超时数
8. 关注中断语义
很多线程池关闭失败,根因是业务代码“吃掉中断”。
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
如果捕获了中断却不恢复中断标记,线程可能无法正确退出。
一个相对稳妥的生产级线程池示例
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class SafeThreadPoolFactory {
public static ThreadPoolExecutor newBizPool(String poolName, int core, int max, int queueSize) {
return new ThreadPoolExecutor(
core,
max,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueSize),
new NamedThreadFactory(poolName),
new LogAndCallerRunsPolicy(poolName)
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String poolName;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String poolName) {
this.poolName = poolName;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r);
t.setName(poolName + "-" + counter.getAndIncrement());
t.setUncaughtExceptionHandler((thread, ex) ->
System.err.println("uncaught in " + thread.getName() + ": " + ex.getMessage()));
return t;
}
}
static class LogAndCallerRunsPolicy implements RejectedExecutionHandler {
private final String poolName;
LogAndCallerRunsPolicy(String poolName) {
this.poolName = poolName;
}
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
System.err.printf(
"pool=%s rejected, active=%d, queue=%d, taskCount=%d%n",
poolName,
executor.getActiveCount(),
executor.getQueue().size(),
executor.getTaskCount()
);
if (!executor.isShutdown()) {
r.run();
}
}
}
}
使用方式:
ThreadPoolExecutor orderPool = SafeThreadPoolFactory.newBizPool("order-async", 8, 16, 200);
orderPool.execute(() -> {
try {
// 业务逻辑
} catch (Exception e) {
e.printStackTrace();
}
});
总结
线程池的问题,难点从来不在 API,而在边界、隔离、可观测性、超时控制。
最后把本文的 8 个坑压缩成一句话版,方便你做代码审查时快速过一遍:
- 别用默认
Executors工厂,风险边界不清 - 别混跑不同类型任务,CPU/IO 要拆池
- 别只 submit 不看 Future,异常会丢
- 别只盯活跃线程,队列等待时间更关键
- 别让阻塞任务无限等待,必须有超时
- 别在线程池里嵌套同池等待,容易饥饿死锁
- 别忘记清理 ThreadLocal,线程复用会串数据
- 别粗暴关闭线程池,要优雅停机并处理中断
如果你现在就要落地,我建议先做这 4 件事,收益通常最大:
- 把所有线程池从
Executors改成显式ThreadPoolExecutor - 给每个线程池补上监控:队列长度、等待时间、拒绝次数
- 给所有阻塞调用补超时
- 按业务域拆线程池,避免互相拖垮
线程池调优不是“把数字调大”,而是让系统在高峰、异常、依赖抖动时,仍然能可控地退化。做到这一点,你的并发系统才算真的稳。