ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JDK8线程池七参数与四种拒绝策略:从源码到线上调优

JDK8线程池七参数与四种拒绝策略:从源码到线上调优 如果你在线上日志里见过RejectedExecutionException大概率已经体会过线程池参数乱配的代价。我那次是凌晨两点监控突然报错错误堆栈指向一个定时任务线程池队列用的是没有指定容量的LinkedBlockingQueue核心线程数 5最大线程数 20表面上看不出毛病。结果流量一起来任务不断积压线程数却始终没涨上去最后内存飙升、任务超时连着报警。排了两个多小时才发现问题不在业务代码而在对 JDK8 中ThreadPoolExecutor那七个核心参数和四种拒绝策略的理解有偏差。这篇文章想把这些东西一次讲透。不管是刚接触线程池的新人还是准备面试、需要做线上调优的开发者都可以把它当作一份可以对照使用的排查手册。我会从任务的完整执行路径讲起逐条拆解参数含义再带你读一遍四种拒绝策略的源码最后给一套能落地的选型和调优思路。1. 任务进了线程池之后到底先走哪条路很多人对线程池的理解停留在有任务就丢进去线程不够就排队队列满了就拒绝。这个说法方向没错但漏掉了最关键的细节线程池不是先把队列装满才去创建新线程的而是有一套固定的优先级顺序。1.1 execute() 源码里的三个分流分支JDK8 的ThreadPoolExecutor.execute()源码非常短核心逻辑就三个分支我建议你直接把这段代码背下来public void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); // 第一步如果当前线程数小于核心线程数直接新建核心线程执行任务 if (workerCountOf(c) corePoolSize) { if (addWorker(command, true)) return; c ctl.get(); } // 第二步核心线程已满尝试把任务放入阻塞队列 if (isRunning(c) workQueue.offer(command)) { int recheck ctl.get(); // 双重检查线程池是否被关闭是否需要补一个线程 if (! isRunning(recheck) remove(command)) reject(command); else if (workerCountOf(recheck) 0) addWorker(null, false); } // 第三步队列也满了尝试创建非核心线程执行任务 else if (!addWorker(command, false)) reject(command); }这三步的顺序就是线程池的交通规则先看核心线程是否用完没满就新建核心线程不多排一秒钟队满了才把任务丢进队列缓存队列也满时才会创建非核心线程兜底如果连最大线程数都满了直接触发拒绝策略。我在排查故障时见过最多的误判就在这里。很多人以为队列满了才会创建非核心线程实际顺序是核心线程占满 → 新任务先入队 → 队列也满才创建非核心线程。如果队列是无界的非核心线程可能从头到尾都不被创建。1.2 用餐厅叫号来理解这套逻辑拿餐厅排队打比方可能更直观。核心线程是店里固定的几个厨师刚开业他们就位阻塞队列是门口等位的长椅非核心线程是临时叫来的帮工。客人在增加只要厨师没满就安排新厨师直接做菜不上桌等厨师满了新客人才开始排队坐长椅长椅也坐满了才临时雇帮工帮工也忙不过来就开始劝退客人这就是拒绝策略这个类比能解释很多实际问题比如为什么设置了大maximumPoolSize高并发下线程数却没上去——因为长椅无界队列无限长客人永远坐不满临时帮工自然不用出场。1.3 线程池整体状态ctl 里的秘密源码里反复出现的ctl是一个AtomicInteger高 3 位保存线程池的运行状态RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED低 29 位保存线程数量。这个打包设计是为了保证线程数增减和状态变更可以用一次 CAS 原子完成。这里不需要把位运算全部展开但有个判断要点isRunning(recheck)用来确认线程池没有被关闭。如果线程池正在关闭任务即使入队成功也会被移除并走reject()。这个细节直接关系到后续拒绝策略的触发条件——并不是只有队列满才会触发拒绝线程池关闭后提交任务同样会触发。2. corePoolSize、maximumPoolSize、keepAliveTime三个控制容量的阀门现在正式拆参数。ThreadPoolExecutor最常用的构造方法有七个参数public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)前三个参数决定线程池的容量边界我习惯把它们分成两组看数量边界和时间边界。2.1 corePoolSize常驻线程数的真实含义corePoolSize是线程池中常驻的核心线程数量。注意常驻两个字——核心线程默认不会被回收即使长时间空闲也占着线程资源。关键行为点是线程池启动时不会预先创建好这批线程而是等任务来了才逐个创建。换句话说corePoolSize 10不代表一开始就有 10 个线程等着执行任务而是说线程数最多可以懒加载到 10 个这 10 个以内都属于核心线程。如果把corePoolSize和maximumPoolSize都设为 0情况会更特殊任务提交时会直接尝试入队然后通过addWorker(null, false)创建非核心线程来处理。也就是说 0 不是不能创建线程只是核心线程数量为 0。这个边界在 JDK8 里偶尔会让人费解实际场景中用SynchronousQueue配合这个配置是合法的但必须同时保证maximumPoolSize 0。2.2 maximumPoolSize兜底上限不是越大越好maximumPoolSize是线程数的硬顶包含核心线程在内。它只在队列也满了之后才会发挥作用。我把这个参数称为最后的梭哈筹码因为触达它意味着任务积压已经超出设计容量线程池正在用最激进的方式消费任务。压测里常见的现象是一旦流量冲到触发非核心线程创建的量级线程数会以极快速度往上爬。这是因为每个新任务过来如果队列已经满了都会尝试直接创建新线程直到撞上maximumPoolSize。所以生产环境里这个值要克制我见过有人把一个 IO 任务的线程数上限设到 500流量一波动线程一下子拉起CPU 上下文切换直接打满系统反而更慢了。2.3 keepAliveTime 和 allowCoreThreadTimeOut空转线程的回收机制keepAliveTime控制非核心线程的空闲存活时间线程执行完任务后如果在指定时间内没有接到新任务就会被回收。默认单位是纳秒实际使用中基本都用TimeUnit.SECONDS或TimeUnit.MILLISECONDS。非核心线程空闲超过keepAliveTime就会被回收核心线程默认不回收除非手动调用allowCoreThreadTimeOut(true)allowCoreThreadTimeOut是常常被人忽略的一个开关。打开之后核心线程空闲超过keepAliveTime也会被回收线程数最终可以降到 0。这个特性对白天高并发、夜间几乎无任务的业务很友好能让线程池在低峰期完全释放线程资源但注意它有一个约束keepAliveTime必须大于 0否则会抛异常。参数控制对象默认行为注意点corePoolSize核心线程数空闲不回收启动时懒创建maximumPoolSize线程总数硬顶队列满时触发增长不能小于 corePoolSizekeepAliveTime非核心线程空闲存活默认60秒级别与 TimeUnit 配合使用allowCoreThreadTimeOut核心线程是否也回收falsetrue 时 keepAliveTime 必须大于03. 阻塞队列和线程工厂排队策略与线程身价容量的阀门之后决定线程池性格的是workQueue。队列类型的选择直接影响参数组合是否成立很多事故的根子就出在这里。3.1 四类阻塞队列的行为差异JDK8 里常用的阻塞队列就几种行为差异非常明显队列是否默认有界特性典型组合ArrayBlockingQueue是需指定容量数组实现有界公平锁可选有界阻塞适合控流量LinkedBlockingQueue否容量为MAX_VALUE链表实现可指定容量不指定容量时是无界炸弹SynchronousQueue不存数据每个offer必须等take直接交接配合大 max 线程数PriorityBlockingQueue否按优先级出队无界对延迟敏感但不丢任务最需要警惕的是Executors.newFixedThreadPool()内部默认使用的无界LinkedBlockingQueue。它没有容量上限任务会无限堆积线程数永远打不到maximumPoolSize。一旦生产流量持续超过核心线程处理能力内存里积压任务对象可能直接压垮 JVM。很多文章讲 OOM 都归咎于堆太小其实无界队列才是那个最大的嫌疑犯。3.2 SynchronousQueue 的实际意义SynchronousQueue特殊在它内部根本不缓存任务。offer一个任务时必须有线程正在take否则认为入队失败。也就是说用它做队列时线程池的行为接近于没有排队环节任务要么直接被线程拿走执行要么就进入创建新线程的分支。所以SynchronousQueue 很大的 maximumPoolSize是常见动态扩缩容配置核心线程处理不过来任务立刻触发创建新线程最大线程数很快被吃满拒绝策略随之触发。这种组合适用于需要快速响应、不允许任务在队列中堆积等待的场景。代价是线程创建频繁且对突发流量非常敏感参数设计不合理时拒绝率会很高。3.3 自定义线程工厂的三个作用threadFactory是很多人会跳过的一个参数但它是线上排查的必备工具。默认的工厂创建出来的线程名是pool-1-thread-1你根本看不出这个线程属于哪个业务。自定义线程工厂至少要做三件事给线程起业务相关的名字比如order-async-worker-设定合理的daemon状态。默认是非守护线程会阻止 JVM 退出如果误设成守护线程主进程结束可能直接丢任务加一个setUncaughtExceptionHandler记录线程内未被捕获的异常ThreadFactory factory new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(order-pool- count.getAndIncrement()); t.setDaemon(false); return t; } };没有自定义线程工厂的线程池出问题时连jstack都不好定位。我建议所有生产环境的线程池都强制走自定义ThreadFactory这是成本最低的治理手段。4. 四种拒绝策略源码走读不丢掉任何一个任务的前提当线程数达到maximumPoolSize且队列已满或者线程池正在关闭时reject(command)就会执行。JDK8 内置了四种RejectedExecutionHandler实现它们的取舍非常清晰。4.1 AbortPolicy默认策略直接抛出异常ThreadPoolExecutor默认的 handler 就是AbortPolicy。源码只有一个动作public static class AbortPolicy implements RejectedExecutionHandler { public AbortPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { throw new RejectedExecutionException(Task r.toString() rejected from e.toString()); } }这个策略的优势是你只要在日志里看到RejectedExecutionException就知道系统已经过载了问题暴露得很直接。代价是被拒绝的任务直接丢失如果调用方没有捕获异常业务会立刻失败。哪些场景适合它对数据一致性要求高、宁愿报错也不允许静默丢失的任务比如订单状态同步、支付回调处理。这些任务失败后必须有明确告警和重试机制不能无声无息。4.2 CallerRunsPolicy调用者线程亲自执行这个策略是最让我觉得设计精妙的一个public static class CallerRunsPolicy implements RejectedExecutionHandler { public CallerRunsPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { r.run(); } } }逻辑很直白线程池满了以后任务不丢弃而是让提交任务的线程自己执行r.run()。提交任务的人通常是业务主线程这意味着它被占用了。好处是形成天然的负反馈调节系统越忙主线程越要干活提交新任务的速度就慢下来相当于限流。但坑也在这里。之前有个同事把异步通知任务配置成CallerRunsPolicy高峰期接口响应时间从 60ms 直接飙到 3 秒。原因就是原来扔线程池的事现在全在主线程里跑异步彻底变成了同步。所以这个策略的正确使用前提是调用方线程是可靠的非阻塞线程并且你能接受它偶尔被任务占用。4.3 DiscardPolicy 和 DiscardOldestPolicy两种舍车保帅DiscardPolicy是最简单的空实现任务被拒绝后什么都不做直接丢弃。它的问题和AbortPolicy一样会导致任务丢失但连错误都不会抛排查问题非常难。除非你的业务允许在流量高峰时直接放弃部分非关键任务否则我不建议在核心链路上用它。DiscardOldestPolicy则更微妙它先丢弃队列头部那个等待最久的任务然后把当前被拒绝的任务重新提交执行。public static class DiscardOldestPolicy implements RejectedExecutionHandler { public DiscardOldestPolicy() { } public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { if (!e.isShutdown()) { e.getQueue().poll(); e.execute(r); } } }这个策略牺牲的是最旧的任务换取新任务有机会被执行。适合任务具有时效性、旧任务的价值逐渐衰减的业务比如消息推送、行情推送。但注意被丢弃的旧任务不会得到任何通知如果业务依赖任务的最终一致性这个策略会留下隐患。策略行为任务是否丢失适用场景AbortPolicy抛异常丢失且暴露对失败敏感、有重试机制的业务CallerRunsPolicy调用线程执行不丢失需要限流、调用方可被占用DiscardPolicy静默丢弃丢失且无感知非关键、可牺牲的任务DiscardOldestPolicy丢弃最旧执行最新旧任务丢失强时效性、旧任务价值低4.4 拒绝策略在 JDK8 中的实际触发时机我见过不少人有个误解认为只要队列满了就会触发拒绝。实际上触发条件有两个并存一是队列已满线程数已到最大二是线程池处于非运行状态比如已经调用过shutdown()。第二种情况最容易踩线程池关闭后代码还在提交任务策略是DiscardPolicy的话任务全丢了且零日志。所以在做线程池生命周期管理时提交前先判断isShutdown()或者把AbortPolicy作为兜底至少能及时发现问题。5. 参数选型与业务匹配从能跑到扛得住这一节解决的是最实际的问题不同业务场景下七个参数怎么组合。5.1 先给任务做分类定性拿到需求先别谈参数先回答三个问题任务有没有实时性要求可以排队等还是必须立即执行任务能不能丢丢了是否会造成资金损失或状态不一致提交任务的线程是什么能不能接受它被反向占用回答完这三个问题参数方向基本出来了。实时性强的优先SynchronousQueue或小容量有界队列不能丢的选AbortPolicy 重试机制或者CallerRunsPolicy能牺牲的才考虑静默丢弃类策略。5.2 三套常见配置模板场景corePoolSizemaximumPoolSizeworkQueuehandlerCPU 密集计算N 1N 1有界 ArrayBlockingQueueCallerRunsPolicyIO 密集 / 高并发异步N * 2 左右N * 4 以内有界 LinkedBlockingQueueAbortPolicy 告警突发流量、强时效接近0合理上限SynchronousQueueDiscardOldestPolicyN 是机器可用 CPU 核数。但我不建议直接套公式这些模板只是起点。CPU 密集任务N 1是经典经验值多出的一个线程可以弥补偶尔的缓存失效或调度停顿IO 密集任务里线程大部分时间在等待网络或磁盘所以线程数可以放大但放大多少取决于阻塞比例。更工程化的做法是先按经验值配置然后压测等比调优。改一次参数观察一次队列深度和拒绝次数找到转折点。5.3 自定义拒绝策略的正确姿势内置策略不够用时自己实现RejectedExecutionHandler接口非常容易。我常用的写法是三个动作计数、告警、补偿。public class MonitorRejectedHandler implements RejectedExecutionHandler { private final AtomicLong rejectedCount new AtomicLong(0); private final RejectedExecutionHandler fallback; public MonitorRejectedHandler(RejectedExecutionHandler fallback) { this.fallback fallback; } Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { long count rejectedCount.incrementAndGet(); // 1. 记录拒绝次数供监控指标采集 // 2. 达到阈值时发送告警 // 3. 交给兜底策略处理原始任务 fallback.rejectedExecution(r, e); } }这个包装类可以套在任何内置策略外面既不改变原有行为又能把拒绝事件变成可观测指标。我把拒绝次数、队列深度、活跃线程数都接到监控系统里任何时候都能回答线程池现在压力多大。这才是拒绝策略的正确打开方式——拒绝本身不是终点拒绝之后要有反应。5.4 线程池关闭时的连带问题shutdown()后线程池不再接收新任务但会等队列中的存量任务跑完shutdownNow()则是直接中断正在执行的任务并返回未执行的任务列表。关闭之后提交任务无论队列是否为空都会走拒绝策略。日常开发中常犯的错是应用停机流程里先关了线程池但还在向它提交任务。解决方案有两种要么在提交任务前统一判断可用状态要么在停机阶段先断开消息源再延迟关闭线程池。顺序颠倒任务就是在闭园后还往游泳池里扔人。6. 让参数活起来监控、压测与动态调整配置参数不是写一次就完事真正麻烦的是上线后如何确认参数还合适。6.1 必须盯住的核心指标线程池自带了很多可以实时查询的方法但真正定生死的是四个getQueue().size()队列积压量。持续上涨说明处理能力跟不上是第一个报警信号getActiveCount()活跃线程数。接近maximumPoolSize说明兜底资源已经用尽getCompletedTaskCount()完成任务总数。用来计算吞吐拒绝策略计数器有自定义拒绝策略时直接看计数没有就靠异常日志我在所有使用线程池的接口里都加了一条埋点日志把上面几个值打到日志或监控平台平时不显眼事故排查时直接救命。ThreadPoolExecutor pool (ThreadPoolExecutor) executor; int queueSize pool.getQueue().size(); int active pool.getActiveCount(); long completed pool.getCompletedTaskCount();6.2 压测找边界不要理论推极限有些团队喜欢用公式把corePoolSize算到小数位但生产环境的负载模型往往没有理论计算那么干净。我建议配置只给合理初值然后做容量压测把流量按 50%、80%、100%、120% 逐步加压观察队列深度增长速率和线程数变化曲线。队列深度开始陡增、拒绝策略开始触发的那一档就是当前参数的临界点。临界点找到之后看业务目标如果目标是低延迟就把队列容量调小、调大最大线程数让任务尽量被快速执行如果目标是高吞吐、抗流量波动就保留适度的队列缓冲。目标不同参数方向完全相反没有绝对正确的值。6.3 动态调整参数避免震荡JDK8 的ThreadPoolExecutor原生支持动态调整setCorePoolSize()和setMaximumPoolSize()可以在运行期修改参数。在线调优可以不必重启应用。但有个细节调大corePoolSize时线程池会预创建新线程去执行队列里积压的任务调小时多余的核心线程依然空闲不回收除非开了allowCoreThreadTimeOut。动态调参最怕的是频繁震荡。比如盯到队列深了就加线程队列空了就减线程结果线程数跟着流量来回摆动创建销毁开销反而拖垮性能。我一般只在参数明显失配时做一次性调整日常调优优先调整队列容量和拒绝策略线程数的变动要谨慎。6.4 一个建议把配置收口到统一入口最后分享一个治理层面的心得。线程池参数散落在各个业务代码里是最大的隐患一旦要整体调整根本改不动。我后来把线程池创建收敛到一个统一配置中心参数全部做成可动态下发的配置业务方只能选业务类型具体corePoolSize、队列类型、拒绝策略由基础设施团队统一管控。这样做的好处是线上问题可以全局定位参数调整可以灰度下发。线程池本身只是 JDK 提供的一个工具真正决定它稳不稳的是你有没有把它当成一块需要持续运维的基础设施来对待。我也一直认为理解七个参数和四种策略只是第一步能把它们组合出可控的、可观测的行为边界才算真正掌握 JDK8 里的线程池。
返回列表