线程池一言不合就给你内存溢出

摘要:从一次"线上应用突然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)10001000入队,其余拒绝安全 ✅

🕳️ 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个=1GBnew 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

防御方案

  1. 不用Executors,手动创建
  2. 有界队列(ArrayBlockingQueue)
  3. 限制最大线程数(合理值)
  4. 监控队列大小
  5. 限流(控制提交速度)
  6. ThreadLocal必须remove
  7. 拒绝策略用CallerRunsPolicy

🎉 结束语

一周后,哈吉米把所有线程池都改成了手动创建。

哈吉米:"改成有界队列后,再也没有OOM了!"

南北绿豆:"对,线程池的队列大小是关键,无界队列就是定时炸弹。"

阿西噶阿西:"记住:永远不用Executors,手动创建指定队列大小。"

哈吉米:"还有监控队列大小,超过80%就告警。"

南北绿豆:"对,提前发现问题,比OOM后补救强100倍!"


记忆口诀

线程池OOM五死法,无界队列是祸首
Executors千万别用,手动创建最安全
队列大小要有界,ArrayBlockingQueue
任务对象要轻量,只传ID别传大对象
ThreadLocal必remove,监控队列防堆积