2. corePoolSize参数详解与最佳实践:核心线程数的作用、与CPU/IO的关系、设置原则与常见误区

聊线程池,第一个绕不开的参数就是 corePoolSize。很多同学觉得这玩意儿就是“线程池里固定有几个线程”,嗯,大致没错,但只对了一半。我见过不少项目,corePoolSize随手填个10、20,结果线上CPU飙到90%,或者接口响应慢得像蜗牛。今天咱们就把这个参数掰开揉碎了讲讲。

2.1 核心线程数到底管什么?

先说定义。核心线程数,就是你希望线程池“常驻”的线程数量。注意“常驻”这两个字。非核心线程(也就是超过corePoolSize的部分)是可以被回收的,但核心线程默认不会被回收——除非你设置了 allowCoreThreadTimeOut(true)。

我刚开始用线程池时,以为corePoolSize越大越好。结果呢?一次压测,我填了个50,任务倒是处理得飞起,但系统负载直接爆表。后来才明白:corePoolSize决定了线程池的“基础吞吐能力”,而不是“最大吞吐能力”。

核心要点:corePoolSize是线程池的“常驻兵力”。任务来了,先看有没有空闲的核心线程,没有就创建新的核心线程,直到达到corePoolSize。核心线程满了,任务才会进队列。

看看ThreadPoolExecutor的构造方法,你就知道它多重要了:

public ThreadPoolExecutor(
    int corePoolSize,      // 常驻线程数
    int maximumPoolSize,   // 最大线程数
    long keepAliveTime,    // 非核心线程空闲存活时间
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
)

这里有个常见的理解误区:很多人以为“corePoolSize满了→直接创建新线程”,其实不是。corePoolSize满了,任务会先放队列。队列满了,才会继续创建线程直到maximumPoolSize。这个执行顺序很重要,我专门画了个流程图:

核心线程数决策流程图 任务到达 核心线程数 < corePoolSize 是 → 创建新核心线程 否 是 工作队列是否已满? 否 → 放入队列等待 否 任务入队等待 是 线程池已满? 线程数 < maxPoolSize 是 创建新线程(非核心) 否 执行拒绝策略 ⚠ 关键执行顺序: 核心线程 → 任务队列 → 非核心线程(maximumPoolSize) → 拒绝策略(RejectedExecutionHandler)

你看,这个流程其实很清晰。但我要说的是——核心线程数设置得合不合理,直接决定了你的系统是“丝滑”还是“卡死”。

2.2 CPU密集型 vs IO密集型:核心线程数怎么算?

这个问题面试常问,但真正做对的人不多。说白了,就是看你的任务“卡在哪儿”。

CPU密集型任务:任务主要占用CPU计算资源,比如视频编码、图像处理、数学计算。这类任务,线程一旦多了,CPU上下文切换的开销反而会拖慢速度。我个人的经验是:corePoolSize = CPU核心数 + 1。

小技巧:为什么是N+1而不是N?算是一个“保险”——万一某个线程因为缺页中断或系统异常被阻塞,多出来这个线程可以顶上,保证CPU不闲置。

IO密集型任务:任务大部分时间在等待IO,比如数据库查询、远程RPC调用、文件读写。线程发起IO请求后,CPU基本是空闲的。这时候,线程数可以多一些。corePoolSize = 2 × CPU核心数,甚至更高。

我整理了一个对照表,方便你快速参考:

任务类型 特点 核心线程数建议 我踩过的坑
CPU密集型 大量计算,极少等待 N+1(N为CPU核数) 我曾设成N×2,结果CPU上下文切换高达30%
IO密集型 大量等待,少量计算 2N ~ 4N 设成8核×4=32,结果线程太多导致内存溢出
混合型 计算+等待都有 根据阻塞系数计算 忽略阻塞系数,直接用2N,结果CPU跑不满

这里需要多说一句:公式只是起点,压测才是终点。你想想看,不同机器的CPU架构不同,缓存大小不同,甚至JVM的GC策略都会影响。所以我习惯的做法是:先用公式算个基准值,然后逐步压测调整。

2.3 什么时候不该用默认公式?

有些场景,上面的公式就不太灵了。

场景一:线程池被多个业务共享。比如一个全局线程池既处理短信发送(IO密集),又处理日志解析(CPU密集)。这时候你怎么设?按IO密集设,CPU任务就会抢占资源;按CPU密集设,IO任务就排队排到超时。我的建议是——拆池。不同的业务类型,用不同的线程池。别图省事塞进一个池子里。

场景二:极端IO等待场景。举个例子,调用第三方支付接口,平均响应时间2秒。这种情况下,一个线程处理一个请求,2秒内CPU基本是空闲的。你能开多少线程?理论上是:线程数 = QPS × 单个请求处理时间。假如你希望QPS达到1000,每个请求耗时2秒,那就需要2000个线程。但现实中,2000个线程的上下文切换开销非常恐怖。我做过一次压测,2000线程时,光是线程切换就占了CPU的40%。

警醒:我曾经天真地以为线程越多越好,把corePoolSize设成500,结果线上OOM了。每个线程默认要有1MB左右的栈空间,500个线程就是500MB。你还得留内存给堆和元空间啊!

场景三:有界队列 + 高并发突发流量。很多同学设置线程池时,只关注corePoolSize和maximumPoolSize,忽略了队列的作用。你想想看,如果队列设得太大(比如无界队列),任务就会疯狂堆积,corePoolSize设得再合理也没用。反之,队列设得太小,一有流量尖峰就触发拒绝策略。这里有个平衡点,后面讲队列参数时我们再细聊。

2.4 常见误区与避坑指南

我在Code Review中,看到过太多类似的错误。下面列几个典型的:

  • 误区一:corePoolSize = maximumPoolSize
    这样做等于关闭了“弹性扩容”机制。任务再多,也不会创建非核心线程,队列满了直接拒绝。我建议两者保持差距,让线程池能应对突发流量。
  • 误区二:corePoolSize设置过大,不顾系统负载
    比如8核机器设置corePoolSize=100。大量线程争抢CPU,任务反而处理得更慢。记住:不是线程越多,吞吐量就越高。
  • 误区三:只调corePoolSize,不调队列
    核心线程数再合理,队列设计不合理也白搭。举个例子,你corePoolSize=10,队列容量=10000,高峰期突然涌入20000个任务。结果呢?10000个任务在队列里排队,等得花儿都谢了。
  • 误区四:靠感觉填数值,从不压测
    我见过一个项目,corePoolSize填20,理由是“看着顺眼”。这种“感觉流”做法,风险极高。哪怕你按公式算好了,也要做压测验证。

我曾经接手过一个线上事故:用户反馈页面响应极慢,查监控发现线程池的核心线程数设成了CPU核数×10,但任务全是IO密集型。按理说应该没问题,但实际情况是——每个IO任务里混了一段复杂的数据加密计算(CPU密集),导致大量线程在轮询等待加密完成。最后我拆成了两个线程池,一个处理纯IO,一个处理加密计算,问题解决了。

2.5 总结一下我的个人经验

嗯,关于corePoolSize,啰嗦了不少。最后给你三个可以立刻用起来的经验:

  • 纯CPU计算:corePoolSize = N+1。N是CPU核数,别问为什么+1,问就是避免线程因缺页中断或系统异常完全闲置。
  • 纯IO等待:从2N开始压测,逐步往上加,直到CPU使用率接近70%~80%。不要超过4N,超过后线程切换成本会陡增。
  • 混合型任务:先估算任务的阻塞系数(IO等待时间 ÷ 总执行时间),然后套公式:核心线程数 = CPU核心数 × (1 + 阻塞系数)。计算复杂?没关系,你跑一轮压测,观察CPU使用率,调整到70%~80%就对了。

最后说一句:没有完美的数字,只有经过验证的数字。线程池参数调试,理论上可以讲三天三夜,但你实际动手跑一轮压测,比什么都强。