【硬核·避坑】线程池的核心参数,你真的用对了吗?从源码到OOM的血泪实战

最近在Code Review时,发现很多同事(包括一些工作3-5年的老手)在使用线程池时,还在使用Executors.newFixedThreadPool()或者随意定义corePoolSize和maximumPoolSize。直到有一天,线上服务因为线程池队列积压触发了Full GC,我们才意识到:不懂线程池的“饥饿”与“溢出”机制,写出来的代码就是定时炸弹。
今天这篇文章,我们不背八股文,直接从源码设计和业务场景出发,深入剖析ThreadPoolExecutor的核心参数逻辑,并总结一套真正能落地的工作流拒绝策略。
## 一、从一个“优雅”的OOM开始

先看一段“标准”的代码:
```java
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.execute(() -> {
    // 假设这里是一个批量处理数据库IO的任务
    Thread.sleep(1000);
});

问题来了: 如果这10个线程都在忙,此时来了第10001个任务,会发生什么?

  • 如果你回答“阻塞等待”,那你只答对了一半。

  • 如果你回答“抛出RejectedExecutionException”,那你可能没仔细看过源码。

正确答案(大多数人不知道的): newFixedThreadPool使用的是无界队列(LinkedBlockingQueue)。当线程数达到corePoolSize后,新任务会全部放入队列
如果任务生产速度远大于消费速度,队列会无限膨胀,最终撑爆你的JVM内存(OOM)。

这就是为什么《阿里巴巴Java开发手册》明确规定:不允许使用Executors创建线程池,而要用ThreadPoolExecutor自定义。

二、撕开源码:三个最容易被误解的参数

我们直接看ThreadPoolExecutor最全的构造函数:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)

很多博客把参数解释为“核心线程数”和“最大线程数”,但这只是字面意思。真正的核心逻辑是:线程数到底什么时候增加?

1. 误解一:当核心线程满了,就创建非核心线程?

错误。
源码execute()方法的执行顺序是:

  1. 如果当前工作线程数 < corePoolSize,直接新建线程执行任务。

  2. 如果当前工作线程数 >= corePoolSize,尝试将任务放入队列

  3. 如果队列已满,且当前工作线程数 < maximumPoolSize,才会新建非核心线程执行任务。

  4. 如果队列已满,且当前工作线程数 >= maximumPoolSize,执行拒绝策略

结论: 队列是介于核心线程和最大线程之间的“缓冲池”。 只有当队列塞爆了,才会破例创建临时线程。

2. 误解二:corePoolSize 设置越大越好?

错误。
如果corePoolSize设置过大,比如20,而你的CPU只有8核。那么上下文切换的损耗会非常大,且任务响应延迟反而不稳定。

经验值(IO密集型 vs CPU密集型):

  • CPU密集型(计算):corePoolSize = CPU核数 + 1

  • IO密集型(数据库、RPC):corePoolSize = CPU核数 * 2(或者更多,视阻塞系数而定,通常经验值为 2*Ncpu)

3. 误解三:keepAliveTime 只针对非核心线程?

不完全对。
默认情况下,keepAliveTime确实只作用于超过corePoolSize的线程。但是,如果你调用了allowCoreThreadTimeOut(true),那么核心线程在空闲时也会被回收。这在流量低峰期非常节省资源。

三、拒绝策略的终极选择:别再用DiscardPolicy了!

JDK自带的四种策略:

  1. AbortPolicy(默认):抛异常,适合关键业务,不容忍丢失。

  2. CallerRunsPolicy把任务回退给调用者线程执行

  3. DiscardPolicy:静默丢弃(极其危险,无感知丢数据)。

  4. DiscardOldestPolicy:丢弃队列头部的任务(也不推荐,容易造成业务数据错乱)。

实战推荐:
在绝大多数分布式微服务场景下,我推荐自定义拒绝策略,结合MQ重试报警打点
比如:

RejectedExecutionHandler handler = (r, executor) -> {
    // 1. 记录日志告警(发钉钉/邮件)
    log.error("线程池已满,任务被拒绝!队列大小:{}", executor.getQueue().size());
    // 2. 将任务持久化到Redis或DB,兜底补偿
    saveToRetryDb(r);
};

四、如何动态调整线程池?别重启!

生产环境最怕什么?改配置要重启。

我们可以利用ThreadPoolExecutor提供的set方法,结合配置中心(Apollo/Nacos)实现动态调优:

@RefreshScope // 以Spring Cloud为例
public class DynamicThreadPool {
    @Value("${thread.pool.core:10}")
    private int core;
    
    @Value("${thread.pool.max:20}")
    private int max;
    
    private ThreadPoolExecutor executor;

    @PostConstruct
    public void init(){
        // 初始化...
    }
    
    // 监听配置变更
    public void refresh(){
        executor.setCorePoolSize(core);
        executor.setMaximumPoolSize(max);
        // 注意:setCorePoolSize 能动态调整,如果当前线程数小于新core,会立即新增。
    }
}

五、总结:一张图看懂线程池工作流(建议收藏)

为了加深记忆,我把execute()的逻辑画成了伪代码(JDK 8源码简化版):

if (workerCount < corePoolSize) {
    addWorker(command, true); // 核心线程
    return;
}
if (workQueue.offer(command)) { // 入队(此时队列未满)
    // 二次校验,预防线程池状态突变
    return;
}
if (workerCount < maximumPoolSize) {
    addWorker(command, false); // 非核心临时线程
    return;
}
reject(command); // 终极拒绝

最后送大家一句忠告:
线程池用得好,系统稳如狗;线程池用不对,半夜修BUG。

如果你有更奇葩的“线程池踩坑”经历,欢迎评论区交流。别忘了点赞、收藏,关注我,带你深挖更多JAVA底层细节。

各位大佬,你们的业务场景中,线程池队列是选ArrayBlockingQueue还是LinkedBlockingQueue?为什么?
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值