做Java开发这些年,线程池是我几乎每个项目都要碰一遍的东西。坦白讲,线程池参数怎么定、拒绝策略怎么选,很多经验都是踩坑踩出来的。尤其是面试必考的“CPU密集型配多少个线程、IO密集型配多少个线程”这道题,网上答案一堆,什么N+1、2N、N*(1+WT/CT),但真正把公式背后的逻辑讲清楚、并且能在线上环境落地的内容并不多。
这篇文章我打算换个讲法:先带你完整走过线程池的任务提交流程,搞清楚拒绝策略为什么会有这几种、核心线程数和最大线程数到底怎么配合,然后再回到CPU密集型和IO密集型的黄金公式,给出一套可以直接抄到项目里的配置思路。适合正在做Java服务端开发、对线程池知道个大概但不敢随便改参数的兄弟,也适合准备面试、想系统梳理并发知识的同学。
1. 拒绝策略:线程池被逼到墙角的应对方案
很多人对线程池的第一印象就是那几个构造参数,但真正出问题时,往往是拒绝策略先站出来报警。拒绝策略不是让你配置完就忘掉的东西,它是线程池的兜底机制,决定了系统在极端压力下是优雅降级、硬扛到底,还是直接崩溃。
1.1 四种内置拒绝策略,先搞懂各自脾气
JDK自带的RejectedExecutionHandler有四种实现,很多人背得出来名字,但真到选型的时候就会开始含糊。我用一个表格把它们的行为、场景和隐藏的风险一次说清楚:
| 策略 | 行为 | 典型场景 | 风险点 |
|---|---|---|---|
| AbortPolicy(默认) | 直接抛RejectedExecutionException | 任务不允许丢失,调用方要感知 | 调用方不捕获会中断主流程,需要有对应重试或补偿机制 |
| CallerRunsPolicy | 由提交任务的线程自己执行该任务 | 任务必须执行,能接受调用线程阻塞 | 高并发下提交线程被拖慢,上游接口响应时间变长 |
| DiscardPolicy | 静默丢弃新任务,不通知 | 允许丢弃非核心任务 | 无任何日志,线上出了问题根本不知道任务丢了 |
| DiscardOldestPolicy | 丢弃队列中最老的未处理任务,然后尝试提交新任务 | 可接受丢弃积压的旧任务 | 被丢弃的任务可能包含重要状态,需要确认业务能容忍 |
这里我最想强调的一点:千万别因为Discard开头就乱选。DiscardPolicy这个策略连日志都不打,我亲眼见过某团队因为用这个策略,线上丢了大量定时同步任务,最后对账才发现数据少了一大截。你如果真要丢弃任务,也得先确认业务侧能感知到“任务被丢弃”这件事。
1.2 任务提交流程:为什么拒绝策略是最后一道防线
要理解拒绝策略,你得先搞清楚任务提交后经历了什么。ThreadPoolExecutor的提交流程是有一套严格顺序的:
提交新任务时: 1. 当前工作线程数 < corePoolSize → 创建核心线程执行 2. 当前工作线程数 >= corePoolSize → 尝试放入 workQueue 3. workQueue 已满 → 尝试创建非核心线程执行(需满足当前工作线程数 < maximumPoolSize) 4. 工作线程数 >= maximumPoolSize → 触发RejectedExecutionHandler注意第2步和第3步的顺序,很多人会搞反。线程池的策略是“先排队,再扩容”,不是核心线程满了立刻创建非核心线程。这意味着,如果你的队列设得很大,非核心线程可能长时间都不会被创建,maximumPoolSize形同虚设。
这个流程还解释了另一个现象:线程池关闭之后提交任务,也会触发拒绝策略。因为线程池状态不再是RUNNING,任务提交入口直接就拦住了。遇到这种问题,先检查是不是有人在线程池shutdown之后还在继续提交任务,别一上来就怀疑参数有问题。
1.3 自定义拒绝策略:日志、监控、补偿一个都不能少
生产环境中,我非常建议你自定义一个拒绝策略,而不是直接用JDK默认的。不是说默认的不能用,而是默认策略要么抛异常要么静默丢弃,都缺少关键的现场信息。真正出问题时,你需要知道当前线程数、活跃线程数、队列积压量、总任务数,才能判断到底是流量突增、下游变慢,还是线程数配小了。
我常用的一个自定义策略长这样:
public class LogAndAlertRejectedExecutionHandler implements RejectedExecutionHandler { private static final Logger LOGGER = LoggerFactory.getLogger(LogAndAlertRejectedExecutionHandler.class); private final RejectedExecutionHandler fallback; public LogAndAlertRejectedExecutionHandler(RejectedExecutionHandler fallback) { this.fallback = fallback; } @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { LOGGER.error("task rejected, poolSize={}, activeCount={}, queueSize={}, taskCount={}", executor.getPoolSize(), executor.getActiveCount(), executor.getQueue().size(), executor.getTaskCount()); // 兜底逻辑走原有策略,保留异常抛出或调用方执行等行为 fallback.rejectedExecution(r, executor); } }这里有两层含义:外层负责留痕和报警,内层保留原策略的语义。比如你在外面包一层自定义策略,内层还是AbortPolicy,那异常还会正常抛,但至少抛之前留下了完整的上下文日志,排查起来会快很多。
不过也有个坑要提醒:不要在rejectedExecution方法里做太重的操作,比如同步发送短信、调用远程告警接口。被拒绝的任务可能是一大批,重操作会让本来就紧张的线程池雪上加霜。常规做法是异步上报监控指标,或者只记录日志,由独立的监控系统去聚合。
2. 任务类型画像:CPU密集还是IO密集,不能靠感觉猜
线程池参数之所以有“黄金公式”,前提是你得先搞清楚自己的任务属于什么类型。很多人上来就问“8核机器该配多少线程”,但同一台机器上跑批量计算任务和跑大量HTTP调用,线程数完全是两个量级。CPU密集和IO密集,绝不是拍脑袋说“大概是CPU密集吧”就能定的。
2.1 CPU密集型任务的特征与识别方法
CPU密集型任务的核心特征是:计算过程中几乎没有等待,线程大部分时间都处于RUNNABLE状态。典型场景包括图像处理、加解密、复杂排序、编解码、大文件解析等。
怎么识别?最直接的办法是看线程状态。用jstack导出线程快照,统计RUNNABLE线程的比例;再用top -H -p 看进程内各线程的CPU占用。如果大部分线程CPU占用都很高、线程状态基本是RUNNABLE,任务类型基本可以判断为CPU密集型。
我踩过的最典型的一个坑是:某批处理任务是做大量数学计算的,但里面夹杂了一行不起眼的数据库查询。结果线程状态里WAITING占比不低,按CPU密集配置N+1明显不够,吞吐上不去。所以判断的时候千万别只看业务描述,要看实际运行时线程都在干什么。
2.2 IO密集型任务的特征与识别方法
IO密集型和CPU密集型正好相反,线程大部分时间都在等待外部响应。等待的场景包括网络请求、RPC调用、数据库读写、磁盘IO、消息队列消费等。这些操作的共同点是:CPU本身很闲,但线程被阻塞在等待上。
识别IO密集型任务,同样可以用jstack,但看的是WAITING和TIMED_WAITING状态的比例。如果大量线程处于这两种状态,CPU利用率又不高,那就是IO密集。我见过一些服务,线程数配到了核心数的8倍,CPU也只有30%上下,因为线程全在等下游HTTP响应。
有个实操技巧:用命令行工具对线程状态做实时统计,比如每隔5秒采样一次,连续采样几次,看状态占比是否稳定。这样比看单个时间点的快照更能反映真实情况,因为单次快照可能正好碰上瞬时抖动。
2.3 混合型任务:拆分而不是硬凑
现实中的很多任务其实是混合型的,比如“拉取数据 → 计算 → 写回结果”,既有IO等待又有CPU计算。这种任务最怕你直接套公式,算出来一个不上不下的线程数,结果两头都不讨好。
我的处理习惯是:尽量把混合任务拆开。先把IO拉取和计算拆成两个阶段,各自用独立的线程池管理;或者用CompletableFuture编排,让IO部分和计算部分分属不同的线程池。
如果拆不了,比如任务本身就是一个不可分割的整体,那只能估算WT和CT的比例了,这个比例是IO密集型公式的关键输入。估算方法也比较粗糙:在任务里埋点,统计总耗时、计算耗时、等待耗时各是多少,取平均。有了这个比例,你再套公式才有意义,否则公式只是个摆设。
3. 黄金公式:从推导到落地
网上关于线程数的公式版本很多,我先把最核心的两个写出来,然后逐条拆解它们的推导逻辑和适用前提。记住,公式只是起点,不是终点,任何参数最终都要经过压测验证。
3.1 CPU密集型:为什么是N+1而不是N
CPU密集型任务的最优线程数计算式为:
最优线程数 = CPU核心数 + 1先解释为什么是N+1,而不是N。一个CPU核心同一时刻只能运行一个线程,理论上配N个线程让每个核心都跑满就行,但实际运行中线程会因为偶发的缺页中断、内存停顿、操作系统调度的随机性而短暂让出CPU。这时候如果每个核心只挂了一个线程,CPU就会出现短暂的空闲。多加的这个线程,就是用来填补这些短暂空档的。
有人说CPU密集型应该配2N,我在机器上实测过,线程数从N+1提升到2N之后,CPU利用率并不会翻倍,反而因为上下文切换增多,任务总耗时反而变长了。在纯CPU密集的场景下,2N只会增加调度开销。当然,如果机器上还要跑别的进程,或者你的计算任务里有少量隐藏的等待,那2N可能碰巧更合适,但这就已经不属于纯CPU密集的范畴了。
3.2 IO密集型:N*(1+WT/CT)是怎么推出来的
IO密集型任务的最优线程数计算式为:
最优线程数 = CPU核心数 * (1 + 等待时间 / 计算时间)这个公式的推导核心在于单核CPU的“时间复用”。假设一个任务的计算时间是CT(Compute Time),等待时间是WT(Wait Time)。单个线程处理这个任务的花费是CT + WT。在这段时间里,CPU真正忙碌的时间只有CT,剩下的WT时间内CPU是空闲的。既然CPU空闲,就可以让另一个线程跑进来,用这段等待时间去执行别的任务。
所以理想的并发度等于 (CT + WT) / CT,也就是1 + WT/CT。再乘上CPU核心数,就能算出系统能同时支撑的最大线程数。
举个例子:某个任务平均计算耗时50ms,等待IO耗时150ms,则WT/CT=3。8核机器上,理论最优线程数 = 8 * (1 + 3) = 32。这个数字比2N(16)明显要高,原因是等待占了大头,线程才有更多机会在等待间隙切换执行。
需要注意,这个公式有两个隐含前提:一是CPU是真正的瓶颈资源,二是任务的等待和计算是均匀交替发生的。如果系统里锁竞争严重、内存带宽受限、或者下游服务响应时间波动极大,公式算出来只能是参考值,必须通过压测修正。
3.3 完整计算示例:从理论到可落地的配置
假设我们有一个网关服务,机器是8核16G,任务类型为调用下游HTTP接口。埋点统计显示:单任务计算耗时约5ms,等待下游响应耗时约95ms,WT/CT=19。
第一步,计算理论核心线程数:
核心线程数 = 8 * (1 + 19) = 160160个线程乍一看有点多,但每个线程默认栈大小一般也就1MB左右,160个线程的栈内存大概160MB上下,16G的机器完全扛得住。但在实际业务中,我会先打个折扣,比如按理论值的70%到100%配置,先配128,压测看效果再往上加。
第二步,定最大线程数。最大线程数的作用是应对流量脉冲。我会在核心线程数基础上再留20%到30%的余量,也就是配到200左右。这里要注意,只有当队列满时才会创建非核心线程,所以最大线程数不是配了就会用,它只是给系统留了扩容的天花板。
第三步,选队列和拒绝策略。这个网关服务要求任务尽量不丢,我会选有界队列ArrayBlockingQueue(2000),拒绝策略用自定义的LogAndAlertRejectedExecutionHandler包一层CallerRunsPolicy,宁可提交线程慢一点,也不能直接抛异常让用户请求失败。
3.4 容器环境下的核心数陷阱
公式里最关键的一个输入就是CPU核心数。很多人直接用Runtime.getRuntime().availableProcessors(),但在容器环境下这个值可能不是你想的那样。
老版本JDK在Docker容器里运行时会读取宿主机的/proc/cpuinfo,导致availableProcessors()返回宿主机的核心数而不是容器限制的核心数。比如宿主机是8核,容器限制只能用2核,但JVM认为自己有8个核,按公式算出来的线程数直接翻了好几倍,线程一多,上下文切换暴增,性能反而更差。
这个问题在新版JDK(JDK 8u191以上、JDK 10以上)里已经默认处理了,可以通过-XX:+UseContainerSupport启用容器感知。如果你还在用老版本JDK,可以显式指定:
-XX:ActiveProcessorCount=2更稳妥的做法是:不要把核心数写死在代码里,而是通过环境变量或配置中心传入,比如:
int cpuCore = Integer.parseInt(System.getProperty("app.cpu.core", String.valueOf(Runtime.getRuntime().availableProcessors())));这样即使部署环境变化,也可以不改代码直接调整线程数。
4. 队列选择:线程池调优里最容易翻车的环节
我可以负责任地说,线上线程池出问题,十有八九出在队列上,而不是线程数上。很多人把核心线程数、最大线程数研究得门清,但队列随便new一个new LinkedBlockingQueue<>()就交差了,这个习惯在流量正常时没啥问题,流量一大就会连环爆雷。
4.1 三种内置队列的内部机制
JDK线程池常用的队列有三种:SynchronousQueue、LinkedBlockingQueue、ArrayBlockingQueue。它们的机制差异很大,我整理成一张表:
| 队列 | 容量 | 工作机制 | 典型使用场景 |
|---|---|---|---|
| SynchronousQueue | 0 | 不缓存任务,提交的任务必须直接交给线程处理 | 配合较大maximumPoolSize,实现“无空闲线程就立即创建新线程”的效果 |
| LinkedBlockingQueue | 默认无界,可指定有界 | 链表结构,生产者和消费者通过锁同步 | 追求吞吐、任务量平稳的场景;无界时要非常小心 |
| ArrayBlockingQueue | 必须有界 | 数组结构,支持公平/非公平访问 | 业务能容忍有限积压、希望队列长度可控的场景 |
SynchronousQueue是个很特殊的队列,它本身不存任务,线程池检查队列是否满(空)时,只要没有空闲工作者线程,就会尝试创建新线程。这个特性意味着它不会让任务在队列里等着,而是逼着线程池要么立即处理,要么拒绝。适合对延迟敏感、任务量可控的场景,但拒绝策略触发也会更频繁。
LinkedBlockingQueue默认无界,这是我极力不推荐直接使用的,原因后面细说。它做有界队列用的时候,性能通常不错,因为链表结构在并发场景下有比较成熟的实现。但你既然要限长度,为什么不直接用ArrayBlockingQueue?主要是考虑消费速度平滑、队列波动大的场景下,链表在增长时对内存的使用更灵活。
ArrayBlockingQueue是固定容量的数组实现,必须指定初始大小。它最直观,队列满不满一眼就能判断,推荐在业务线程池中优先使用。
4.2 队列长度到底怎么算
队列长度没有绝对公式,但要基于业务容忍度来算。我一般从两个维度思考:一是内存容忍度,二是延迟容忍度。
内存维度:队列里每个任务都是一个Runnable对象,加上它引用的业务数据,可能占几KB到几十KB不等。假设单个任务平均占20KB,2000个任务就占大概40MB内存。这个量在大多数服务里都能接受,但如果任务里还引用了大对象或数组,内存占用就会翻好几倍,所以要结合任务实际大小估算。
延迟维度:队列长度决定了任务最长要等多久才被执行。假设单任务平均耗时100ms,8个线程每秒能消费约80个任务,队列里有1600个任务时,最后进入队列的任务大约要等20秒才能被处理。如果业务上无法接受20秒的延迟,就必须缩短队列,或者加大线程数提高消费速度。
还有一个更实用的算法:用流量脉冲来估算。假设业务平均每秒到达80个任务,峰值每秒到达200个,持续10秒。峰值期间每秒超出的任务量是120个,持续10秒就多出1200个任务。队列至少要能装下1200个,再留一点余量,我通常会取1600到2000。
4.3 队列与拒绝策略的联动关系
队列和拒绝策略不是孤立的,它们共同决定了线程池在高压下的表现曲线。
如果队列是无界的,那么队列永远装不满,线程数永远不会增长到maximumPoolSize,拒绝策略也永远不会触发。这看起来是好事,但代价是任务无限堆积,内存最终被撑爆,进程OOM。我见过一次线上事故:某消费者服务用了无界队列,上游突然流量翻倍,消费速度跟不上,队列里积累了上百万个任务对象,直接内存溢出,整个服务重启之后流量还在积压,又再次溢出,形成了死循环。
反过来,如果用SynchronousQueue配合较大的maximumPoolSize,提交的任务不会被队列缓存,线程池会尽量创建新线程来处理,线程数增长非常快,拒绝策略更容易触发。这个设计适合能快速扩容、任务量受控的场景。
我的原则很简单:生产环境一律使用有界队列,长度根据业务容忍度来定,配合自定义拒绝策略兜底。无界队列只会把问题从“丢任务”变成“服务崩溃”,前者至少还有补偿手段,后者是真正的灾难。
5. 三个常见场景的线程池配置清单
理论讲完了,我直接给出三个真实业务场景的完整配置方案。这三个场景基本覆盖了日常开发中的常见线程池用法,你可以直接参考,然后根据自己的压测结果做微调。
5.1 场景一:高并发Web接口调用下游RPC
这是最常见的场景:接口收到请求后,需要调用下游RPC服务获取数据再返回。任务特征是等待时间远大于计算时间,明显的IO密集型。
机器配置:8核16G。 埋点数据显示:单任务计算约30ms,等待下游响应约120ms,WT/CT=4。
核心线程数 = 8 * (1 + 4) = 40。但考虑到机器还要跑其他业务,我实际配置时会稍作折中,先配32,最大线程数配48到64。队列用ArrayBlockingQueue(2000),拒绝策略用自定义包装的CallerRunsPolicy。
ThreadPoolExecutor rpcExecutor = new ThreadPoolExecutor( 32, 64, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), namingThreadFactory("rpc-call"), new LogAndAlertRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()) );为什么这里用CallerRunsPolicy?因为用户请求不能直接失败,即使线程池达到上限,让提交请求的线程自己执行一次任务,也就是让Tomcat处理线程去调用RPC,虽然会拖慢接口响应,但至少不会丢请求。当然这要求你的接口调用是同步的,且调用线程能接受阻塞。
5.2 场景二:批量数据处理(CPU密集)
某定时任务每天凌晨解析几十万条日志文件,做字段提取、格式转换、简单统计。任务特征基本是CPU计算,几乎无等待。
机器配置:4核8G。 核心线程数 = 4 + 1 = 5,最大线程数 = 8。队列用ArrayBlockingQueue(500),拒绝策略用AbortPolicy,由任务调度层捕获异常后重试。
ThreadPoolExecutor parseExecutor = new ThreadPoolExecutor( 5, 8, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), namingThreadFactory("log-parse"), new ThreadPoolExecutor.AbortPolicy() );队列为什么设这么小?批处理任务对实时性不敏感,但积压过多会影响本次任务的完成时间。如果队列里堆了几千个待解析的任务,最晚的日志可能要好几个小时才能处理完,这就不满足业务时效了。所以宁可让它触发拒绝策略,由调度层把任务重新塞回去,也不要在内存里堆积大量待处理数据。
5.3 场景三:异步消息消费
从MQ拉取消息后,需要执行业务逻辑:查库、调用内部服务、再更新状态。这里既有IO等待也有少量计算,但整体偏IO密集。
机器配置:8核16G。 消息处理链路中,单条消息的平均耗时为50ms,其中计算约8ms,等待IO约42ms,WT/CT≈5。
按公式算核心线程数 = 8 * (1 + 5) = 48,但我实际配置时会取稍微保守的值,比如40,最大值取48。队列用ArrayBlockingQueue(1000),拒绝策略用自定义策略——把被拒绝的消息重新投递到延迟队列,稍后重试,而不是丢弃。
ThreadPoolExecutor msgExecutor = new ThreadPoolExecutor( 40, 48, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), namingThreadFactory("mq-consumer"), new ReEnqueueRejectedExecutionHandler() );需要注意一点:做消息消费时,如果业务要求消息顺序性,线程池方案本身并不合适,因为多线程并发处理天然会打乱顺序。那时应该考虑按业务主键路由到固定线程,或者退化为单线程消费加批量优化。线程池适合对顺序不敏感的消息处理。
5.4 线程工厂:这个细节别偷懒
上面几个配置里的namingThreadFactory都很重要。线程池里的线程如果都用默认命名,比如“pool-1-thread-1”,出问题时你根本不知道异常来自哪个业务线程池。自定义线程工厂命名,成本极低,收益极大。
public static ThreadFactory namingThreadFactory(String prefix) { AtomicInteger seq = new AtomicInteger(1); return r -> { Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement()); t.setDaemon(false); return t; }; }我还会顺带设置异常处理:在线程run方法的外层包一层try-catch,记录线程意外终止的日志。这样即使任务抛了未捕获异常导致线程死亡,也能在日志里第一眼看到。
6. 线上问题排查与参数调优实录
参数配好只是开始,真正的挑战在线上。我把这些年遇到的典型线程池问题整理一下,按排查思路来写,希望能省去你一轮又一轮的抓瞎过程。
6.1 线上疯狂抛RejectedExecutionException,先看什么?
遇到RejectedExecutionException,第一反应不要是“我参数配小了”,而是要分情况:
如果是线程池正常运行期间突然开始抛异常,先看三个指标:activeCount是否已经打到maximumPoolSize、queueSize是否打满、taskCount是否在持续增长。如果三者都处于极限,说明确实是任务到达速度超过了消费能力。这时候要往下游查:是不是RPC响应变慢了?是不是某个依赖服务性能衰减了?
如果活跃线程数没满、队列也没满就开始抛异常,那基本可以断定是线程池状态出问题了,比如有人调用了shutdown()之后还在提交任务。这种情况属于代码bug,参数怎么调都没用。
如果只在流量高峰偶尔抛几个,而且业务可以容忍,那可以接受,但一定要有监控和日志,因为拒绝次数如果缓慢上升,往往预示着下游在变慢。
6.2 线程数为什么一直上不去?
我在一次优化中碰到过这个现象:maximumPoolSize配了200,但监控显示线程数始终趴在corePoolSize附近,活跃线程数也很低,队列却一直在增长。
这正好印证了任务提交流程里“先入队再扩容”的顺序。队列还有大量空余时,新任务会一直排队,非核心线程根本不会被触发创建。这其实就是流量没有超过队列容量,系统在用自己的“缓冲”来吸收压力。
这种设计对整体稳定性是好的,但对延迟敏感型业务不友好。任务在队列里等了很久,调用方早就超时了。这时候要么把corePoolSize调大,让更多线程处理任务;要么把队列缩短,让压力更早暴露出来并去创建非核心线程。
6.3 无界队列导致OOM:最经典的翻车事故
这是线程池最常见也是最严重的事故之一。用无界队列LinkedBlockingQueue + 固定线程数,生产速度长期高于消费速度,队列里的任务越堆越多,最终堆内存被打爆,服务反复OOM重启。
处理过一次之后,我对无界队列是零容忍的。生产环境一律用有界队列,而且队列长度的上限必须通过内存估算和延迟评估来确定。就算业务实在无法接受拒绝策略,我宁可让提交线程休眠重试,也不让队列无限堆积。
如果你现在代码里还有new LinkedBlockingQueue<>()这种写法,建议尽快改成有界队列,哪怕长度设大一点,比如5000或10000,也比你无界安全得多。
6.4 线程池参数速查与自检清单
最后给一张速查表,方便你排查和复盘的时候对照:
| 检查项 | 方法/API | 参考标准 |
|---|---|---|
| 线程数量 | executor.getPoolSize() | 正常稳定在corePoolSize附近,波动说明压力在增长 |
| 活跃线程 | executor.getActiveCount() | 高峰期最多接近maximumPoolSize |
| 队列积压 | executor.getQueue().size() | 不应长期超过队列容量的80% |
| 拒绝次数 | 统计RejectedExecutionException | 一般应为0,可容忍场景也要有明确监控 |
| 任务完成数 | executor.getTaskCount() | 长期不增长可能说明线程卡死或锁竞争 |
| CPU占用 | top -H -p PID | CPU密集型应接近核心数;IO密集型通常不会打满 |
我自己每次上线线程池相关代码,都会做一遍自检:
- 线程池有没有自定义ThreadFactory命名?
- 队列是不是有界的,长度有没有经过业务评估?
- 拒绝策略会不会记录日志、触发报警?
- 核心线程数、最大线程数是不是根据理论公式和压测结果定的,而不是拍脑袋?
- 线程池关闭前,有没有确保不再有任务提交进来?
线程池调优不是一次性的工作,它跟业务流量、下游稳定性、机器配置都强相关。你要做的是通过压测找到基线,再通过监控观察变化,小步调整,让它慢慢逼近当前环境下的最优值。公式给的是起点和方向,真正的答案还是要靠线上的数据和一次次踩坑沉淀出来。