背景与问题
线上接口突然开始变慢,最开始只是偶发超时,接着 Full GC 频率升高,监控里堆内存像台阶一样往上爬。很多人第一反应是“是不是数据库慢了”或者“是不是外部接口抖了”,但我实际踩坑时发现,真正的问题往往藏在线程池用法里。
这个坑有个典型特征:
- 接口 RT 持续升高
- 活跃线程数越来越多
- 队列积压严重
- 堆内存上涨明显
- 线程 dump 看起来“并不忙”,但请求就是卡住
- 重启后短暂恢复,过一阵又复发
如果你的业务里有下面这些场景,就要尤其小心:
- 每个请求里都
newFixedThreadPool()/newCachedThreadPool() - 把慢 IO 任务和 CPU 任务混放到同一个线程池
- 线程池队列设置成无界
- 使用
Future.get()同步等待,结果把接口线程自己堵死 - 任务里塞了大对象、请求上下文、批量数据,导致队列越积越占内存
这篇文章我就按**“现象复现 → 原理解释 → 排查路径 → 止血方案 → 长期治理”**的方式,带你走一遍。
现象复现
先看一个很常见、也很危险的写法。
错误示例:无界队列 + 接口同步等待
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class BadThreadPoolDemo {
// 看起来线程数不大,似乎很安全
// 但 Executors.newFixedThreadPool 底层使用的是无界 LinkedBlockingQueue
private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool(8);
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100000; i++) {
int requestId = i;
handleRequest(requestId);
}
}
public static String handleRequest(int requestId) throws Exception {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 20; i++) {
int taskId = i;
futures.add(EXECUTOR.submit(() -> {
// 模拟慢接口/慢 SQL
Thread.sleep(300);
// 模拟每个任务持有较大对象
byte[] payload = new byte[1024 * 256]; // 256KB
return "req=" + requestId + ", task=" + taskId + ", payload=" + payload.length;
}));
}
StringBuilder result = new StringBuilder();
for (Future<String> future : futures) {
// 这里同步阻塞等待
result.append(future.get()).append("\n");
}
return result.toString();
}
}
这个例子的问题集中在三点:
- 线程池队列无界
- 请求线程同步等待任务完成
- 任务排队时还持有大对象引用
只要请求流量一上来,线程池处理不过来,任务就会疯狂堆积在队列里,队列里的每个任务都可能关联请求参数、上下文对象、返回数据缓存,堆内存很快就被吃掉。
核心原理
线程池不是“加了就更快”,它本质上是一个有限处理能力的调度器。用错了,反而会把系统拖垮。
1. 线程池的真实工作方式
Java 线程池接收任务时,大致遵循这个流程:
flowchart TD
A[提交任务] --> B{当前线程数 < corePoolSize?}
B -- 是 --> C[创建核心线程执行]
B -- 否 --> D{工作队列能否放入?}
D -- 能 --> E[任务入队等待]
D -- 否 --> F{当前线程数 < maximumPoolSize?}
F -- 是 --> G[创建非核心线程执行]
F -- 否 --> H[触发拒绝策略]
关键点在于:
- 如果你用的是无界队列,那
maximumPoolSize基本上就失效了 - 因为队列永远“还能塞”,线程池就不会继续扩线程
- 最后变成:少量线程 + 无限排队
这就是很多人误以为“我设置了最大线程数,应该能扛住”的根源。
2. 为什么会接口超时
接口线程通常会这样调用异步任务:
- 收到 HTTP 请求
- 往线程池提交多个子任务
- 通过
Future.get()/join()等待结果 - 汇总后返回响应
问题是:你表面上用了异步,实际上又同步等回来了。
如果线程池拥堵:
- 子任务迟迟拿不到执行机会
- 主请求线程一直阻塞等待
- Tomcat / Undertow / Netty 工作线程被占满
- 新请求进来后继续排队
- 整体 RT 和超时率一起上升
可以简单理解成:
你把一部分工作“挪”到了线程池,但没有减少总工作量,反而增加了调度、排队和等待成本。
3. 为什么会内存飙升
很多人看到线程池问题,首先想到“线程太多导致内存高”。这只说对了一半。
真正更常见的是:队列积压导致内存上涨。
队列内存来自哪里?
排队中的任务通常会持有:
- 请求参数对象
- 用户上下文
- DTO / VO
- SQL 查询结果的中间对象
- 大数组、缓存片段、文件块
- Lambda 捕获的外部变量
也就是说,任务没执行前,这些对象也不会被释放。
sequenceDiagram
participant Client as 客户端
participant App as 接口线程
participant Pool as 线程池
participant Queue as 队列
participant Heap as 堆内存
Client->>App: HTTP请求
App->>Pool: 提交20个任务
Pool->>Queue: 线程忙,任务入队
Queue->>Heap: 持有请求参数/大对象
App->>Pool: Future.get()等待
Pool-->>App: 子任务迟迟未执行
App-->>Client: 超时/慢响应
所以线上看起来像是“接口超时 + 内存飙升”,本质上往往是同一个问题的两面:
- 吞吐跟不上
- 排队无限增长
定位路径
如果你已经在线上遇到这个问题,建议不要上来就改代码,先按下面顺序定位。
1. 先看监控,确认是不是线程池拥堵
重点看这些指标:
- 线程池活跃线程数
activeCount - 队列长度
queue.size - 任务总数 / 已完成任务数
- 接口 RT、超时率
- JVM 堆使用率、Young GC / Full GC
- Tomcat 工作线程使用率
如果出现这种组合,基本可以锁定:
activeCount长期接近核心线程数queue.size持续增长不回落- 已完成任务增长缓慢
- 堆内存与队列长度同步上涨
2. 看线程 dump,确认是否大量阻塞等待
用 jstack 看线程栈,常见现象有:
- 大量业务线程卡在
FutureTask.get - 线程池工作线程卡在慢 IO、远程调用、数据库查询
- Web 容器线程也在等待异步结果
示例特征:
"http-nio-8080-exec-35" #112 daemon prio=5 os_prio=0 tid=0x00007f...
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:211)
at java.util.concurrent.FutureTask.awaitDone(FutureTask.java:447)
at java.util.concurrent.FutureTask.get(FutureTask.java:190)
at com.example.service.OrderService.query(OrderService.java:86)
这说明接口线程不是在干活,而是在等线程池里的任务。
3. 看堆 dump,确认是不是队列积压
如果有堆 dump,可以重点搜:
LinkedBlockingQueueFutureTask- 自定义
Runnable/Callable - 被 Lambda 捕获的上下文对象
- 大对象数组
常见结果是:
- 某个线程池的
workQueue特别大 - 队列中每个任务都引用了请求对象
- 请求对象链路上挂着很大的集合或字节数组
4. 反查代码里的危险信号
排查代码时,我一般先全局搜这些关键词:
Executors.newFixedThreadPool
Executors.newCachedThreadPool
Executors.newSingleThreadExecutor
Future.get(
CompletableFuture.join(
parallelStream(
因为这些地方最容易藏住“看起来没问题,线上却出事”的坑。
实战代码(可运行)
下面给一个相对靠谱的改造版本,核心目标是:
- 自定义线程池参数,不用
Executors默认工厂 - 有界队列,避免无限堆积
- 明确拒绝策略,实现背压
- 给异步任务设置超时
- IO 任务和请求线程解耦,但不无脑并发
改造示例:有界线程池 + 超时 + 降级
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.stream.Collectors;
public class GoodThreadPoolDemo {
private static final ThreadPoolExecutor BIZ_POOL = new ThreadPoolExecutor(
8, // corePoolSize
16, // maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime
new ArrayBlockingQueue<>(200), // 有界队列,限制内存占用
new NamedThreadFactory("biz-pool"),
new ThreadPoolExecutor.CallerRunsPolicy() // 背压,让调用方感知压力
);
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100; i++) {
String result = handleRequest(i);
System.out.println(result);
}
BIZ_POOL.shutdown();
}
public static String handleRequest(int requestId) {
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
int taskId = i;
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> slowQuery(requestId, taskId), BIZ_POOL)
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "fallback:req=" + requestId + ", task=" + taskId);
futures.add(future);
}
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.joining(" | "));
}
private static String slowQuery(int requestId, int taskId) {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
return "ok:req=" + requestId + ", task=" + taskId;
}
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private int counter = 0;
NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public synchronized Thread newThread(Runnable r) {
Thread thread = new Thread(r, prefix + "-" + (++counter));
thread.setDaemon(false);
return thread;
}
}
}
这个版本不代表“完美”,但至少解决了最危险的两件事:
- 任务不会无限入队吃光内存
- 超时时能失败返回,而不是无休止等待
止血方案
线上出了问题时,优先级不是“写出最优雅的代码”,而是先把故障面收住。
短期止血的顺序
1. 限流或降并发
如果队列已经在涨,继续放流量进来只会扩大事故面。先做:
- 网关限流
- 关闭高耗时非核心功能
- 减少批量查询、聚合请求
2. 调小任务提交量
很多接口一个请求拆几十个并发子任务,这在低流量时没事,一上量马上炸。短期可以先:
- 减少单请求拆分数
- 合并子任务
- 改串行一部分流程
3. 增加超时和降级
如果下游慢,就不要无限等:
- RPC 超时
- SQL 超时
CompletableFuture.orTimeout- fallback 默认值
4. 临时扩大容量,但要有边界
可以适度增加:
- 线程池大小
- 队列长度
- 容器实例数
但这只是延缓问题,不是根治。尤其是盲目加大无界队列,只会让 OOM 来得更晚一点。
常见坑与排查
下面这些是我见过最典型的线程池误用。
坑 1:直接使用 Executors.newFixedThreadPool
看起来很规范,其实隐藏了无界队列。
ExecutorService executor = Executors.newFixedThreadPool(16);
底层相当于:
- 核心线程数固定
- 最大线程数固定但无意义
- 队列无限增长
正确做法
ThreadPoolExecutor executor = new ThreadPoolExecutor(
16,
32,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500),
new ThreadPoolExecutor.AbortPolicy()
);
坑 2:每次请求都创建线程池
public String query() {
ExecutorService executor = Executors.newFixedThreadPool(10);
// ...
executor.shutdown();
return "ok";
}
这个问题很隐蔽:
- 线程创建销毁开销大
- 短时间产生大量线程
- 线程名难追踪
- 容易造成 native memory 压力
排查信号
- 线程数异常高
- 线程名杂乱
top -H/jstack看到很多短生命周期线程
坑 3:IO 密集和 CPU 密集共用一个池
比如:
- 查库、调远程接口、读文件
- JSON 序列化、压缩、规则计算
都塞到同一个线程池。结果就是:
- 慢 IO 把线程占住
- CPU 任务排不上
- 全链路 RT 一起恶化
更好的方式
至少做基本隔离:
ioPoolcpuPool
坑 4:任务里再提交子任务,自己把自己卡死
这是“线程池嵌套等待”的经典死锁/假死问题。
import java.util.concurrent.*;
public class NestedDeadlockDemo {
private static final ExecutorService POOL = Executors.newFixedThreadPool(2);
public static void main(String[] args) throws Exception {
Future<String> f1 = POOL.submit(() -> {
Future<String> inner = POOL.submit(() -> {
Thread.sleep(1000);
return "inner";
});
return inner.get();
});
Future<String> f2 = POOL.submit(() -> {
Thread.sleep(5000);
return "task2";
});
System.out.println(f1.get());
System.out.println(f2.get());
POOL.shutdown();
}
}
如果池子资源紧张,外层任务占着线程不放,内层任务又排队等执行,就可能卡住。
坑 5:拒绝策略没想清楚
如果用了有界队列,就一定会遇到“队列满了怎么办”。
常见策略:
AbortPolicy:直接抛异常,适合明确失败CallerRunsPolicy:让调用线程自己执行,形成背压DiscardPolicy:静默丢弃,风险大DiscardOldestPolicy:丢最旧任务,要看业务是否允许
不要默认选一个然后不管。
拒绝策略本质上是在定义:系统过载时,谁来承受代价。
安全/性能最佳实践
这一部分我尽量写成能落地执行的清单。
1. 线程池必须“显式配置”
不要依赖默认值,至少明确这些参数:
corePoolSizemaximumPoolSizequeueCapacitykeepAliveTimeThreadFactoryRejectedExecutionHandler
2. 队列一定要有界
这是避免内存飙升的关键。
边界条件
- 队列太小:容易频繁触发拒绝
- 队列太大:延迟和内存占用会上升
所以容量不是越大越好,而是要根据业务吞吐和任务耗时估算。
3. 不同类型任务要隔离
一个很实用的经验法则:
- CPU 密集:线程数接近 CPU 核数
- IO 密集:线程数可以更高,但必须结合下游容量测试
不要因为“线程池空着”就随便加并发,下游数据库、Redis、HTTP 服务未必扛得住。
4. 为任务设置超时和取消机制
如果任务超时了,只在主线程超时返回还不够,最好配合:
- 下游超时
- 中断感知
- 任务取消
- 幂等处理
否则经常会出现:
- 用户已经超时返回
- 后台任务还在继续跑
- 线程池持续被无效任务占满
5. 把监控做在代码设计里
建议至少暴露这些指标:
- 当前线程数
- 活跃线程数
- 队列大小
- 拒绝次数
- 任务执行耗时
- 超时次数
- 降级次数
可以接 Prometheus / Micrometer:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
8, 16, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// 示例:定时打印核心指标
System.out.println("poolSize=" + executor.getPoolSize());
System.out.println("activeCount=" + executor.getActiveCount());
System.out.println("queueSize=" + executor.getQueue().size());
System.out.println("completedTaskCount=" + executor.getCompletedTaskCount());
6. 压测时要看“排队时间”,不只看成功率
很多压测报告只看:
- QPS
- 平均 RT
- 错误率
但线程池问题里,真正关键的是:
- P95 / P99 延迟
- 任务排队时长
- 队列峰值
- 拒绝数
- GC 次数和停顿
7. 少用“异步包装同步”
如果下游本来就是同步阻塞型调用,你只是把它扔进线程池再 get() 回来,这种“伪异步”收益很有限,反而容易放大线程池问题。
先问自己两个问题:
- 这个并发拆分真的缩短总耗时吗?
- 下游是否支持更高并发,而不是被我压垮?
一个更完整的排查流程图
线上遇到接口超时和内存上涨时,可以直接按这个顺序处理。
flowchart TD
A[接口RT升高/超时告警] --> B[查看线程池指标]
B --> C{队列是否持续增长}
C -- 是 --> D[查看jstack是否大量Future.get等待]
C -- 否 --> E[排查数据库/网络/锁竞争]
D --> F[查看堆内存与队列对象占用]
F --> G{是否无界队列或任务持有大对象}
G -- 是 --> H[立即限流+降级+缩减任务拆分]
H --> I[改为有界队列+拒绝策略+任务超时]
G -- 否 --> J[检查线程池嵌套提交/混用CPU与IO池]
一个线程池配置思路图
classDiagram
class ThreadPoolDesign {
+corePoolSize
+maximumPoolSize
+queueCapacity
+threadFactory
+rejectedHandler
+timeoutStrategy
+metrics
}
class TaskType {
<<enumeration>>
CPU_BOUND
IO_BOUND
SCHEDULED
}
class RuntimeMetrics {
+activeCount
+queueSize
+rejectCount
+taskLatency
+timeoutCount
}
ThreadPoolDesign --> TaskType
ThreadPoolDesign --> RuntimeMetrics
总结
线程池问题最容易误导人的地方在于:它常常不是“直接报错”,而是以一种很像下游故障的形式出现:
- 接口越来越慢
- 超时越来越多
- 内存越来越高
- GC 越来越频繁
但根因可能只是几个不起眼的设计失误:
- 用了无界队列
- 盲目并发拆分
- 同步等待异步结果
- 任务持有大对象
- 线程池没有隔离和监控
如果你想把这个问题真正解决,我建议记住这几条最实用的结论:
- 不要直接用
Executors默认线程池工厂 - 线程池队列一定要有界
- 拒绝策略不是装饰品,要明确过载行为
- 异步任务必须有超时、降级和取消意识
- IO 与 CPU 任务分池隔离
- 排查时同时看队列、线程栈、堆对象,不要只盯接口日志
最后说一句很接地气的话:
线程池不是“性能优化开关”,它更像一个“流量闸门”。闸门设计得好,系统稳;闸门设计错了,洪水先从自己家里漫出来。