背景与问题
线上接口“突然变慢”这件事,很多时候不是算法写得多差,而是并发模型出了问题。
我踩过一个很典型的坑:为了提升接口吞吐,把原本同步处理的一段逻辑丢进线程池异步执行。压测初期看起来还不错,请求线程很快返回,接口 RT 一度下降。但过了十几分钟,问题开始冒头:
- 平均响应时间越来越高
- P99 飙升明显
- JVM 堆内存持续上涨
- Full GC 次数变多
- 机器 CPU 不一定高,但服务就是“越来越卡”
最后排查下来,根因不是业务逻辑本身,而是线程池被错误使用:
- 使用了无界队列,任务疯狂堆积
- 请求链路里把大量 I/O 操作塞进同一个线程池
- 线程池参数与机器资源、任务类型完全不匹配
Future.get()用法不当,异步写成了“假异步真阻塞”- 缺少监控,线程池爆了之后很晚才发现
这篇文章我就按一次真实 troubleshooting 的思路,带你从现象复现 -> 定位路径 -> 止血方案 -> 根因修复走一遍。
现象复现
先说一个常见错误写法。很多项目里能看到类似代码:
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 典型误用:固定线程数 + 无界队列
private static final ExecutorService EXECUTOR =
new ThreadPoolExecutor(
8,
8,
0L,
TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>()
);
public static String handleRequest(int requestId) throws Exception {
Future<String> future = EXECUTOR.submit(() -> {
// 模拟下游调用耗时
Thread.sleep(200);
return "ok-" + requestId;
});
// 看似异步,实际上当前请求线程仍在阻塞等待
return future.get();
}
public static void main(String[] args) throws Exception {
long begin = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
final int requestId = i;
EXECUTOR.submit(() -> {
try {
String result = handleRequest(requestId);
if (requestId % 1000 == 0) {
System.out.println("response = " + result);
}
} catch (Exception e) {
e.printStackTrace();
}
});
}
System.out.println("submitted in " + (System.currentTimeMillis() - begin) + " ms");
}
}
这个例子的问题很集中:
- 外层线程池处理请求
- 内层又往同一个线程池提交任务
LinkedBlockingQueue默认近似无界- 请求线程还在
future.get()阻塞等待
当并发量上来后,队列会持续堆任务,任务对象、上下文、参数对象都留在内存里,堆自然上涨。与此同时,线程数固定,真正干活的线程只有那几个,队列越堆越长,响应时间越来越差。
定位路径
排查这类问题,我一般会按下面这条路径走,而不是上来就改线程池参数。
1. 先看现象是不是“排队”而不是“计算慢”
几个高频信号:
- CPU 并不高,但 RT 高
- GC 压力越来越大
jstack看线程,业务线程很多在等FutureTask- 堆 dump 看对象,
Runnable/FutureTask/ 业务请求对象堆积明显
下面这个图基本能描述现场:
flowchart TD
A[请求进入接口] --> B[提交线程池]
B --> C{线程池线程是否空闲}
C -- 是 --> D[执行任务]
C -- 否 --> E[任务进入队列]
E --> F[队列持续堆积]
F --> G[请求等待时间变长]
F --> H[任务对象占用堆内存]
H --> I[GC频繁/内存飙升]
G --> J[接口响应变慢]
2. 用 jstack 看线程状态
如果线程池误用,通常能看到:
- 大量线程在
WAITING/TIMED_WAITING - 某些请求线程阻塞在
Future.get() - 真正运行中的工作线程数量很有限
常见堆栈特征类似:
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:194)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:447)
at java.util.concurrent.FutureTask.get(FutureTask.java:190)
这个时候要警觉:你以为自己在做异步,其实只是把阻塞换了个地方。
3. 看线程池实时指标
如果你用的是 ThreadPoolExecutor,下面几个指标非常关键:
getPoolSize()getActiveCount()getQueue().size()getCompletedTaskCount()getTaskCount()
一个很典型的异常态势是:
activeCount长时间贴近最大线程数queue.size持续增长completedTaskCount增长缓慢
这说明系统进入了持续积压状态,而不是短暂抖动。
4. 堆内存分析
如果拿到 heap dump,你常常会发现:
LinkedBlockingQueue$Node数量惊人FutureTask、业务 DTO、请求上下文链条被队列引用- 一些大对象因为任务未执行完,迟迟无法释放
线程池队列本质上也是内存容器。无界队列一旦遇到生产速度 > 消费速度,就只是时间问题。
核心原理
要修好这个坑,必须先理解线程池几个关键机制。
1. 线程池不是“越多线程越快”
线程池的本质是:
- 限制并发
- 复用线程
- 平衡吞吐与资源消耗
如果任务主要是 CPU 密集型,线程数接近 CPU 核数通常更合适。
如果任务主要是 I/O 密集型,可以适当放大,但也不能无限放。
2. corePoolSize、maximumPoolSize、队列三者联动
ThreadPoolExecutor 的处理流程可以简化成:
flowchart LR
A[新任务到达] --> B{工作线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列未满?}
D -- 是 --> E[任务入队]
D -- 否 --> F{工作线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
这里最容易误解的一点是:
如果你用了无界队列,那么队列几乎永远“未满”,
maximumPoolSize基本没机会发挥作用。
所以很多人把 maximumPoolSize 配成 100、200,觉得自己并发能力很强,但实际运行时可能永远只有 corePoolSize 那几个线程在干活,剩下的任务都在排队。
3. 无界队列会把“并发问题”变成“内存问题”
无界队列不是不能用,但前提是你明确知道:
- 任务提交速率相对稳定
- 单个任务占用内存很小
- 不会在流量高峰时持续堆积
- 有足够监控和降级策略
否则就会变成:
- 线程干不过来
- 队列越堆越长
- 堆内存越来越大
- GC 越来越频繁
- 最后整个服务雪崩
4. “假异步”的两个典型形态
形态一:提交后立即 get()
Future<Result> future = executor.submit(() -> doRemoteCall());
Result result = future.get();
这只是把执行逻辑切到线程池,但当前线程还在等结果。
如果你的目标是提升接口吞吐,这种写法往往没达到目的。
形态二:同池嵌套提交
请求线程本身就在某个线程池中运行,又往同一个线程池提交子任务,然后等待其结果。这很容易导致“线程互等”或严重排队。
sequenceDiagram
participant Client as 客户端
participant Web as 请求线程
participant Pool as 线程池
participant Worker as 工作线程
Client->>Web: 发起请求
Web->>Pool: submit 子任务
Web->>Pool: future.get() 等待结果
Pool->>Worker: 调度执行
Note over Pool: 当线程数不足且队列积压时
Note over Web: 请求线程持续等待
Worker-->>Web: 返回结果
Web-->>Client: 响应变慢
实战代码(可运行)
下面给出一个更合理的修复版本。目标不是“绝对最优”,而是能解决大多数线上线程池误用问题。
修复思路
- 使用有界队列
- 自定义线程工厂,方便排查
- 配置合理的拒绝策略
- 区分 I/O 线程池和计算线程池
- 避免提交后立刻阻塞等待
- 给线程池加指标输出
示例代码
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodThreadPoolDemo {
// 模拟 I/O 密集型线程池:线程数可适当放大,但必须有界
private static final ThreadPoolExecutor IO_POOL =
new ThreadPoolExecutor(
16,
32,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("io-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static void main(String[] args) throws Exception {
// 周期性打印线程池状态
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(
new NamedThreadFactory("monitor")
);
monitor.scheduleAtFixedRate(() -> printStats(IO_POOL), 0, 2, TimeUnit.SECONDS);
int totalRequests = 300;
CountDownLatch latch = new CountDownLatch(totalRequests);
long begin = System.currentTimeMillis();
for (int i = 0; i < totalRequests; i++) {
final int requestId = i;
try {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
return callRemoteService(requestId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted-" + requestId;
}
}, IO_POOL)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "fallback-" + requestId);
future.thenAccept(result -> {
if (requestId % 50 == 0) {
System.out.println("requestId=" + requestId + ", result=" + result);
}
latch.countDown();
});
} catch (RejectedExecutionException ex) {
// 明确处理拒绝,而不是默默丢请求
System.out.println("request rejected, requestId=" + requestId);
latch.countDown();
}
}
latch.await();
long cost = System.currentTimeMillis() - begin;
System.out.println("all done, cost = " + cost + " ms");
monitor.shutdown();
IO_POOL.shutdown();
}
private static String callRemoteService(int requestId) throws InterruptedException {
// 模拟大多数请求 100ms,少数慢请求 800ms
if (requestId % 20 == 0) {
Thread.sleep(800);
} else {
Thread.sleep(100);
}
return "ok-" + requestId;
}
private static void printStats(ThreadPoolExecutor pool) {
System.out.println("[pool-stats] " +
"poolSize=" + pool.getPoolSize() +
", active=" + pool.getActiveCount() +
", queue=" + pool.getQueue().size() +
", taskCount=" + pool.getTaskCount() +
", completed=" + pool.getCompletedTaskCount()
);
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger counter = new AtomicInteger(1);
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + "-" + counter.getAndIncrement());
t.setDaemon(false);
return t;
}
}
}
这个版本有几个关键点:
ArrayBlockingQueue<>(200):避免无限堆积CallerRunsPolicy:让调用方承担一部分回压,减缓提交速度orTimeout(...):慢任务及时超时,避免长期占用线程exceptionally(...):提供兜底结果,避免请求链路全挂- 监控线程池状态:便于观察积压趋势
止血方案
线上已经出问题时,优先考虑“先稳住”,再做结构性修复。
可立即执行的止血动作
1. 限流
如果线程池已经处于持续积压状态,先限流比盲目扩线程更有效。
因为如果下游本来就慢,扩线程只会把更多请求压进去,结果是:
- 下游更慢
- 本服务内存更高
- 整体雪崩更快
2. 缩短超时时间
对于外部依赖调用:
- 连接超时
- 读超时
- Future 超时
都应该明确设置。
没有超时,就等于允许坏请求长期占坑。
3. 临时下调异步化范围
如果某段异步逻辑没有真正提升吞吐,反而引入额外排队,那就先改回同步,或者只保留核心异步任务。
4. 清理“大对象任务”
有些任务会把完整请求报文、图片、长字符串、查询结果集直接带进线程池。
这类任务一旦积压,对内存特别不友好。可以优先改成:
- 只传 ID
- 到任务内部再查所需数据
- 尽量缩短对象引用链
常见坑与排查
坑 1:直接用 Executors.newFixedThreadPool()
很多人觉得这个 API 很方便,但它底层默认是无界队列:
ExecutorService pool = Executors.newFixedThreadPool(10);
风险点不在“10 个线程”,而在“无界排队”。
建议:线上服务尽量显式使用 ThreadPoolExecutor,把队列、线程数、拒绝策略写清楚。
坑 2:多个业务共用一个线程池
日志落库、短信通知、远程调用、报表计算全塞进同一个池子,平时看不出问题,一到高峰互相拖垮。
建议:
- CPU 密集型任务单独池
- I/O 密集型任务单独池
- 高优先级业务与低优先级业务隔离
坑 3:拒绝策略没处理
很多项目用了默认拒绝策略 AbortPolicy,高峰期一打满就抛异常。
更糟的是,有些地方把异常吞了,最后表现成“偶发丢数据”。
new ThreadPoolExecutor.AbortPolicy()
建议:根据业务选择:
CallerRunsPolicy:适合需要回压的场景- 自定义拒绝策略:记录日志、打监控、做降级
- 不要静默吞掉拒绝异常
坑 4:任务里有 ThreadLocal 大对象
线程池线程会复用,如果 ThreadLocal 没清理,容易造成隐性内存泄漏。
private static final ThreadLocal<byte[]> LOCAL = new ThreadLocal<>();
如果任务中设置了大对象但没 remove(),长期运行后很容易出问题。
建议:
try {
// use ThreadLocal
} finally {
LOCAL.remove();
}
坑 5:把数据库连接池问题误判为线程池问题
有时接口变慢确实和线程池有关,但也可能是:
- 数据库连接池耗尽
- 下游 HTTP 连接池打满
- 锁竞争严重
线程池积压只是“结果”,不是根因。
所以排查时最好串起来看:
- 线程池队列
- 数据库连接池活跃数
- HTTP 客户端连接池
- 下游依赖 RT
- GC 情况
安全/性能最佳实践
这里给一套比较实用的落地建议,不追求教科书式“标准答案”,但很适合中级开发日常用。
1. 线程池参数按任务类型设计
一个常见经验值:
- CPU 密集型:线程数接近 CPU 核数
- I/O 密集型:线程数可适当放大,但要压测验证
不要拿“别人配了 200 个线程”直接照抄。
你的机器核数、内存、业务耗时、下游容量都不一样。
2. 队列必须有界
有界不是为了让你更容易报错,而是为了让系统更早暴露压力,形成回压,而不是悄悄堆到 OOM。
建议优先考虑:
ArrayBlockingQueue- 有明确容量的
LinkedBlockingQueue
边界条件:
- 如果任务执行时间波动特别大,队列容量要结合峰值流量压测
- 容量过小会频繁拒绝,过大则会拖长排队时间并吃内存
3. 监控先于优化
没有指标,线程池问题只能靠猜。
最少要监控:
- 当前线程数
- 活跃线程数
- 队列长度
- 已完成任务数
- 拒绝次数
- 任务执行耗时
- 任务排队耗时
可以把这些指标上报到 Micrometer、Prometheus 或公司内部监控系统。
4. 超时、熔断、降级要配套
线程池不是万能缓冲层。
如果下游持续变慢,线程池只会把问题放大。
建议组合使用:
- 调用超时
- 熔断
- 限流
- 默认值降级
- 隔离舱壁模式
5. 不要在请求主链路里滥用异步
一个经验判断:
如果你提交异步任务后马上就得等结果,那大概率不值得异步化。
适合异步的通常是:
- 非核心结果
- 可延迟处理
- 可失败重试
- 与主链路解耦的任务
比如:
- 审计日志
- 消息通知
- 埋点上报
而不是“主查询结果的一部分却必须同步返回”。
6. 明确线程命名,便于 dump 排查
线程名不是小事。
线上 jstack 一看全是 pool-1-thread-3、pool-2-thread-8,定位效率会很差。
建议命名规范:
order-io-pool-*user-query-pool-*audit-async-pool-*
7. 用压测验证“排队时间”
很多团队只看接口 RT,却不看任务在队列里等了多久。
其实线程池问题里,真正致命的往往是排队时间远大于执行时间。
可以把任务包装一下,记录:
- 提交时间
- 开始执行时间
- 执行结束时间
从而区分:
- 是排队慢
- 还是执行慢
下面这个状态图很适合理解任务生命周期:
stateDiagram-v2
[*] --> Submitted
Submitted --> Queued
Queued --> Running
Running --> Success
Running --> Timeout
Running --> Failed
Queued --> Rejected
Success --> [*]
Timeout --> [*]
Failed --> [*]
Rejected --> [*]
一个简单的排查清单
如果你明天值班遇到“接口慢 + 内存涨”,可以按这份清单快速过一遍:
-
看监控
- RT、QPS、错误率
- 堆内存、GC、CPU
- 线程池 active / queue / reject
-
看线程 dump
- 是否大量阻塞在
Future.get() - 是否存在同池嵌套提交
- 工作线程是否长期满载
- 是否大量阻塞在
-
看下游依赖
- 数据库连接池
- HTTP 调用超时
- Redis/消息队列是否异常
-
看代码实现
- 是否使用无界队列
- 是否
Executors.newFixedThreadPool - 是否把大对象塞进任务
- 是否使用
ThreadLocal未清理
-
先止血
- 限流
- 降级
- 缩短超时
- 关闭非关键异步任务
-
再修复
- 队列改有界
- 线程池隔离
- 增加监控
- 重构假异步
总结
这类问题最容易误导人的地方在于:表面看是“接口变慢”和“内存飙升”,但根因往往不是单点性能差,而是线程池把流量压力以排队的方式藏起来了。
你可以记住这几个核心结论:
- 无界队列很危险,尤其在高并发接口里
maximumPoolSize不一定真的生效,队列策略比你想象中更关键- 提交后立刻
get(),很多时候只是“假异步” - 线程池问题本质上是资源治理问题,不只是参数调优
- 真正有效的修复,通常是有界队列 + 隔离线程池 + 超时降级 + 指标监控
如果你现在项目里还有 Executors.newFixedThreadPool() 跑在线上主链路,我建议第一时间排查一下。平时没事,不代表高峰没事;而线程池这类坑,往往都是在流量上涨、下游变慢、业务最忙的时候一起爆出来。