线程池七个参数:一个无界队列引发的 OOM
newFixedThreadPool 把队列设成无界,3000 个任务直接吃满 3.2 GB。七个参数的真实作用顺序、四种拒绝策略与五个必踩的坑。
ThreadPoolExecutor 的构造函数有七个参数,但真正决定线上稳定性的其实只有三个:队列容量、最大线程数、拒绝策略。绝大多数线程池事故都出在这三个的默认值上——尤其是 Executors.newFixedThreadPool(),它把队列设成了无界。
这篇文章从一次真实的内存溢出讲起,然后逐一拆解七个参数、四种拒绝策略、五个必踩的坑,最后给出一份可以直接用的线程池配置模板。
一、事故现场
一个报表导出服务,用 Executors.newFixedThreadPool(10) 起线程池,上线半年没事。某天运营批量触发了 3000 个导出任务:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
Dumping heap to /tmp/java_pid1.hprof ...
看堆 dump,最大的对象是一个 LinkedBlockingQueue,里面存了 2980 个 ExportTask,每个 task 持有一个 20 万行的结果集,总共吃了 3.2 GB。
问题出在 newFixedThreadPool 的定义:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // ← 无界队列
}
new LinkedBlockingQueue() 的默认容量是 Integer.MAX_VALUE。所以:
- 线程数永远停在 10(因为队列永远不会满,
maximumPoolSize根本用不上) - 任务无限堆积,直到堆爆
这是 Executors 工厂方法的最大陷阱:它把所有边界都藏起来了,看起来简单,实际把风险留到了运行时。
阿里 Java 开发手册里那条「不允许使用 Executors 创建线程池」,说的就是这件事。
二、七个参数到底谁管什么
public ThreadPoolExecutor(
int corePoolSize, // 1. 常驻线程数
int maximumPoolSize, // 2. 最大线程数
long keepAliveTime, // 3. 空闲回收时间
TimeUnit unit, // 4. 时间单位
BlockingQueue<Runnable> workQueue, // 5. 任务队列
ThreadFactory threadFactory, // 6. 线程工厂
RejectedExecutionHandler handler // 7. 拒绝策略
)
必须先记住任务提交的完整流程,否则调参全是盲调:
提交任务
→ 当前线程数 < corePoolSize ? 是 → 新建线程执行
→ 队列未满 ? 是 → 入队等待
→ 当前线程数 < maximumPoolSize ? 是 → 新建线程执行
→ 执行拒绝策略
这个顺序是最反直觉的地方:队列满了才会扩容到 maximumPoolSize。
所以「无界队列 + maximumPoolSize 设很大」等于 maximumPoolSize 完全失效——队列永远不满,永远走不到第 3 步。
这个顺序也解释了一个常见困惑:为什么 corePoolSize=10, maximumPoolSize=100, queue=100,压测时线程数永远只有 10?因为队列 100 还没满。要看到线程扩到 20,得先塞满 100 个任务。
三、队列选型:三种队列三种行为
| 队列 | 容量 | 行为 | 适用 |
|---|---|---|---|
SynchronousQueue |
0 | 不存任务,直接交给线程;没有空闲线程就扩容 | 短任务、高并发、CachedThreadPool 用的就是这个 |
ArrayBlockingQueue |
固定 | 有界,满了触发扩容/拒绝 | 生产环境首选 |
LinkedBlockingQueue |
默认 MAX_VALUE |
无界(不传容量时) | 必须显式传容量,否则就是 OOM 定时炸弹 |
PriorityBlockingQueue |
无界 | 按优先级排序 | 任务有优先级时用,但同样要防无界 |
DelayQueue |
无界 | 延迟执行 | 定时重试场景 |
生产环境的选择逻辑很清晰:
- CPU 密集型:
ArrayBlockingQueue(小容量)+core == max - IO 密集型:
ArrayBlockingQueue(中等容量)+max > core,允许突发扩容 - 任务可丢弃(比如埋点上报):
ArrayBlockingQueue(小容量)+DiscardPolicy
队列容量怎么定:用 Little's Law 估算。
队列长度 ≈ 到达速率 × 平均处理时间
QPS 200、单个任务 50ms,那同时积压的任务约 10 个。留 3~5 倍余量,队列设 30~50 就够。设成 10000 只会让「积压」这件事延迟暴露,而不是消失。
四、拒绝策略:四种默认 + 一种自定义
JDK 提供四种:
| 策略 | 行为 | 风险 |
|---|---|---|
AbortPolicy(默认) |
抛 RejectedExecutionException |
调用方必须捕获,否则请求 500 |
CallerRunsPolicy |
由提交任务的线程自己执行 | 会阻塞调用方,如果是 Tomcat 线程,等于拖慢整个请求 |
DiscardPolicy |
静默丢弃,不报错 | 任务静默丢失,最难排查 |
DiscardOldestPolicy |
丢掉队列最老的,再试一次 | 老任务丢了也不知道 |
AbortPolicy 是默认值,也是最安全的——至少你会知道出问题了。但需要在业务层兜底:
try {
executor.execute(task);
} catch (RejectedExecutionException e) {
// 降级:写本地文件 / 打日志 / 返回"系统繁忙"
metrics.counter("threadpool.rejected", "pool", "export").increment();
throw new BizException("当前任务过多,请稍后重试");
}
我更推荐自定义策略,把「拒绝」变成可观测事件:
public class MetricsRejectHandler implements RejectedExecutionHandler {
private final Counter rejected;
private final MeterRegistry registry;
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
rejected.increment();
log.warn("线程池拒绝 pool={} active={} queue={} completed={}",
e, e.getActiveCount(), e.getQueue().size(), e.getCompletedTaskCount());
throw new RejectedExecutionException("线程池已满");
}
}
关键是记录拒绝次数并设告警。threadpool.rejected > 0 就说明容量不足或下游变慢,这是最早的预警信号。
五、五个必踩的坑
坑 1:submit() 吞掉异常
executor.submit(() -> {
throw new RuntimeException("boom"); // ← 静默消失
});
submit() 返回 Future,异常被封装进 Future,如果不调 get(),异常就永远不出现。线程池继续跑,日志里什么都没有。
对比 execute():
executor.execute(() -> {
throw new RuntimeException("boom"); // ← 打印到 stderr / 未捕获处理器
});
规则:不需要返回值的任务用 execute()。必须用 submit() 时,务必处理 Future:
Future<?> f = executor.submit(task);
try {
f.get();
} catch (ExecutionException e) {
log.error("任务失败", e.getCause()); // 注意取 getCause()
}
坑 2:ThreadLocal 泄漏
线程池的线程是复用的,ThreadLocal 不清理就会把上一个任务的数据带给下一个。
executor.execute(() -> {
UserContext.set(currentUser); // 没有 remove
doWork();
});
// 下一个任务在同一个线程上跑,UserContext.get() 还是上一个用户
这是数据串号事故的典型成因——A 用户能看到 B 用户的数据。
正确写法:
executor.execute(() -> {
try {
UserContext.set(currentUser);
doWork();
} finally {
UserContext.remove(); // 必须放在 finally
}
});
用 Spring 的 RequestContextHolder 时也一样。或者直接用 TaskDecorator 统一处理:
@Bean
public TaskDecorator contextCopyingDecorator() {
return runnable -> {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
return () -> {
try {
RequestContextHolder.setRequestAttributes(attrs);
runnable.run();
} finally {
RequestContextHolder.resetRequestAttributes();
}
};
};
}
坑 3:线程池不隔离,一个慢接口拖垮全部
所有业务共用一个 ThreadPoolTaskExecutor,报表导出把线程占满,登录接口也一起排队。现象是「平时好好的,一有批量任务全站变慢」。
解决是按业务拆分线程池,每个池独立配置容量:
@Configuration
public class ExecutorConfig {
@Bean("fastExecutor") // 短任务:登录、查询
public ThreadPoolTaskExecutor fastExecutor() {
return build(8, 32, 200, "fast-", new AbortPolicy());
}
@Bean("slowExecutor") // 长任务:导出、报表
public ThreadPoolTaskExecutor slowExecutor() {
return build(4, 8, 50, "slow-", new CallerRunsPolicy());
}
private ThreadPoolTaskExecutor build(int core, int max, int queue,
String prefix, RejectedExecutionHandler h) {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(core);
e.setMaxPoolSize(max);
e.setQueueCapacity(queue);
e.setKeepAliveSeconds(60);
e.setThreadNamePrefix(prefix);
e.setRejectedExecutionHandler(h);
e.setWaitForTasksToCompleteOnShutdown(true); // 优雅关闭
e.setAwaitTerminationSeconds(30);
e.initialize();
return e;
}
}
setThreadNamePrefix 看着无所谓,但线上排查时 jstack 里全是 pool-1-thread-3 根本分不清是谁的线程。有了前缀,grep slow- 一眼定位。
坑 4:没有监控,出问题只能靠猜
线程池的六个核心指标必须暴露:
@Component
public class PoolMetrics {
public PoolMetrics(List<ThreadPoolTaskExecutor> executors, MeterRegistry registry) {
for (ThreadPoolTaskExecutor e : executors) {
ThreadPoolExecutor p = e.getThreadPoolExecutor();
Gauge.builder("threadpool.size", p, ThreadPoolExecutor::getPoolSize)
.tag("pool", e.getThreadNamePrefix()).register(registry);
Gauge.builder("threadpool.active", p, ThreadPoolExecutor::getActiveCount)
.tag("pool", e.getThreadNamePrefix()).register(registry);
Gauge.builder("threadpool.queue", p, x -> x.getQueue().size())
.tag("pool", e.getThreadNamePrefix()).register(registry);
Gauge.builder("threadpool.remaining", p, x -> x.getQueue().remainingCapacity())
.tag("pool", e.getThreadNamePrefix()).register(registry);
}
}
}
告警阈值设定:
| 指标 | 告警条件 | 含义 |
|---|---|---|
queue / capacity |
> 80% 持续 1 分钟 | 消费能力不足 |
active / max |
> 90% 持续 30 秒 | 线程即将打满 |
rejected 增量 |
> 0 | 已经开始拒绝请求 |
坑 5:keepAliveTime 对 corePool 不生效
默认情况下,核心线程即使空闲也不会被回收。想回收必须加:
executor.allowCoreThreadTimeOut(true);
不过生产环境一般不建议——线程反复创建销毁的成本比常驻更高。这个参数主要用在「白天忙、夜里闲」的服务,配合较长的 keepAliveTime(比如 300 秒)省一点内存。
六、一份可直接用的配置模板
综合上面所有结论,这是我在用的模板,参数按「IO 密集型 + 有界队列 + 失败可见」设定:
public final class ThreadPools {
/** 通用短任务:HTTP 调用、RPC */
public static ThreadPoolExecutor io(String name, int core, int queue) {
return build(name, core, core * 4, queue, false);
}
/** CPU 密集:加解密、压缩、计算 */
public static ThreadPoolExecutor cpu(String name, int queue) {
int cpu = Runtime.getRuntime().availableProcessors();
return build(name, cpu + 1, cpu + 1, queue, false);
}
private static ThreadPoolExecutor build(String name, int core, int max,
int queue, boolean callerRuns) {
return new ThreadPoolExecutor(
core, max, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queue),
new NamedThreadFactory(name),
callerRuns ? new CallerRunsPolicy() : new RejectCounter(name)
);
}
static class NamedThreadFactory implements ThreadFactory {
private final AtomicInteger seq = new AtomicInteger(1);
private final String prefix;
NamedThreadFactory(String p) { this.prefix = p + "-"; }
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, prefix + seq.getAndIncrement());
t.setDaemon(false);
t.setUncaughtExceptionHandler((th, e) ->
log.error("线程池未捕获异常 thread={}", th.getName(), e));
return t;
}
}
}
三个细节值得注意:
cpu + 1是 CPU 密集型的经典取值。多出的 1 个用于在某线程偶尔缺页时顶上。纯计算任务设成2 × cpu反而会因为上下文切换变慢。new ArrayBlockingQueue<>而不是LinkedBlockingQueue。前者必须传容量,从类型上强制你考虑边界。UncaughtExceptionHandler。没有它,线程里逃逸的异常可能彻底静默——尤其是ScheduledExecutorService里抛异常会导致后续调度静默停止,这是另一个高频坑。
七、定时任务线程池的特别提醒
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
throw new RuntimeException("偶发错误"); // ← 之后再也不执行了
}, 0, 10, TimeUnit.SECONDS);
scheduleAtFixedRate 的行为是:任务抛异常就取消后续所有执行,而且不打印任何日志。现象是「定时任务跑了第一次就不跑了」,排查半天找不到原因。
修复:
scheduler.scheduleAtFixedRate(() -> {
try {
doWork();
} catch (Throwable t) { // 捕获 Throwable,连 Error 也不能漏
log.error("定时任务失败", t);
}
}, 0, 10, TimeUnit.SECONDS);
Spring 的 @Scheduled 默认也是这个行为(单线程 + 异常即中断)。所以 @Scheduled 方法内部一定要自己 try-catch,并且配置 spring.task.scheduling.pool.size > 1,否则一个慢任务会阻塞所有其他定时任务。
八、参数确定的三步走
不要凭经验值拍脑袋,按这个流程走:
第 1 步:压测拿数据
# 用 jstack 采样,统计线程状态分布
for i in $(seq 1 10); do jstack 1 | grep -c 'RUNNABLE'; sleep 1; done
# 再统计队列长度
watch -n1 'jcmd 1 Thread.print | grep -c "waiting on condition"'
第 2 步:按公式定初值
线程数 = CPU 核数 × 目标利用率 × (1 + 等待时间 / 计算时间)
IO 等待 90%、核数 8 → 8 × 1 × (1 + 9) = 80。这是理论上界,实际取值要小一些(因为下游有连接池限制)。
第 3 步:看运行数据迭代
上线后观察 active / max 和 queue / capacity:
- 队列长期 > 50%:加线程或加下游能力
- 队列长期 < 5% 且线程空闲:缩容
- 拒绝 > 0:先确认是流量突增还是下游变慢
小结
线程池的所有问题可以归结成一句话:要有界,要可见,要隔离。
- 有界:队列必须有容量,
Executors的工厂方法一律不用 - 可见:拒绝次数、队列长度、活跃线程数必须打点告警
- 隔离:快任务和慢任务分池,别让导出把登录拖死
三个参数的实际作用顺序也要记牢:corePoolSize 决定常驻、队列决定缓冲、maximumPoolSize 只有在队列满时才起作用。搞错这个顺序,配什么参数都是盲调。
正在加载评论…