ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

线程池参数配置实战:从OOM事故到ThreadPoolExecutor调优

线程池参数配置实战:从OOM事故到ThreadPoolExecutor调优 1. 先从一次线上事故说起线程池参数搞错了会怎样这事过去快两年了但每次有人问我线程池参数不就是填几个数字嘛有什么好讲究的我都会把那次事故拿出来说。当时我们有个订单状态同步服务逻辑很简单用户下单后系统把订单状态推送给外部ERP同时回写本地缓存。由于上游接口响应慢我们用了线程池做异步推送避免阻塞主流程。上线初期一切正常直到某天大促流量上来订单量翻了十来倍服务突然陆续出现接口超时、内存上涨最后直接OOM。查下来的结果很尴尬。我们的线程池配置是这样的ExecutorService executor Executors.newFixedThreadPool(10);一词以蔽之无界队列。newFixedThreadPool底层用的是LinkedBlockingQueue默认容量是Integer.MAX_VALUE。大促期间推送接口变慢10个核心线程全被占住新任务全部堆积到队列里。队列越堆越长每个任务还持有订单上下文和数据库连接引用GC根本来不及回收内存就被拖垮了。事后复盘如果当时用的是ArrayBlockingQueue并显式设一个队列上限配合合理的拒绝策略最坏情况也就是丢弃一部分非核心任务或者让上游先降级不至于把进程拖死。那次之后我养成了一个习惯凡是看到线上代码里直接用Executors工厂方法创建线程池我都会多问一句队列和拒绝策略你清楚吗。不清楚的话那这就是一颗定时炸弹。这篇文章不是八股文式的参数罗列我想从一个实际使用者的角度把ThreadPoolExecutor这七个参数、任务的流转路径、队列选型和线程数测算讲透。后面对应的场景和参数建议都来自我自己的项目落地经验你可以直接拿去做参考但请务必结合你自己的业务特性再调整。2. ThreadPoolExecutor核心参数逐项拆解每个数字背后的选择逻辑先明确一个大前提Executors那几个静态方法只是帮你预设了一套参数真正决定线程池行为的是ThreadPoolExecutor的构造函数。JDK给的是这个最完整的版本public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)七个参数少一个都不行。下面逐个说。2.1 corePoolSize常驻线程的心理底线核心线程数决定了线程池在没有任务时保留多少线程。注意没有任务时这几个字——核心线程默认不会被回收除非你设置了allowCoreThreadTimeOut(true)这个后面会提到。核心线程数的设定没有万能公式但有一个常用的经验判断维度你的任务是CPU密集型还是IO密集型。如果是纯计算任务核心线程数压到CPU核数 1左右即可如果是IO密集型可以放宽到CPU核数 * 2甚至更高。原因在后面的场景测算部分会详细展开。这里想强调一个容易忽略的点corePoolSize并不是一开始就创建这么多线程。线程池是懒创建机制只有任务到达时才会创建线程直到达到corePoolSize。也就是说new ThreadPoolExecutor(10, 20, ...)不会在初始化时创建10个线程而是先创建第1个、第2个……逐步加到10个。如果你希望线程池启动时就预热一定数量的线程需要调用prestartAllCoreThreads()。2.2 maximumPoolSize线程数的硬顶当队列也满了线程池才会尝试把线程数从corePoolSize往上扩展直到maximumPoolSize。换句话说maximumPoolSize不是最多允许这么多线程同时跑而是当核心线程忙且队列满时允许额外创建这么多线程。这里有一个非常关键的逻辑很多人被面试题带偏了线程数不是从corePoolSize直接跳到maximumPoolSize的中间隔着队列容量。任务来了先让核心线程跑核心线程都忙进队列队列满了才加线程。这个顺序理解错了后面一切配置都是空中楼阁。我在第3章会通过一个具体例子把完整流转路线画出来。maximumPoolSize并不是越大越好。线程一多上下文切换的开销会显著上升CPU时间都耗在保存和恢复线程状态上吞吐量反而下降。如果用Arthas或JProfiler看一眼线程Dump你会发现在高峰期大量线程处于RUNNABLE状态但实际在争抢锁这种场景下加线程只会更糟。2.3 keepAliveTime与unit非核心线程的空闲寿命keepAliveTime表示非核心线程空闲多久后被回收unit是时间单位。JDK默认在非核心线程空闲达到这个时间后将其终止释放资源。注意这个机制只作用于超出corePoolSize的那部分线程核心线程不受影响。很多团队会把keepAliveTime设得偏大比如10分钟、30分钟理由是避免频繁创建线程的开销。但线程创建开销在现代JVM上其实很低远低于一次远程调用或一次数据库查询的耗时。把keepAliveTime拉长只会让线程长期占据内存和句柄尤其是当你的服务高峰和低谷差异很大时低谷期大量空闲线程纯属浪费。把keepAliveTime设为30到60秒已经绰绰有余。如果你希望核心线程也能回收可以加一行threadPoolExecutor.allowCoreThreadTimeOut(true);同时配合一个合适的keepAliveTime。这个能力在弹性伸缩场景下特别有用比如夜间流量极低的服务可以借此把线程数压缩回个位数。2.4 workQueue线程池的蓄水池队列是整个线程池最容易出问题的地方。这一点我在文章开头的事故里已经吃过大亏。常用队列有这么几类LinkedBlockingQueue默认无界、ArrayBlockingQueue有界、SynchronousQueue无缓冲直接交接、PriorityBlockingQueue按优先级出队。关于队列选型和它对线程数策略的影响我会在第5章单独用一整节展开。这里只提醒一句不要用无界队列除非你能确认任务总量恒有上限且每个任务执行时间极短。否则一旦下游抖动队列就会变成内存杀手。2.5 threadFactory给线程起个好名字ThreadFactory看起来是七个参数里最没存在感的但它的价值在排查问题时会被无限放大。默认的Executors.defaultThreadFactory()创建的线程名字是pool-1-thread-1这类线上日志一多你根本分不清任务跑在哪个池子里。我自己的习惯是自定义ThreadFactory把业务名字放进去并顺手设置守护线程标志ThreadFactory factory new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(1); Override public Thread newThread(Runnable r) { Thread t new Thread(r, order-sync-pool- counter.getAndIncrement()); t.setDaemon(true); return t; } };配合日志框架的MDC或者traceId出问题时能直接定位到是哪个线程正在执行什么任务排查效率完全不一样。2.6 RejectedExecutionHandler任务无处安放时的最后一根稻草当线程数已经达到maximumPoolSize且队列已满新提交的任务会被交给RejectedExecutionHandler处理。JDK内置了四种策略策略行为适用场景AbortPolicy直接抛RejectedExecutionException默认策略适合明确告知调用方任务提交失败CallerRunsPolicy由提交任务的线程自己执行该任务适合不能丢任务、允许反馈式降速的场景DiscardPolicy静默丢弃任务适合可容忍丢失的辅助任务如打点DiscardOldestPolicy丢弃队列中最老的任务然后重试提交适合拉取最新状态的场景如刷新缓存我尤其想提一下CallerRunsPolicy。这个策略在削峰场景下非常有效假设线程池已经满负荷新任务不会直接失败而是由提交线程通常是请求线程自己去跑这个任务。好处是任务不会丢坏处是请求线程被占用调用方会感知到延迟上升形成一种天然的背压机制。很多异步批处理系统就是靠它保命的。3. 任务提交后的执行流转一个常被误解的执行顺序网上关于线程池任务的执行顺序流传着各种相互矛盾的表述。我见过有人的理解是核心线程满了立刻开新线程到最大数量然后再放队列也有人觉得无论如何都先放队列。这两种都不对。ThreadPoolExecutor.execute()的实际执行流程可以归纳成三句话线程数小于corePoolSize时创建新线程执行任务线程数大于等于corePoolSize时优先把任务放入队列队列满了且线程数小于maximumPoolSize时创建新线程执行任务队列满了且线程数达到maximumPoolSize时走拒绝策略。注意第二点和第三点的顺序核心线程满 → 队列 → 非核心线程 → 拒绝这就是线程池最核心的执行顺序。我们用一个具体的配置来走一遍流程。假设配置为corePoolSize2maximumPoolSize4队列容量为2来了6个任务每个任务执行时间足够长提交任务t1当前线程数0 2创建线程A执行t1。提交任务t2当前线程数1 2创建线程B执行t2。提交任务t3线程数2已等于corePoolSize队列空t3进入队列。提交任务t4线程数2队列空t4进入队列。提交任务t5线程数2但队列已满当前线程数2 4创建线程C执行t5。提交任务t6线程数3 4创建线程D执行t6。如果此时再来t7线程数4已达maximumPoolSize队列满触发拒绝策略。这个例子能解释很多线上疑难杂症。比如你配了corePoolSize10maximumPoolSize200队列容量用了LinkedBlockingQueue默认的无界队列那maximumPoolSize200实际上永远用不上——因为队列永远不会满。之前我见过一个团队配了看起来挺合理的参数核心线程10最大200结果压测时发现并发能力始终上不去就是被无界队列坑了。理解了这个流转模型你在设置参数时才会意识到workQueue的容量才是决定何时扩容线程的开关。队列设计得越小线程扩容越激进队列设计得越大线程扩容越保守但堆积风险也越高。还有一点值得补充execute()和submit()在入口处有一些差异。submit()底层仍然是调用execute()但它会把任务包装成FutureTask因此可以拿到返回值。用submit()时任务内部如果抛了异常这个异常会被存到FutureTask里不会直接打印到日志而是等你在get()时抛出ExecutionException。如果你只submit()但从不调用future.get()任务异常会被静默吞掉排查问题时毫无痕迹。我的建议是不需要返回值的异步任务优先用execute()这样异常会直接打到当前线程的日志里需要返回值的才用submit()并记得处理Future。4. Executors内置线程池的桎梏为什么生产环境要绕开Executors提供了几个现成的工厂方法用起来确实方便一行代码就能搞定线程池。但如果你把这些内置线程池直接搬到生产环境通常是在给自己埋雷。我在第1章里讲过newFixedThreadPool导致OOM的案例这里系统性地把几个常见内置线程池的问题盘一遍。4.1 newFixedThreadPool与newSingleThreadExecutor无界队列的内存风险这两个方法底层都用LinkedBlockingQueue且没指定容量等价于无界队列。核心线程与最大线程数相同意味着线程数永远不会扩容。任务一多全部堆积到队列里内存和延迟双双失控。有的观点会说反正我会控制任务提交速率但实际系统里你控制不了所有调用方。下游抖一下上游任务就排队排队的任务如果还引用了大对象、数据库连接那内存问题会比你想的更早爆发。4.2 newCachedThreadPool线程数失控的隐患newCachedThreadPool的corePoolSize是0maximumPoolSize是Integer.MAX_VALUE队列用的是SynchronousQueue。这个队列本身没有缓冲来一个任务就必须有一个线程立刻接手没有空闲线程就创建一个新线程。如果任务执行慢且提交快线程数会无限膨胀直接打满操作系统线程资源。它有适用的场景比如短平快的请求-响应模型任务执行时间基本在毫秒级线程来不及积累就被回收了。但人员流动大的团队里短平快这个前提很难被后面接手的人记住万一哪天有人在里面跑了个耗时操作线程池就会悄悄把服务器拖垮。所以我的观点是非特殊情况不用CachedThreadPool真要弹性伸缩自己用SynchronousQueue配一个maximumPoolSize上限上面的风险是可控的。4.3 newScheduledThreadPool定时任务场景的初始化参数陷阱newScheduledThreadPool(corePoolSize)的坑相对隐蔽。它返回的是ScheduledThreadPoolExecutor任务队列用的DelayedWorkQueue这是一个无界队列。如果你用它执行周期任务正常情况问题不大但万一某个周期的任务执行时间超过了周期被跳过的任务会堆积在队列里同样存在内存风险。另外一个容易忽略的点scheduledAtFixedRate和scheduleAtFixedRate的语义差别以及setRemoveOnCancelPolicy(true)的作用。后者可以让被取消的定时任务立刻从队列中移除否则已取消的任务会一直占据着队列中的位置影响后续任务调度。这些细节平时看不出来真到排查问题时才让人头大。4.4 内置线程池真正的适用边界说了这么多坑也不代表内置线程池一无是处。如果是个人项目、原型验证、短期脚本用Executors.newFixedThreadPool(4)快速跑起来没有任何问题。但在正式的在线服务里我会一律走自定义ThreadPoolExecutor哪怕参数照抄内置工厂的值也要把队列容量、线程名、拒绝策略显式写清楚。这样做的目的不只是正确性更是为了让代码的意图清晰可见后面接手的人一眼就能看出你当时的设计考量。5. 阻塞队列选型它如何反向决定corePoolSize与maximumPoolSize队列选择不是独立的它直接决定线程池在什么时机扩容。网上讨论线程池参数时经常把队列一笔带过把它当成一个放任务的容器。实际上从第3章的执行流程可以看出队列容量是连接核心线程与最大线程数的桥梁它的长短决定了缓冲和扩容之间的天平偏向哪边。5.1 三种常用队列的定位差异先说LinkedBlockingQueue。如果不显式传容量它就是无界队列这在前面已经否掉了。如果传了容量它作为有界队列也没问题。它内部是链表结构take和put各有一把锁所以吞吐量比ArrayBlockingQueue更高一点。需要既能缓冲又能指定上限时我一般优先用它。ArrayBlockingQueue是基于数组的有界队列生产者和消费者共用同一把锁。它比LinkedBlockingQueue的并发度差一些但正因为结构简单容量耗尽时的行为更可预测。如果你希望队列满就立刻感知并扩容线程用ArrayBlockingQueue会更直接。SynchronousQueue比较特殊它不存任务。提交任务的线程必须等待一个工作线程来取货否则就会阻塞或创建新线程取决于线程数是否已达maximumPoolSize。newCachedThreadPool就是用它实现来一个任务就开一个线程的效果。5.2 队列容量如何影响整体并发策略假设corePoolSize10maximumPoolSize50任务执行时间约200ms提交速率约每秒80个。如果队列容量设为1000任务基本都会被队列吞掉线程数很难突破10不管maximumPoolSize设成多大都触发不了扩容。此时你真正应该调的是corePoolSize而不是maximumPoolSize。如果队列容量设为20一旦任务积压超过20线程池就会开始扩容最多扩到50。如果50还不够再触发拒绝策略。如果队列容量设为0等价于SynchronousQueue只要有任务且没有空闲线程就会立刻创建新线程直到maximumPoolSize。看到没有同样是10和50配上不同的队列容量整体行为天差地别。很多线上事故就是最大线程配了200但因为无界队列实际并发永远只有核心线程数这种配置错位。5.3 队列长度的估算逻辑队列容量设置多少可以按任务提交速率 × 希望等待的秒数来粗算。比如你的任务峰值提交速率是每秒100个你希望任务在队列里最多等待5秒那队列容量设为500左右是合理的。当然这只是一个粗粒度估算实际还要考虑单个任务内存大小。如果每个任务引用了一个10KB的对象500个任务大约是5MB内存放大到业务上下文后可能远不止这个数所以有必要按最坏情况评估。我在订单推送场景里的做法是队列容量按峰值速率乘以3秒的容忍时间设置同时配合CallerRunsPolicy做最后的兜底。这样既给了下游抖动一定的缓冲时间又不会无限堆积任务。6. 按业务场景测算线程数CPU密集、IO密集与混合型任务线程数到底配多少这是线程池问题里被问得最多的也是最不能直接抄答案的问题。本节我给出一套可以落地的测算思路而不是干巴巴的公式。6.1 CPU密集型任务的线程数CPU密集型任务指几乎不等待外部资源、一直在消耗CPU的任务比如图片压缩、大量计算、加密解密。这类任务线程数超过CPU核数后增加的线程只会带来上下文切换开销。经验值是CPU核数 1。多出的1个是为了应对偶尔的页缺失或其他原因导致的暂停保证某个线程暂停时其他线程能继续占满CPU。如果你用的是Java 19以上的虚拟线程那另说但传统平台线程场景下这个经验值仍然适用。获取CPU核数可以这样写int cpuCores Runtime.getRuntime().availableProcessors();注意在容器环境下availableProcessors()返回的可能是宿主机的核数。如果你在Kubernetes里给Pod设置了limits.cpu2但宿主机是64核JVM默认可能拿到64。这种情况需要显式指定-XX:ActiveProcessorCount2否则线程数测算会完全失真。6.2 IO密集型任务的线程数IO密集型任务大部分时间在等待远程接口、数据库、文件读写。线程发出IO请求后CPU基本空闲完全可以让更多线程承接等待。一个可用的估算方式是线程数 CPU核数 * (1 平均等待时间 / 平均计算时间)这是《Java并发编程实战》里提过的公式俗称线程数 CPU核数 × (1 W/C)。假设平均IO等待200ms计算耗时50msW/C4那么核数为4时线程数约为4 × (1 4) 20。但这个公式算出来的是理论参考值实际你还要把一个关键因素考虑进去内存占用。每个IO密集型任务的线程往往持有请求上下文、连接池连接等资源线程数放得太大内存会先扛不住。6.3 混合型任务的拆解思路现实中的任务很少是纯CPU或纯IO。比如一个订单推送任务内部先查数据库IO再做数据组装CPU最后调外部接口IO。这种情况下我的做法是把这个任务拆成多个可统计的段计算IO耗时占比和CPU耗时占比再套上面的公式。如果不好拆可以用一个更保守的方法先把线程数设置成2 * 核数跑一轮压测观察CPU使用率、响应时间和队列积压情况然后逐步上调或下调。对线上线程池参数的调整永远比一步到位更靠谱。6.4 配置示例一个异步订单外呼场景以一个我实际做过的场景举例。业务是订单状态变更后通知外部仓储系统任务大概长这样查询订单详情平均30ms本地组装报文平均5ms调用仓储接口平均150ms最坏500ms回写回调状态平均20msIO占比非常高总体响应大约205ms。服务器是4核部署了2个实例。代入公式4 ×1 3015020/ 5≈ 164。但这是理想上限我不能真的配160个线程因为每个任务持有的数据库连接、HTTP连接都可能打爆连接池。最后我把参数定为new ThreadPoolExecutor(16, 48, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(512), factory, new ThreadPoolExecutor.CallerRunsPolicy());核心线程16最大线程48队列512。压测下来4核CPU使用率稳定在70%左右吞吐量比最开始的固定线程池提升了三倍多。核心线程16是怎么来的4核 * 2 * 2个实例的权衡值加上对数据库连接池8连接的限制我卡在了一个想让线程多跑但连接池不允许的平衡点上。所以配置线程池之前先看看你的下游连接池上限是多少没什么比这个更现实的了。6.5 动态调整线程池参数的能力JDK的ThreadPoolExecutor提供了几个动态调参方法setCorePoolSize、setMaximumPoolSize、setKeepAliveTime。这给了我们一条很重要的后路——先用一个相对合理的参数上线然后监控运行数据再动态调优。我见过一个比较成熟的方案在配置中心里放线程池参数修改后通过setCorePoolSize动态生效不用重启服务。这样在流量突增时运维可以先把最大线程数和队列容量调大扛过峰值后再慢慢调回来。比改代码重新发布这个传统路径要快得多。动态调整有个时间点需要留心setMaximumPoolSize如果被调小已有的线程数高于新值时超出部分不会立刻被杀死而是等这些线程空闲到超过keepAliveTime后才回收。所以调小参数后不要指望立刻看到线程数下降需要给空闲回收留一点时间。7. 我在生产环境踩过的坑与善后经验这一节没有系统性的理论全是实操中掉过坑之后的教训。想起什么写什么但每一条都是真金白银换来的。7.1 线程池中的任务异常不会自动暴露前面提过submit()会吞异常其实execute()也不是完全没有坑。如果你的Runnable实现里没有try-catch兜底线程池会把异常打印到UncaughtExceptionHandler但某些时候尤其是你自定义了ThreadFactory但没有设置UncaughtExceptionHandler异常日志可能被吞掉任务直接终结你根本发现不了。我的做法是所有提交到线程池的任务在入口处统一包一层try-catch至少记录一条日志带上任务ID和执行时间。有时候为了快速定位还会在捕获异常后把MDC里的traceId捞出来打一遍。别嫌麻烦线上排查问题时一条带任务ID的异常日志能省你半天时间。7.2 线程池的优雅关闭比你想的更讲究调用executor.shutdownNow()之后线程池会尝试中断正在执行的任务并返回队列里还没执行的任务列表。很多人的第一反应是我直接shutdownNow就行了但如果你正在跑的任务里有数据库事务或者远程调用强制中断可能导致连接占用、事务悬挂。我现在的关闭流程基本都是三步executor.shutdown(); // 不再接受新任务 executor.awaitTermination(30, TimeUnit.SECONDS); // 等待已提交任务完成 executor.shutdownNow(); // 超时后再强制中断第二步和第三步之间的超时时间要看业务容忍度。正常情况下我会等任务自然完成只有超过宽限时间才强制中断并用返回的ListRunnable记录被强行中断的任务方便后续补偿。7.3 线程池与ThreadLocal的配合要格外小心如果用到了ThreadLocal在线程池环境里你必须显式清理。线程池的线程是复用的上一次任务留在ThreadLocal里的值会原封不动地被下一个任务读到轻则数据串了重则权限串了。我见过一个典型的故障某个网关服务在线程池里执行任务时从ThreadLocal里取了用户身份信息。正常情况下没问题但偶尔出现A用户访问了B用户的数据。原因就是线程复用时上一个请求的用户信息没被清理被下一个请求读到了。后来我们在任务执行结束时统一做了一次清理try { // 业务逻辑 } finally { userContext.remove(); }同时在提交任务前把需要的上下文显式作为参数传入而不是让任务自己去ThreadLocal里取。这样最彻底任务即函数上下文即参数跟线程池的复用机制彻底解耦。7.4 监控线程池状态比调参数更值钱参数调得再合理没有监控辅助你始终是在盲调。我会在线上服务里给线程池挂一套基础监控指标当前线程数getPoolSize()活跃线程数getActiveCount()队列积压量getQueue().size()任务总数与完成任务数getTaskCount()/getCompletedTaskCount()拒绝任务次数自己用AtomicLong统计队列积压量和拒绝任务次数这两项是判断线程池是否即将崩溃的最早期信号。队列积压持续上涨且活跃线程数已经顶满那就说明下游在变慢这个时候去检查依赖接口的耗时、监控外部系统健康状态往往能赶在故障前发现问题。我也习惯在监控面板里把拒绝次数单独画一条报警线一旦出现拒绝就告警。宁可让系统主动拒绝一部分非核心任务、快速失败也不要让无界队列把整个进程拖垮。8. 最后再分享两个小技巧第一个是线程池参数的配置外部化。我会把corePoolSize、maximumPoolSize、queueSize、keepAliveTime全部放到配置中心而不是硬编码在Java代码里。这样线上调参只需要改配置、等动态生效不需要重新发版。前提是你的线程池是用ThreadPoolExecutor创建的而不是Executors工厂方法——因为工厂方法返回的ExecutorService接口根本暴露不了setCorePoolSize这类方法。第二个是关于线程池要不要拆细的问题。很多团队喜欢把所有的异步任务都扔到一个大线程池里这样做管理简单但不同任务之间的优先级和资源占用会互相干扰。我现在的习惯是按业务域拆池比如订单推送池、消息通知池、日志处理池各自独立每个池有自己的参数、监控和告警。这样某个池出现问题时影响面可控排查时也容易定位。就个人实际体验来说线程池参数没有最好的配置只有当前流量和依赖条件下最合适的配置。理解每个参数背后的行为逻辑、队列在其中的作用、以及业务场景的资源特征你才能真正掌握线程池而不是背下一堆面试答案。
返回列表