背景与问题
有一次我接手一个“偶发超时”的接口问题,最开始大家都怀疑是数据库慢、下游服务抖动,甚至有人开始翻 Nginx 超时配置。但排查一圈后发现,真正的问题根本不在外部依赖,而是在业务代码里对线程池的误用。
现象很典型:
- 接口平均响应时间本来在
100ms ~ 200ms - 高峰期突然飙到
3s ~ 10s - 应用堆内存持续上涨,Full GC 变频繁
- CPU 不一定打满,但服务吞吐明显下降
- 最后甚至出现:
- 请求堆积
- 线程池队列暴涨
- OOM 或被 Kubernetes 重启
这类问题最坑的地方在于:表面上看像慢请求,实际上是异步任务堆积导致的资源挤兑。
一个常见的错误写法
很多项目里都能看到类似代码:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class BadExecutorHolder {
// 看起来很省事,但风险很大
public static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(20);
}
然后在接口里这么用:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class OrderService {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(20);
public String queryOrder() {
for (int i = 0; i < 1000; i++) {
EXECUTOR.submit(() -> {
// 模拟耗时操作,比如远程调用、复杂计算、日志落库
doSlowTask();
});
}
return "ok";
}
private void doSlowTask() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
问题在哪?
Executors.newFixedThreadPool(20) 的底层使用了无界队列 LinkedBlockingQueue。
线程数固定为 20,但任务提交速度远大于处理速度时,多出来的任务不会被拒绝,而是无限堆积在队列里。
这就会带来两个直接后果:
- 接口超时:任务排队太久,业务流程迟迟得不到结果
- 内存飙升:队列里堆满
Runnable、上下文对象、参数引用,堆占用越来越高
现象复现
先别急着讲理论,我更喜欢先把坑复现出来。下面给一个可运行示例。
错误示例:无界队列导致任务堆积
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class WrongThreadPoolDemo {
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
private static final AtomicInteger COUNTER = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
while (true) {
for (int i = 0; i < 500; i++) {
final int taskId = COUNTER.incrementAndGet();
EXECUTOR.submit(() -> {
try {
// 模拟慢任务
Thread.sleep(1000);
if (taskId % 1000 == 0) {
System.out.println("done taskId=" + taskId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("submitted total tasks=" + COUNTER.get());
Thread.sleep(200);
}
}
}
如果你给这个程序一个较小堆,比如:
java -Xms256m -Xmx256m WrongThreadPoolDemo
运行一段时间后,通常会看到:
- 提交速度远大于消费速度
- 堆内存不断增加
- GC 越来越频繁
- 最终可能 OOM
问题演化流程
flowchart TD
A[接口请求到来] --> B[提交大量异步任务到线程池]
B --> C{线程池线程是否空闲}
C -- 是 --> D[立即执行]
C -- 否 --> E[进入无界队列等待]
E --> F[队列持续堆积]
F --> G[堆内存占用上升]
G --> H[GC频繁]
H --> I[接口响应变慢/超时]
I --> J[更多重试或请求堆积]
J --> F
这个闭环一旦形成,就会进入恶性循环。
核心原理
要修这个问题,必须先真正理解 ThreadPoolExecutor 的行为,而不是只记住“不要用 Executors”。
ThreadPoolExecutor 的关键参数
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
核心点是这几个参数的配合关系:
corePoolSize:核心线程数maximumPoolSize:最大线程数workQueue:任务队列RejectedExecutionHandler:队列满、线程也到上限后的拒绝策略
提交任务时的执行顺序
线程池不是一上来就把线程开到最大,它有一套明确规则:
- 线程数 <
corePoolSize:优先创建新线程 - 否则先尝试放入队列
- 如果队列满了,且线程数 <
maximumPoolSize:继续创建线程 - 如果队列也满、线程也满:触发拒绝策略
这意味着:
- 无界队列下,队列几乎永远不会满
- 所以线程池通常只会维持在
corePoolSize maximumPoolSize形同虚设
这正是很多人误解的地方:
“我明明设置了最大线程数 200,为什么高峰时还是只有 20 个线程在跑?”
答案是:因为你配了无界队列。
线程池执行机制示意
flowchart LR
A[submit任务] --> B{worker < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{队列能放下?}
D -- 是 --> E[任务入队等待]
D -- 否 --> F{worker < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
为什么会引发内存飙升
队列里堆积的并不只是一个个“轻量任务”。
很多时候任务对象会持有:
- 请求参数
- 用户上下文
- 大对象引用
- DTO 列表
- 远程调用结果缓存
- 日志上下文
MDC
如果一次接口提交 200 个任务,每个任务又引用几十 KB 的对象,积压几万条后,堆内存很快就顶不住了。
为什么接口会超时
常见有两种场景:
场景一:接口等待异步结果
例如:
future.get(2, TimeUnit.SECONDS);
任务虽然已经 submit 了,但实际上排在队列里没执行到,get() 等不到结果,自然超时。
场景二:异步任务拖垮整体应用
即使接口本身不等待结果,大量堆积也会带来:
- GC 变慢
- CPU 被上下文切换和 GC 吃掉
- 下游连接池被打满
- 日志 I/O 变重
最后“看起来每个接口都慢了”。
定位路径
我通常会按“从外到内”的顺序排查,避免一上来就盲猜。
1. 先看监控现象
优先关注这些指标:
- 接口 RT、TP99、超时率
- JVM 堆使用率
- Young GC / Full GC 次数与耗时
- 线程数
- CPU 使用率
- 下游依赖耗时
- 线程池活跃线程数、队列长度、拒绝次数
如果你们没有线程池监控,这是第一个要补的洞。
2. 看线程池是否异常堆积
如果是 Spring 项目,很多线程池会有名字。可以结合日志或监控看:
activeCountpoolSizequeueSize
一个典型异常特征是:
activeCount不高,比如一直 20queueSize却持续上涨到几千、几万
这基本就能说明:处理不过来,而且没有背压。
3. 抓线程栈
使用:
jstack <pid>
常能看到:
- 大量业务线程在等待
Future.get() - 线程池 worker 正在执行慢任务
- 某些请求线程卡在同步等待异步结果的地方
4. 看堆直方图
使用:
jmap -histo <pid>
重点关注:
java.util.concurrent.FutureTaskjava.util.concurrent.LinkedBlockingQueue$Node- 各种业务
Runnable/Callable - 大量 DTO/上下文对象
如果这些对象数量异常多,基本就坐实了“任务堆积导致内存上涨”。
5. 排查代码入口
最后回到代码,找:
- 接口里是否批量 submit
- 是否使用
Executors.newFixedThreadPool/newSingleThreadExecutor - 是否同步等待异步结果
- 是否缺失超时控制
- 是否没有拒绝策略兜底
一次典型定位时序
sequenceDiagram
participant U as 用户请求
participant API as 接口线程
participant TP as 线程池
participant Q as 队列
participant JVM as JVM/GC
U->>API: 发起请求
API->>TP: submit多个任务
TP->>Q: 空闲线程不足,任务入队
Q-->>TP: 队列持续变长
API->>TP: future.get等待结果
JVM-->>API: 堆内存上涨,GC变频繁
TP-->>API: 任务执行延迟
API-->>U: 超时/响应变慢
实战代码(可运行)
下面给一个“错误写法”到“修复写法”的完整对比。
错误版本
问题点:
- 使用
Executors.newFixedThreadPool - 无界队列
- 没有超时控制
- 没有拒绝策略
- 接口线程同步等待所有异步结果
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadApiService {
private final ExecutorService executor = Executors.newFixedThreadPool(10);
public List<String> queryBatch(List<String> ids) {
List<Future<String>> futures = new ArrayList<>();
for (String id : ids) {
futures.add(executor.submit(() -> {
Thread.sleep(500);
return "result-" + id;
}));
}
List<String> result = new ArrayList<>();
for (Future<String> future : futures) {
try {
result.add(future.get()); // 无超时
} catch (Exception e) {
throw new RuntimeException(e);
}
}
return result;
}
}
修复版本
修复目标:
- 显式创建
ThreadPoolExecutor - 使用有界队列
- 自定义线程名,便于定位
- 使用合理拒绝策略
- 对等待结果设置超时
- 对异常做降级处理
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class GoodApiService {
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
10,
20,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("order-worker"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public List<String> queryBatch(List<String> ids) {
List<Future<String>> futures = new ArrayList<>();
for (String id : ids) {
try {
futures.add(executor.submit(() -> simulateRemoteCall(id)));
} catch (RejectedExecutionException e) {
futures.add(CompletableFuture.completedFuture("fallback-" + id));
}
}
List<String> result = new ArrayList<>();
for (Future<String> future : futures) {
try {
result.add(future.get(800, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
result.add("timeout-fallback");
} catch (Exception e) {
result.add("error-fallback");
}
}
return result;
}
private String simulateRemoteCall(String id) throws InterruptedException {
Thread.sleep(300);
return "result-" + id;
}
public void printStats() {
System.out.println("poolSize=" + executor.getPoolSize()
+ ", active=" + executor.getActiveCount()
+ ", queue=" + executor.getQueue().size()
+ ", completed=" + executor.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);
t.setName(prefix + "-" + counter.getAndIncrement());
return t;
}
}
public static void main(String[] args) {
GoodApiService service = new GoodApiService();
List<String> ids = new ArrayList<>();
for (int i = 0; i < 100; i++) {
ids.add(String.valueOf(i));
}
List<String> result = service.queryBatch(ids);
service.printStats();
System.out.println("result size=" + result.size());
service.executor.shutdown();
}
}
这个修复为什么有效
这版代码做了几件关键的事:
- 有界队列:避免任务无限堆积
- 最大线程数生效:队列满后可以扩容到
maximumPoolSize - 拒绝策略兜底:至少不会悄悄把内存吃爆
- 超时控制:避免接口一直傻等
- 降级返回:保障接口可用性而不是硬超时
常见坑与排查
这部分我单独列一下,因为很多问题不是“不会配线程池”,而是“以为自己配对了”。
坑 1:以为 fixedThreadPool 很稳
newFixedThreadPool(n) 只是在线程数固定这件事上稳,不代表系统整体稳。
它最大的隐藏问题是:
- 无界队列
- 高峰无背压
- 内存风险大
如果任务来源不可控,风险尤其高。
坑 2:maximumPoolSize 配了但没用
很多人会写:
new ThreadPoolExecutor(
20, 200, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>()
)
表面看最大线程数是 200,实际上因为 LinkedBlockingQueue 默认近似无界,任务会优先入队,线程池通常不会扩到 200。
这是非常常见的误区。
坑 3:接口线程里“异步转同步”
最常见的伪异步写法就是:
Future<Result> future = executor.submit(task);
return future.get();
如果当前线程马上就 get(),那本质上还是同步等待,只不过多绕了一圈线程池,还额外增加了:
- 线程切换
- 队列等待
- 超时风险
这种写法只有在“并发聚合多个任务”时才有意义,否则往往是负优化。
坑 4:任务里塞大对象
例如:
- 把完整请求对象传进
Runnable - 闭包捕获大 List
- 把上下文全量复制到异步任务
这会放大队列堆积带来的内存问题。
建议只传任务执行所需的最小字段。
坑 5:线程池没有隔离
把这些任务全扔进一个线程池:
- 查询接口
- 导出报表
- 异步通知
- 日志补偿
- 第三方回调
结果就是一个慢任务类型拖死所有业务。
更合理的方式是按场景隔离:
- IO 密集型池
- CPU 密集型池
- 核心接口池
- 非核心降级池
坑 6:没有监控拒绝次数
很多团队修完有界队列后,又出现另一个问题:任务被拒绝,但没人知道。
如果没有监控:
- 业务 silently fail
- 接口看似正常,结果数据丢了
- 排查时非常痛苦
拒绝次数一定要打点。
止血方案
线上已经报警时,优先做的是止血,而不是追求“完美改造”。
短期止血
1. 限流或降级
先把入口流量压下来,避免堆积继续扩大。
可选手段:
- 网关限流
- 核心接口熔断
- 非核心功能临时关闭
- 批量任务拆小
2. 缩小单次提交任务数
如果一个请求会提交几百个任务,先控制成几十个,通常立竿见影。
3. 给等待结果加超时
future.get(500, TimeUnit.MILLISECONDS);
哪怕先返回降级结果,也比把请求线程全部拖死强。
4. 替换无界队列
把:
Executors.newFixedThreadPool(20)
替换成显式构造的有界线程池,往往是最关键的一步。
中期修复
- 线程池按业务隔离
- 建立容量模型
- 加线程池监控和告警
- 优化任务粒度
- 对慢下游增加超时与熔断
安全/性能最佳实践
这里给一套比较实用的原则,不求绝对标准,但足够能避开大多数坑。
1. 不直接使用 Executors 快捷工厂创建业务线程池
优先使用显式参数:
new ThreadPoolExecutor(...)
这样你能明确控制:
- 队列大小
- 最大线程数
- 拒绝策略
- 线程命名
2. 有界队列是默认选择
除非你非常确定任务量上限,否则不要轻易用无界队列。
常见选择:
ArrayBlockingQueue:有界、结构简单,适合稳定场景LinkedBlockingQueue(容量):可指定容量,灵活一些SynchronousQueue:不存储任务,适合强背压模型
3. 拒绝策略要按业务选
常见策略:
AbortPolicy:直接抛异常,适合必须感知失败的场景CallerRunsPolicy:调用方线程自己执行,适合自然限流- 自定义策略:记录日志、打监控、降级处理
我个人经验是:
核心接口优先“可观测失败”,非核心任务可考虑降级或调用方回退。
4. 线程池大小要结合任务类型
粗略经验:
- CPU 密集型:
CPU核数或CPU核数 + 1 - IO 密集型:可以适当大一些,但必须配合队列、超时和下游承载能力
不要只看本机 CPU,还要看:
- 数据库连接池
- HTTP 连接池
- 下游 QPS 限制
- Redis/ES 等依赖的并发能力
5. 所有异步任务都要有超时意识
包括:
Future.get(timeout)- 下游 HTTP 超时
- 数据库查询超时
- 重试次数限制
没有超时,就没有真正可控的并发。
6. 监控最少要覆盖这些指标
建议为每个业务线程池暴露:
poolSizeactiveCountqueueSizecompletedTaskCounttaskCountrejectCountlargestPoolSize
7. 线程命名必须可读
比如:
order-query-workerinvoice-export-workercallback-retry-worker
不要留默认线程名,否则 jstack 时基本等于盲人摸象。
8. 注意上下文泄漏
异步线程里如果使用:
ThreadLocalMDC- 用户上下文
一定要在任务结束后清理,否则容易出现:
- 内存泄漏
- 脏数据串请求
- 日志 traceId 混乱
一个更稳妥的线程池配置思路
如果你暂时没有完整容量模型,可以先按下面思路落地:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadPoolFactory {
public static ThreadPoolExecutor createIoPool(String name) {
return new ThreadPoolExecutor(
16,
32,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new NamedThreadFactory(name),
new ThreadPoolExecutor.AbortPolicy()
);
}
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;
}
}
}
这不是“万能配置”,但比无界队列的默认写法安全得多。
真正上线前,还是要结合压测结果调整:
- 队列是否过小导致频繁拒绝
- 最大线程数是否压垮下游
- 超时阈值是否合理
- 调用方是否能接受降级
总结
这次踩坑的核心教训,其实就一句话:
线程池不是“加了异步就更快”,配错了反而会把系统拖进更深的坑。
遇到“接口超时 + 内存飙升”时,我建议优先检查这几件事:
- 有没有使用
Executors.newFixedThreadPool()等快捷工厂 - 队列是不是无界
- 接口里是否批量 submit 任务
- 是否同步等待异步结果
- 是否缺少超时、拒绝策略和降级
- 是否缺少线程池监控
如果你只能先做一件事,我建议是:
把业务线程池改成显式 ThreadPoolExecutor + 有界队列 + 可观测拒绝策略。
这一步不能解决所有性能问题,但通常能先把“无限堆积导致内存打爆”的大坑填上。剩下的,再通过压测、监控和业务隔离慢慢收口。
很多线上事故,不是因为系统扛不住压力,而是因为没有在压力来临时及时“说不”。
而线程池配置,本质上就是系统表达“说不”的一种方式。