摘要:从一次"线上应用突然OOM宕机"的严重故障出发,深度剖析线程池使用不当导致内存溢出的5大场景。通过无界队列堆积、线程数暴增、任务对象过大、ThreadLocal泄漏、以及拒绝策略失效的真实案例,揭秘Executors工具类的致命陷阱、如何正确设置队列大小、以及线程池监控的关键指标。配合堆内存分析图展示OOM过程,给出线程池使用的安全指南。
💥 翻车现场
周五下午5点,哈吉米准备下班。
突然,告警炸了。
告警:
🚨 应用内存持续增长
🚨 15:00 - 内存使用:2GB
🚨 16:00 - 内存使用:4GB
🚨 17:00 - 内存使用:7GB
🚨 17:15 - OutOfMemoryError: Java heap space
🚨 应用宕机
哈吉米:"卧槽,OOM了!"
紧急重启应用,查看堆dump:
MAT(Memory Analyzer Tool)分析:
内存占用TOP 10:
Object | Shallow Heap | Retained Heap
----------------------------------------|--------------|---------------
LinkedBlockingQueue | 48 bytes | 3.2 GB
└─ Object[] elementData | 16 bytes | 3.2 GB
└─ ImportTask[0...500000] | | 3.2 GB
问题:
- LinkedBlockingQueue占用3.2GB
- 队列中堆积了50万个任务
- 每个任务对象6KB
- 总计:50万 × 6KB = 3GB
哈吉米:"队列堆积了50万个任务?怎么会这样?"
查看代码:
// 导入用户功能
@Service
public class UserImportService {
// 用Executors创建线程池(危险)
private ExecutorService executor = Executors.newFixedThreadPool(10);
public void importUsers(List<User> users) {
for (User user : users) {
// 提交任务(每个用户一个任务)
executor.execute(new ImportTask(user));
}
}
static class ImportTask implements Runnable {
private User user; // 用户对象(6KB)
@Override
public void run() {
saveUser(user);
}
}
}
问题分析:
用户导入100万条数据:
1. 创建100万个ImportTask对象(每个6KB)
2. 线程池只有10个线程,处理速度:1000个/秒
3. 100万个任务,需要1000秒(16分钟)
4. 任务生成速度(1秒10万个)> 任务处理速度(1秒1000个)
5. 任务堆积到队列中
6. 队列无界(LinkedBlockingQueue,默认容量Integer.MAX_VALUE)
7. 堆积50万个任务 × 6KB = 3GB
8. 内存溢出 ❌
南北绿豆和阿西噶阿西赶来了。
南北绿豆:"你用了 Executors.newFixedThreadPool()?这是OOM的罪魁祸首!"
哈吉米:"???"
阿西噶阿西:"来,我给你讲讲线程池OOM的5种死法。"
🕳️ OOM死法1:无界队列(最常见)
Executors的陷阱
问题代码:
// Executors.newFixedThreadPool()的实现
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(
nThreads, // corePoolSize
nThreads, // maximumPoolSize(和core一样)
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>() // 无界队列(危险)
);
}
LinkedBlockingQueue的默认容量:
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE); // 约21亿
}
问题:
提交100万个任务:
1. 线程池只有10个线程
2. 任务全部堆积到队列
3. 队列容量:21亿(几乎无限)
4. 100万个任务对象 × 6KB = 6GB
5. 内存溢出 ❌
正确写法
// ❌ 错误
ExecutorService executor = Executors.newFixedThreadPool(10);
// ✅ 正确(手动创建,指定队列大小)
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize
20, // maximumPoolSize
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000), // 有界队列,容量1000
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
效果对比:
| 队列类型 | 容量 | 100万任务 | 结果 |
|---|---|---|---|
| LinkedBlockingQueue() | 21亿 | 全部入队 | OOM ❌ |
| ArrayBlockingQueue(1000) | 1000 | 1000入队,其余拒绝 | 安全 ✅ |
🕳️ OOM死法2:线程数暴增
Executors.newCachedThreadPool()的陷阱
// Executors.newCachedThreadPool()的实现
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(
0, // corePoolSize=0(没有核心线程)
Integer.MAX_VALUE, // maximumPoolSize=21亿(危险)
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>() // 不缓存任务,直接交给线程
);
}
问题场景:
瞬间提交10万个任务:
1. SynchronousQueue不缓存任务
2. 每个任务都需要一个线程
3. 创建10万个线程
4. 每个线程栈空间:1MB
5. 总内存:10万 × 1MB = 100GB
6. 内存溢出 ❌
实际案例:
ExecutorService executor = Executors.newCachedThreadPool();
// 批量任务
for (int i = 0; i < 100000; i++) {
executor.execute(() -> {
// 任务执行时间:10秒
Thread.sleep(10000);
});
}
// 结果:
// 10秒内提交10万个任务
// 创建10万个线程
// 内存爆满,OOM
正确写法
// ✅ 限制最大线程数
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 核心线程
50, // 最大线程(有上限)
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
🕳️ OOM死法3:任务对象过大
问题场景
public class BigTask implements Runnable {
private byte[] data = new byte[1024 * 1024]; // 1MB的数据
@Override
public void run() {
// 处理数据
process(data);
}
}
// 提交10000个任务
for (int i = 0; i < 10000; i++) {
executor.execute(new BigTask()); // 每个任务1MB
}
// 问题:
// 队列容量:1000
// 队列中的任务:1000 × 1MB = 1GB
// 正在执行的任务:10 × 1MB = 10MB
// 总内存:1GB+
解决方案
方案1:减小任务对象
// ✅ 只传ID,不传大对象
public class SmallTask implements Runnable {
private Long userId; // 只传ID(8字节)
@Override
public void run() {
// 从数据库查询数据(需要时才加载)
User user = userMapper.selectById(userId);
process(user);
}
}
方案2:分批提交
// ✅ 分批提交(控制队列大小)
List<User> allUsers = ...; // 100万用户
int batchSize = 1000;
for (int i = 0; i < allUsers.size(); i += batchSize) {
List<User> batch = allUsers.subList(i, Math.min(i + batchSize, allUsers.size()));
for (User user : batch) {
executor.execute(new ImportTask(user));
}
// 等待这批任务完成
Thread.sleep(1000);
}
🕳️ OOM死法4:ThreadLocal泄漏
问题场景
public class TaskWithThreadLocal implements Runnable {
private static final ThreadLocal<byte[]> BIG_DATA = new ThreadLocal<>();
@Override
public void run() {
// 设置大对象到ThreadLocal
BIG_DATA.set(new byte[1024 * 1024]); // 1MB
// 处理任务
process();
// 忘记remove了 ❌
// BIG_DATA.remove();
}
}
// 线程池(复用线程)
ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, ...);
// 提交10000个任务
for (int i = 0; i < 10000; i++) {
executor.execute(new TaskWithThreadLocal());
}
// 问题:
// 1. 线程池复用10个线程
// 2. 每个线程的ThreadLocal存储1MB数据
// 3. ThreadLocal没有remove,数据一直保留
// 4. 10个线程 × 1MB = 10MB(看起来不多)
// 5. 但如果任务不断提交,ThreadLocal不断set新数据
// 6. ThreadLocalMap的Entry[]数组不断扩容
// 7. 最终OOM
正确写法
public class TaskWithThreadLocal implements Runnable {
private static final ThreadLocal<byte[]> BIG_DATA = new ThreadLocal<>();
@Override
public void run() {
try {
// 设置ThreadLocal
BIG_DATA.set(new byte[1024 * 1024]);
// 处理任务
process();
} finally {
// 必须remove ✅
BIG_DATA.remove();
}
}
}
🕳️ OOM死法5:拒绝策略设置不当
AbortPolicy导致的问题
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.AbortPolicy() // 默认:抛异常
);
// 调用代码
try {
for (int i = 0; i < 100000; i++) {
executor.execute(new Task());
}
} catch (RejectedExecutionException e) {
// 吞掉异常 ❌
log.error("任务被拒绝", e);
}
// 问题:
// 1. 队列满了(1000个任务)
// 2. 线程数达到max(20个)
// 3. 后续任务全部被拒绝(98000个任务)
// 4. 异常被catch,任务丢失
// 5. 业务数据不完整
推荐:CallerRunsPolicy
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy() // 推荐:调用者执行
);
// 提交任务
for (int i = 0; i < 100000; i++) {
executor.execute(new Task()); // 队列满了,由当前线程(main)执行
}
// 效果:
// 1. 前1020个任务 → 线程池执行(队列1000 + 线程20)
// 2. 后续任务 → 当前线程执行(自然降速)
// 3. 任务不会丢失 ✅
// 4. 自动背压(提交速度 = 处理速度)
拒绝策略对比:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 抛异常 | 需要感知拒绝,手动处理 |
| CallerRunsPolicy | 调用者执行 | ⭐⭐⭐⭐⭐ 推荐(不丢任务) |
| DiscardPolicy | 静默丢弃 | 不重要的任务 |
| DiscardOldestPolicy | 丢弃最老的任务 | 保留最新任务 |
🎯 线程池OOM的根本原因
南北绿豆:"总结一下,线程池OOM的根本原因是什么?"
原因分析
核心矛盾:
任务提交速度 > 任务处理速度
结果:
任务堆积 → 队列满 → 内存溢出
示例:
任务提交速度:10万个/秒
任务处理速度:1000个/秒(10个线程 × 100个/秒)
1秒后堆积:10万 - 1000 = 9.9万个
10秒后堆积:99万个
100秒后堆积:990万个 → OOM
OOM的5种场景
| 场景 | 原因 | 示例 |
|---|---|---|
| 无界队列 | LinkedBlockingQueue()容量21亿 | Executors.newFixedThreadPool() |
| 线程数暴增 | maximumPoolSize=21亿 | Executors.newCachedThreadPool() |
| 任务对象大 | 每个任务1MB,队列1000个=1GB | new BigTask() |
| ThreadLocal泄漏 | 线程复用,ThreadLocal未remove | 每个线程累积数据 |
| 拒绝策略失效 | 异常被catch,任务丢失 | try-catch吞掉异常 |
🛡️ 线程池OOM的防御方案
防御1:永远不用Executors
// ❌ 禁止使用
Executors.newFixedThreadPool(10); // 无界队列
Executors.newCachedThreadPool(); // 无限线程
Executors.newSingleThreadExecutor(); // 无界队列
Executors.newScheduledThreadPool(10); // 无界队列
// ✅ 手动创建
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 核心线程数
20, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲时间
new ArrayBlockingQueue<>(1000), // 有界队列
new ThreadFactoryBuilder()
.setNameFormat("my-pool-%d")
.build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
阿里巴巴Java开发手册:
【强制】线程池不允许使用Executors创建,而是通过ThreadPoolExecutor的方式。
原因:
1. FixedThreadPool和SingleThreadPool:允许的请求队列长度为Integer.MAX_VALUE,可能堆积大量请求,导致OOM
2. CachedThreadPool和ScheduledThreadPool:允许创建的线程数量为Integer.MAX_VALUE,可能创建大量线程,导致OOM
防御2:设置合理的队列大小
// 队列大小设置公式
队列大小 = 峰值QPS × 平均任务执行时间 × 2
示例:
峰值QPS:1000
平均执行时间:1秒
队列大小 = 1000 × 1 × 2 = 2000
// 配置
new ArrayBlockingQueue<>(2000);
防御3:监控线程池状态
@Component
public class ThreadPoolMonitor {
@Autowired
private ThreadPoolExecutor executor;
@Scheduled(fixedDelay = 10000) // 每10秒
public void monitor() {
int activeCount = executor.getActiveCount(); // 活跃线程数
int queueSize = executor.getQueue().size(); // 队列任务数
long completedCount = executor.getCompletedTaskCount(); // 已完成任务数
int poolSize = executor.getPoolSize(); // 当前线程数
log.info("线程池监控 - 活跃:{}, 队列:{}, 已完成:{}, 线程数:{}",
activeCount, queueSize, completedCount, poolSize);
// 告警
if (queueSize > 800) { // 队列使用率80%
log.warn("线程池队列堆积,使用率: {}%", queueSize / 10.0);
}
if (activeCount == poolSize && poolSize == executor.getMaximumPoolSize()) {
log.error("线程池已满负荷运行");
}
}
}
防御4:限流
// 方案1:Guava RateLimiter(单机限流)
private final RateLimiter rateLimiter = RateLimiter.create(1000.0); // 每秒1000个
public void submitTask(Task task) {
// 限流
if (!rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
return; // 限流,拒绝
}
executor.execute(task);
}
// 方案2:Sentinel(分布式限流)
@SentinelResource(value = "submitTask", blockHandler = "handleBlock")
public void submitTask(Task task) {
executor.execute(task);
}
防御5:分批处理
// ✅ 分批提交(控制速度)
public void importUsers(List<User> users) {
int batchSize = 1000;
for (int i = 0; i < users.size(); i += batchSize) {
List<User> batch = users.subList(i, Math.min(i + batchSize, users.size()));
// 提交一批
for (User user : batch) {
executor.execute(new ImportTask(user));
}
// 等待队列消化
while (executor.getQueue().size() > 500) {
Thread.sleep(100);
}
}
}
🎓 面试标准答案
题目:线程池为什么会OOM?如何避免?
答案:
5种OOM场景:
1. 无界队列
- LinkedBlockingQueue()容量21亿
- 任务堆积导致OOM
- 解决:用有界队列(ArrayBlockingQueue)
2. 线程数暴增
- newCachedThreadPool()最大线程21亿
- 瞬间大量任务创建大量线程
- 解决:限制maximumPoolSize
3. 任务对象过大
- 每个任务1MB,队列1000个=1GB
- 解决:减小任务对象,只传ID
4. ThreadLocal泄漏
- 线程复用,ThreadLocal未remove
- 解决:finally中remove
5. 拒绝策略失效
- 异常被catch,任务丢失
- 解决:用CallerRunsPolicy
防御方案:
- 不用Executors,手动创建
- 有界队列(ArrayBlockingQueue)
- 限制最大线程数(合理值)
- 监控队列大小
- 限流(控制提交速度)
- ThreadLocal必须remove
- 拒绝策略用CallerRunsPolicy
🎉 结束语
一周后,哈吉米把所有线程池都改成了手动创建。
哈吉米:"改成有界队列后,再也没有OOM了!"
南北绿豆:"对,线程池的队列大小是关键,无界队列就是定时炸弹。"
阿西噶阿西:"记住:永远不用Executors,手动创建指定队列大小。"
哈吉米:"还有监控队列大小,超过80%就告警。"
南北绿豆:"对,提前发现问题,比OOM后补救强100倍!"
记忆口诀:
线程池OOM五死法,无界队列是祸首
Executors千万别用,手动创建最安全
队列大小要有界,ArrayBlockingQueue
任务对象要轻量,只传ID别传大对象
ThreadLocal必remove,监控队列防堆积