
高并发这个话题几乎每个Java后端都会接触到。特别是当流量从几千QPS涨到几万QPS时线程池那些参数就不再是面试八股文里背的公式而是实打实决定服务生死的关键。我这些年做过不少电商、支付类的接口优化踩过的坑大多集中在三个地方参数拍脑袋乱配、队列选型不当、以及只看手册不监控。这篇文章就把线程池在高并发场景下的参数配置逻辑、阻塞队列选型、并发隔离实践和线上排查方法完整梳理一遍适合正在优化Java服务、或者准备系统设计面试的开发同学直接抄作业。1. 高并发场景下线程池的整体设计思路1.1 线程池到底解决了什么问题先讲清楚为什么高并发场景离不开线程池。一个请求进来如果每次都新建线程处理线程创建销毁的开销会随着并发量线性放大。每条线程会分配独立栈空间默认大小在1MB左右8核16G的机器裸开几百个线程光栈内存就吃掉一大块再加上线程切换造成的上下文切换开销CPU的时间都花在调度上而不是业务逻辑上。线程池的核心价值是“线程复用”和“任务分离”。复用意味着HotSpot中常用的线程池只有少量线程却可以处理大量请求任务分离意味着提交任务的线程不必等任务执行完再走而是把任务丢进队列由一个worker线程慢慢消费。这里有一个很多人刚开始没绕过来的点线程池并不是“任务多了就立刻加线程”而是有个优先级顺序。ThreadPoolExecutor执行任务时线程数小于corePoolSize则新建线程等于或大于corePoolSize则把任务放入workQueue队列满了并且线程数小于maximumPoolSize才新建线程当线程数达到maximumPoolSize且队列也满了才会触发拒绝策略。很多生产事故都是没理解“队列优先于扩容”这个机制把队列设置得特别大结果线程一直不扩容任务却堆到OOM。1.2 ThreadPoolExecutor的核心工作流具体来看ThreadPoolExecutor的内部逻辑光照一张流程来说明提交任务如果当前活跃线程数小于corePoolSize直接创建新线程执行任务。如果活跃线程数已经达到或超过corePoolSize任务不会立刻创建新线程而是进入workQueue等待。如果workQueue已满并且当前线程数小于maximumPoolSize才会创建新线程执行新提交的任务。如果workQueue已满线程数也到了maximumPoolSize执行拒绝策略。这个流程的巧妙之处在于它能应对两种不同的负载模式平稳的流量靠核心线程消化突发流量在队列满之后迅速把线程数扩张到maximumPoolSize。但如果参数配得不对就会出现两种极端一是队列太长突发流量全部排队延迟飙升二是队列太短而最大线程数过大资源被打爆。1.3 高并发场景的核心诉求高并发场景真正考验的不仅是并发能力还有资源占用和延迟。线程池设计得好坏直接决定系统在高负载下的表现曲线是平稳下降还是断崖式崩溃。处理高并发时有几个诉求优先级很高请求延迟可控。如果任务在队列里排队超过超时时间用户已经放弃等待那么这个处理就白做了。内存可控。无界队列和无限制的线程数量都会造成内存被任务或线程栈占满。任务不丢失。拒绝策略如果选择静默丢弃就会导致用户操作失败却没有日志这种问题最难查。资源隔离。不同业务的请求不能互相拖垮订单的高峰不能把商品查询的线程池占满。这些诉求会在后面的参数配置和实战方案里反复体现。2. 核心参数配置数值计算与选型逻辑2.1 corePoolSize和maximumPoolSize怎么算这两个参数是线程池的命根子。计算前要先判断任务类型不同任务的线程数模型完全不同。CPU密集型任务比如图像处理、数据计算、加解密线程主要在执行计算逻辑线程数超过CPU核数只会增加上下文切换不能提升处理速度。经验公式是CPU核数1多出来的一个线程用于处理偶发的缺页中断或GC停顿。IO密集型任务比如调用下游接口、读写数据库、文件操作线程大部分时间都在等待IO返回这个时候阻塞线程不占CPU可以适当增加线程数。经验公式是CPU核数×1平均等待时间/平均工作时间。例如平均等待时间200ms工作时间40ms那么8核机器的合理线程数就是8×1548。实际项目中我不建议只套公式还要考虑任务队列的承接能力。如果队列容量是1000那么核心线程数稍微低一点也可以接受因为队列本身就是缓冲区如果队列容量只有100核心线程数就尽量贴近流量峰值对应的并行度。先说两个我在生产环境里看到过的极端配置有人把corePoolSize设为10maximumPoolSize设为10000队列用无界LinkedBlockingQueue结果队列永远填不满线程永远只开10个还有人把corePoolSize设为100maximumPoolSize设为10000队列设置很小结果突发流量一到线程数瞬间加到几千直接触发服务器CPU中断风暴。2.2 阻塞队列怎么选才不留隐患阻塞队列承担的是线程池的缓冲功能。不同队列的语义差异非常大。LinkedBlockingQueue默认是无界队列不指定容量时任务可以无限堆积这是OOM的最大隐患。FixedThreadPool默认使用它线程数固定任务全部排队一旦任务生产速度快于消费速度内存就会持续上涨。如果要用LinkedBlockingQueue一定要显式传入容量比如new LinkedBlockingQueue(5000)。ArrayBlockingQueue是一个有界队列必须指定容量容量一旦固定就不会动态变化。它基于数组实现适合高并发场景下控制任务的积压上限。它还有一个优势是支持公平模式可以在构造函数里传入true让等待时间最长的线程先获取任务。SynchronousQueue比较特殊它不存储任何任务提交的任务必须直接交给一个空闲线程如果没有空闲线程就尝试创建新线程。CachedThreadPool使用它线程空闲60秒就被回收适合短生命周期任务但高并发时会产生非常多的线程内存压力极大。PriorityBlockingQueue支持任务优先级但要注意它和线程池的消费顺序可能导致某些低优先级任务一直积压而且PriorityBlockingQueue也是无界的必须控制写入量否则一样OOM。DelayQueue主要用于定时任务比如ScheduledThreadPoolExecutor内部的延迟队列。普通业务用不到它。高并发场景下我的选型顺序是ArrayBlockingQueue优先其次是显式指定容量的LinkedBlockingQueue。两者的核心区别在于底层数据结构ArrayBlockingQueue通过一把全局锁维护队头和队尾LinkedBlockingQueue的put和take使用两把锁分离高并发吞吐更高。但ArrayBlockingQueue容量上限明确配合拒绝策略更好控制。2.3 拒绝策略选择默认的并不一定合适当线程池线程数和队列都满了提交的新任务会交给RejectedExecutionHandler处理。JDK提供了四种策略。AbortPolicy是默认策略直接抛RejectedExecutionException。这种策略最安全但也最粗暴异常会一路抛到任务提交方如果不做捕获处理用户请求会直接失败。CallerRunsPolicy是把被拒绝的任务交回给提交任务的线程执行。这种策略等于把压力反馈给调用方能够自然形成背压不会丢失任务但调用方的线程会被阻塞如果调用方是Tomcat请求线程会让请求线程占用更长时间。DiscardPolicy是直接丢弃任务什么都不做日志里也看不到。这是最危险的一种策略用户请求悄无声息就没了生产环境严禁使用。DiscardOldestPolicy是丢弃队列头部的任务然后把新任务加进去。如果系统里允许丢弃过期任务比如验证码、过期的刷新请求可以用它但要注意丢的可能是另一个重要的任务。实际项目里我一般不直接用内置策略而是自定义RejectedExecutionHandler把被拒绝的任务写入Redis队列或者本地磁盘缓冲等线程池空闲后再重新提交。这样既不会丢任务又能削峰。比如public class RetryRejectedHandler implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 写入缓冲队列或MQ后续异步补偿 retryQueue.offer(r); log.warn(task rejected, queueSize{}, executor.getQueue().size()); } }2.4 threadFactory和keepAliveTime容易被忽略的细节threadFactory决定了线程池创建的线程长什么样。默认工厂创建的线程名字是pool-1-thread-1线上查问题根本看不出是哪个业务池。我几乎会为所有线程池配置自定义命名和异常处理器ThreadFactory factory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-handler-pool- seq.getAndIncrement()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, throwable) - { log.error(uncaught exception in thread thread.getName(), throwable); }); return t; } };keepAliveTime控制在非核心线程空闲后的回收时长。如果服务有明显的低谷期和高峰期keepAliveTime可以设置为30秒而不是默认的60秒让线程在低谷期尽快释放资源。假设每秒有大量短任务keepAliveTime太短会导致线程频繁创建销毁反而增加开销。2.5 参数配置速查表把常见的场景参数整理成一张表方便直接参考。场景corePoolSizemaximumPoolSize队列keepAliveTime拒绝策略CPU密集型计算CPU核数1CPU核数1ArrayBlockingQueue(1000)0~30sAbortPolicyIO密集型接口CPU核数×2CPU核数×4ArrayBlockingQueue(2000)30~60sCallerRuns或自定义回填秒杀突发流量10CPU核数×8SynchronousQueue或小容量队列60s自定义丢弃降级任务优先级排序CPU核数×2CPU核数×4PriorityBlockingQueue60sAbortPolicy告警3. 高并发场景的实操配置方案与踩坑记录3.1 典型业务场景的参数配置推荐先给一个标准参考场景订单创建接口平均耗时80ms其中本地计算约10ms依赖数据库和下游服务约70ms。部署在8核16G机器上目标并发支撑2000QPS。这类任务属于典型的IO密集型线程数基数可以放大。我配置的是corePoolSize16maximumPoolSize32队列使用ArrayBlockingQueue(1500)keepAliveTime30秒。为什么队列容量定1500假设500ms内系统无法处理新增量1500个任务对应30万QPS的瞬时积压已经足够应对绝大多数流量波动再大就会让请求等待时间过长。秒杀场景就完全不同了。秒杀的流量特点是短时间超高峰值任务数量巨大但单任务执行时间很短。如果队列很长用户请求会全部排队等到能执行时活动已经结束了。所以秒杀场景更适合小队列甚至无界队列配合大线程池用牺牲部分内存来换取低延迟。抢购接口我实测下来corePoolSize10maximumPoolSize80队列容量256用SynchronousQueue反而会导致线程反复创建。这里更推荐ArrayBlockingQueue(256)让突发流量快速触发线程扩容。还有一类定时任务编排场景比如异步对账、报表生成单个任务执行时间长任务数量稳定。这类任务不建议和实时接口共用一个线程池单独建一个池corePoolSize等于任务并发数maximumPoolSize等于corePoolSize队列用较大容量避免阻塞。3.2 线程池监控不监控一切参数都是盲配线程池参数调优不是一次性的上线之后必须监控。我约束自己至少采集四个维度活跃线程数。活跃线程数持续贴着maximumPoolSize跑说明线程不够用需要调大上限或降低队列等待。队列积压量。队列积压持续增长说明消费速度跟不上生产速度要么加线程要么限流。任务拒绝数。拒绝数从0变1意味着系统已经到达处理上限必须尽快扩缩容或降级。任务执行耗时分布。耗时变长可能是线程竞争或资源争抢。Java里可以用ThreadPoolExecutor自带的getTaskCount、getCompletedTaskCount、getActiveCount、getQueue().size()这些指标通过Spring的定时任务或Micrometer暴露给监控平台。线上实际排查时如果队列积压量从0涨到几千而活跃线程数还是核心线程数说明队列设置太大或扩容条件没触发。给一个简化版监控代码Component public class ThreadPoolMonitor { private final ThreadPoolExecutor orderPool; public ThreadPoolMonitor() { this.orderPool ThreadPoolConfig.createOrderPool(); scheduleReport(); } private void scheduleReport() { Executors.newSingleThreadScheduledExecutor() .scheduleAtFixedRate(() - { log.info(orderPool active{}, poolSize{}, queue{}, taskCount{}, orderPool.getActiveCount(), orderPool.getPoolSize(), orderPool.getQueue().size(), orderPool.getTaskCount()); }, 0, 10, TimeUnit.SECONDS); } }3.3 Executors内置线程池为什么不能直接用于高并发很多人图省事直接用Executors.newFixedThreadPool、newCachedThreadPool其实这对生产环境来说非常危险。FixedThreadPool内部使用了无界LinkedBlockingQueue任务只进不出内存迟早被堆满。CachedThreadPool直接使用SynchronousQueue线程数量没有上限高并发时会创建大量线程最终触达操作系统进程数上限。ScheduledThreadPool内部的延迟工作队列也是无界队列同样有OOM风险。所以我在团队里立了一个代码规范不允许直接使用Executors工厂方法创建线程池必须new ThreadPoolExecutor并显式指定队列容量和拒绝策略。这样做的好处是所有参数都摆在明面上评审的人能一眼看出瓶颈在哪里。3.4 线程池隔离与父子线程上下文传递高并发系统里不能所有业务共用一个大线程池。订单、库存、商品三个接口流量特性完全不同如果共用订单高峰期会把库存和商品的服务也拖死。我通常按业务域拆池每个池独立设置参数再结合Sentinel之类的限流组件做整体保护。另一个容易踩的坑是ThreadLocal传递。业务系统经常通过ThreadLocal保存用户ID、traceId主线程提交任务到线程池后worker线程读不到这些值。因为ThreadLocal是线程私有的不是线程共享的。解决办法有几个提交任务时把上下文参数作为方法参数显式传给任务。使用阿里巴巴的TransmittableThreadLocal它能在任务提交时从父线程拷贝值到子线程。在任务执行完毕后清理ThreadLocal防止线程池复用时数据串味。高并发场景我更推荐第一种显式传参。虽然代码改起来啰嗦但最直观、最好排查不依赖框架魔法。4. 常见问题与排查技巧实录4.1 队列堆积导致OOM去年我接手过一个线上服务内存稳定上涨到濒临OOM。监控面板上的堆占用曲线几乎是线性爬坡怀疑是线程池队列堆了任务。后来dump堆栈发现LinkedBlockingQueue里堆积了几十万个任务对象每个任务持有数据库连接和请求体直接吃满堆内存。根因是当时用了Executors.newFixedThreadPool队列是无界的。生产流量一上去任务生产速度超过消费速度队列越堆越多最终OOM。解决步骤是把固定线程池换成ThreadPoolExecutor队列换成ArrayBlockingQueue并限制容量拒绝策略改成自定义回填同时增加告警。改完后内存曲线稳定在正常水位。4.2 线程数飙升拖垮进程另一个场景服务配置了较大maximumPoolSize队列容量设得很小高峰时期线程数从32涨到500多。结果CPU load升高但QPS反而下降因为线程上下文切换开销已经盖过了业务处理收益。排查时先看监控发现activeCount一直等于maximumPoolSize然后看线程栈大量线程处于BLOCKED或WAITING状态资源都耗在锁等待和线程切换上。最后把maximumPoolSize降低同时增大了队列容量让任务多缓冲而不是立刻创建线程。这也验证了那个原则线程数不是越多越好每个线程都有栈内存和调度成本。4.3 任务饥饿和长任务阻塞使用PriorityBlockingQueue时如果低优先级任务数量很大高优先级任务可能被无限挤压。我曾遇到一个定时报表任务因为前面堆积了大量低优先级的日志任务报表一直得不到执行整整迟了6个小时。排查方式是查看队列内容分布发现低优先级任务占绝大多数。解决思路是不对任务做优先级排队而是把不同优先级的任务分流到不同线程池高优先级池配置更宽松低优先级池配置更严格。长任务阻塞还有一个常见原因任务里使用了锁而锁被某个异常线程持有没有释放。这种问题如果线程池线程数小于锁竞争等待数量就会表现为任务超时。排查时看阻塞线程栈定位锁来源。4.4 线程池优雅关闭与重启关闭线程池如果处理不当会出现两类问题一是正在执行的业务直接中断二是已提交的任务丢失。正确姿势是先shutdown()停止接收新任务等待已有任务执行完毕如果超时再调用shutdownNow()强制中断。pool.shutdown(); if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { pool.shutdownNow(); if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { log.error(thread pool did not terminate); } }需要注意的是shutdownNow()会中断正在执行的任务如果任务本身没有正确处理InterruptedException可能造成资源泄漏。所以线程池里的任务代码必须对中断信号做响应比如释放数据库连接、关闭IO流、清除ThreadLocal。4.5 线程池问题排查速查表现象可能原因处理建议内存持续上涨GC频繁任务队列无界或过大改为有界队列限制队列容量CPU load高QPS下降线程数过多上下文切换严重调低maximumPoolSize增加队列容量任务一直不执行优先级队列堆积了低优先级任务按优先级拆分线程池单任务执行时间异常长锁争用或下游IO阻塞用线程栈定位阻塞点任务被静默丢弃使用了DiscardPolicy改成Abort或自定义回填策略线程池关闭后任务丢失忘记shutdownNow或中断处理错误按优雅关闭流程处理线程池调优这件事我个人的体会是没有一个银弹参数任何配置都必须结合业务模型、部署资源和压测结果来定。我第一次调优时按照公式算好了参数压测却一直不理想后来把核心线程数降下去、队列容量提上来效果反而更好。所以建议你在改完参数之后务必做一轮完整压测观察线程数、队列积压、拒绝数和延迟这四个指标的变化趋势调整参数要有依据而不是凭感觉。另外再分享一个实战小技巧高并发场景下尽量给接口加上超时控制无论是socket超时还是数据库连接超时。很多线程池线程被卡住不是因为并发不够而是因为下游服务响应慢把所有线程池线程占满了。把超时时间设置合理线程池才能在高并发下保持弹性。