ARTICLE DETAIL

资讯详情

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

Java线程池核心原理与生产实践:从execute流程到拒绝策略调优

Java线程池核心原理与生产实践:从execute流程到拒绝策略调优 很多人在面试时能把ThreadPoolExecutor的七大参数倒背如流可一回到工位上线程池照样靠Executors.newFixedThreadPool一把梭。更常见的是线上任务悄悄堆积、线程数涨到离谱、任务莫名丢失翻遍日志也找不到原因。Java线程池这个组件网上讲参数的教程一抓一大把但真正能讲清楚“执行流程为什么这样设计”“队列满了才扩容这件事到底意味着什么”“拒绝策略该怎么结合业务落地”的内容其实不多。这篇我就从源码设计意图讲起把线程池的扩缩容节奏、任务搬运机制、队列选型、拒绝策略、监控调参、优雅关停和实际踩坑串起来。不管你是正在准备面试的Java开发还是被线上线程池问题折磨的后端同学都能在这里找到能直接落地的思路。1. 面试与实战的分水岭execute() 的执行流程到底怎么走1.1 别再背“七大参数”了先搞懂 ctl 这个状态机很多人一上来就背参数corePoolSize、maximumPoolSize、keepAliveTime、TimeUnit、workQueue、ThreadFactory、RejectedExecutionHandler。说实话这些参数在官方文档里写得清清楚楚背下来不难难的是理解它们之间是怎么协同工作的。而这一切的起点是ThreadPoolExecutor里一个不太起眼的AtomicInteger——ctl。private final AtomicInteger ctl new AtomicInteger(ctlOf(RUNNING, 0)); private static final int COUNT_BITS Integer.SIZE - 3; private static final int CAPACITY (1 COUNT_BITS) - 1;ctl这个变量很巧妙它把两个状态硬塞进了一个int里高3位保存线程池的运行状态runState低29位保存工作线程数量workerCount。之所以这样设计是因为做任何操作时都需要同时感知“线程池当前状态”和“线程数量”如果用两个变量分别维护就很难保证原子性还得加锁。现在用一个AtomicInteger配合CAS两个状态一次更新既避免了锁竞争又保证了高并发下的安全性。打个比方这就像动车票上同时标了“车次”和“余票”。乘务员查票时不用跑两个地方核验一张票就能看到全部信息。有了ctl线程池里所有的核心操作——添加Worker、状态切换、线程计数——都变成了对这个整数做一次原子操作。这是理解后面所有行为的基础也是面试里一个很好的加分点。1.2 execute() 的四个分支每一步都藏着并发竞争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); }第一个分支是核心线程还没满直接尝试新建Worker执行任务。注意这里用的是addWorker(command, true)第二个参数true代表“按核心线程标准创建”这块内部会通过CAS保证只有一个线程能创建成功。多个调用方同时进入这个分支时只有一个人能赢输的人拿到最新的ctl继续往下走。第二个分支是核心线程满了尝试把任务放入工作队列。这里有个很多人没注意到的细节入队之后还有一次recheck。为什么要再检查一遍因为在你往队列里放任务的这一瞬间线程池可能已经被shutdown()了此时任务虽然放进去了但已经没人会执行它了线程池会尝试把它从队列里移除并走拒绝流程。还有一种情况入队后重新检查发现工作线程数为0——比如所有核心线程刚好在此时都异常退出了——那就得补一个空转的非核心线程专门来消费队列里已有的任务。这个细节如果你能在面试时主动说出来面试官基本会眼睛一亮。第三个分支是队列也满了线程数还没到maximumPoolSize此时才会尝试创建非核心线程。如果创建失败比如线程数已经到上限、线程工厂返回null就进入拒绝流程。这段代码整体看下来你会发现一个本质线程池不会因为任务一多就立刻加线程它的加线程节奏是“核心线程 → 队列缓冲 → 非核心线程 → 拒绝”每一层都是前一层的兜底。理解了这个顺序就不会再犯“任务堆积为什么线程数不涨”的困惑。2. “先入队还是先建线程”这个顺序比参数本身重要得多2.1 一个模拟推演core2、queue10、max5提交15个任务会发生什么纸上谈兵不如直接推演一遍。假设这样一个线程池核心线程数2有界队列容量10最大线程数5。再假设每个任务执行时间都很长比如sleep 10秒这样任务到达的速度远大于消费速度才能看到真实的调度过程。提交任务序号线程池行为当前工作线程数1创建核心线程worker-1直接执行任务12创建核心线程worker-2直接执行任务23两个核心线程都忙任务进入队列等待24~12继续进入队列队列剩余容量逐渐减少213队列已满创建非核心线程worker-3314队列已满创建非核心线程worker-4415队列已满创建非核心线程worker-5516队列满、线程数已达max触发拒绝策略5这个推演能解释一个非常常见的生产问题“为什么我把maximumPoolSize设成了100任务还是排队排到天荒地老”因为只要队列没满线程数就永远停在corePoolSize的水平。队列是缓冲区它不空出来线程池就不会认为“压力已经大到需要扩容”。如果队列是无界的那maximumPoolSize更是形同虚设核心线程永远不会扩容。所有参数都不是孤立的。你去配置线程池时脑子里应该有一个节奏感核心线程负责常态吞吐队列负责突发流量缓冲非核心线程是应对“缓冲也扛不住”的最后扩容手段。这三者的比例取决于你的业务是稳定流量还是秒杀式脉冲流量。2.2 想让线程池更“激进”有几个现成开关默认情况下线程池不会预先启动核心线程。任务到来时现创建这在大流量瞬间会有一定的线程创建开销。如果希望减少这类延迟可以调用prestartAllCoreThreads()让所有核心线程在系统启动时就就位。这个方法对很多低延迟要求高的服务很有价值缺点就是系统刚启动时会白白占着线程资源。另外还有一个经常被忽略的开关allowCoreThreadTimeOut(boolean)。默认核心线程即使空闲也会永久存活但有些业务的流量有明显的波峰波谷比如凌晨几乎没有任务这时候核心线程挂在那里纯属浪费内存和句柄。打开这个开关后核心线程空闲超过keepAliveTime也会被回收等流量来了再从0重建。这个开关一定要慎重因为从0重建核心线程是有成本的过程如果业务对低峰期突发请求的响应速度敏感就不太适合打开。2.3 keepAliveTime 的设定本质是“线程闲置多久才该退”keepAliveTime字面意思是“线程空闲存活时间”很多人只知道它管非核心线程。实际上它的语义更精确在调用poll(timeout, unit)等待任务时如果超过keepAliveTime时长还没有任务可以执行线程就“超时下班”退出执行循环。核心线程如果开启了allowCoreThreadTimeOut同样会受到这个参数约束。所以keepAliveTime并不是“线程创建后多久必须退出”而是“线程在空闲状态下能容忍多久没活干”。正因为这个机制newCachedThreadPool才能做到“空闲60秒自动回收线程”。它用的是SynchronousQueue作为工作队列配合keepAliveTime60秒实现了一个“有活就派人、没活就散伙”的动态线程池。3. Worker 的“工作循环”线程复用的秘密和阻塞队列的选型3.1 线程复用本质是一个 while 循环很多人误以为“线程复用”是线程池里存着一堆线程来了任务就发一个。其实不是。每个Worker本身就是一个Runnable只不过它的run()方法里不是一个任务而是一个无限循环——任务执行完了不退出而是继续从队列里取下一个任务。final void runWorker(Worker w) { Thread wt Thread.currentThread(); Runnable task w.firstTask; w.firstTask null; try { while (task ! null || (task getTask()) ! null) { w.lock(); try { task.run(); } finally { task null; w.unlock(); } } } finally { processWorkerExit(w, completedAbruptly); } }线程池里的“线程复用”本质是在while循环里反复执行getTask()获取新任务。核心线程在没有设置allowCoreThreadTimeOut时取任务用的是workQueue.take()——没有任务就一直阻塞等待线程看似“活着”但其实在睡着。非核心线程取任务用的是workQueue.poll(keepAliveTime)超过keepAliveTime取不到任务就返回null循环退出线程随之结束生命周期。这样设计在性能上的价值极大线程创建和销毁是重量级操作复用线程避免了反复开关线程的开销。一个长期运行的服务里真正创建线程的操作往往只发生在初始化阶段和突发扩容阶段日常的请求处理都在复用已有线程。3.2 getTask() 与阻塞队列的关系粮仓和工人的比喻把阻塞队列想象成粮仓线程是工人。任务就是粮食。核心工人取粮用take()意思是“没有粮我也坐这儿等不走”非核心工人取粮用poll(timeout)意思是“等了一阵子还没粮我就下班了”。这就解释了为什么队列对线程池这么重要——它不仅是任务的存储介质还直接决定了线程何时休眠、何时退出。getTask()内部关键逻辑大致是这样的Runnable r timed ? workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) : workQueue.take(); if (r ! null) return r; timedOut true;timed这个布尔值由两步判定得出线程数是否超过了corePoolSize或者是否允许核心线程超时。两者满足其一就用poll限时等待否则就无限期take等待。这也是为什么“为什么核心线程永远不会被回收”的底层答案——它压根没有走带超时时间的取任务逻辑。3.3 四种阻塞队列怎么选才能不被面试官一句话问倒队列实现是否默认有界数据结构典型搭配场景潜在风险ArrayBlockingQueue有界数组固定大小有界线程池生产环境最推荐容量需要精打细算LinkedBlockingQueue无界支持定为有界链表newFixedThreadPool默认无界时任务堆积可能OOMSynchronousQueue无容量直接交接newCachedThreadPool没有任何缓冲线程数可能暴涨PriorityBlockingQueue无界二叉堆任务有优先级要求可能饿死低优先级任务DelayedWorkQueue无界延迟队列ScheduledThreadPoolExecutor普通线程池几乎不用实际生产里我强烈建议用有界队列容量结合压测结果定。LinkedBlockingQueue默认无界是最常见的误用场景把core设成10、max设成20、队列不设容量结果高峰期任务几百上千万地堆在内存里直接OOM。这不是危言耸听很多线上事故的根因就是“无界队列 无界任务量”。有界队列的容量怎么定粗算思路是容量 ≈ 核心线程可接受排队时间内能处理的任务数。假设核心线程处理一个任务平均耗时50ms业务能接受的排队等待时间是1秒那队列容量大概是20左右。注意这只是起点最终要以压测拐点为准。容量太小会频繁触发扩容和拒绝容量太大则让max形同虚设资源白白占用。SynchronousQueue值得多说一句它内部不存储任何任务一个提交线程往里面放任务的时候必须有另一个线程正好在取。newCachedThreadPool配合它理论上只要任务来得够快线程数可以无限增长直到打爆系统。这也是为什么很多教程劝你“不要用Executors静态方法”——本质不是因为Executors烂而是因为它把参数边界藏了起来很多人根本不知道自己的线程池在极端流量下会变成什么样。3.4 Executors 工厂方法的每个“雷”都能在参数层面找到根源newFixedThreadPool用的是无界LinkedBlockingQueue所以maximumPoolSize参数根本没有使用机会newCachedThreadPool用的是SynchronousQueue且max是Integer.MAX_VALUE所以线程数可以无限上涨newSingleThreadExecutor也是无界队列单个线程的执行速度稍微跟不上任务就会无限积压。把这些底层参数摊开看Executors的“坑”其实都是参数组合的必然结果。所以面试被问到“为什么不推荐Executors”时不要只会背结论而是要说“因为它隐藏了关键参数的业务含义容易让使用者在流量突变时失去对资源的控制”。4. 拒绝策略业务降级的最后一道闸门4.1 四种内置策略各自适合什么样的业务当队列满、线程数也到上限时execute()会调用RejectedExecutionHandler.rejectedExecution()。JDK内置了四种策略它们的区别不是性能而是“任务被拒绝后怎么办”的哲学策略行为适合场景AbortPolicy直接抛RejectedExecutionException重要任务拒绝必须被感知CallerRunsPolicy调用者线程自己执行任务能接受调用方变慢但不能丢任务DiscardPolicy静默丢弃任务可丢的日志、统计、临时性消息DiscardOldestPolicy丢弃队头最老任务重新尝试入队实时性要求高旧数据无意义默认的AbortPolicy虽然简单但在很多生产环境里并不好用——因为你如果不显式捕获异常任务被拒绝时调用方可能毫无感知尤其是用submit()提交任务时异常会被Future吞掉。如果你是靠控制台看异常输出才知道任务被拒了那说明这个线程池的拒绝策略根本没有接入业务监控体系。CallerRunsPolicy是个很有意思的取舍。它不丢任务但把压力传导给了调用方任务在提交者自己的线程里跑线程池的压力瞬间变成了调用方的RT上涨。这种策略适合内部系统之间能接受“我慢一点”的场景不适合对下游RT有严格承诺的接口。4.2 自定义拒绝策略把“拒绝”变成“降级与补偿”实际项目中我更推荐写一个自定义策略把线程池溢出这件事从“默默发生”变成“可追踪、可恢复”。你在rejectedExecution()里可以同时做三件事上报监控指标、把任务序列化后投递到MQ做异步补偿、记录现场日志便于事后排查。public class AlarmAndFallbackPolicy implements RejectedExecutionHandler { private final MetricRegistry registry; private final MqProducer producer; public AlarmAndFallbackPolicy(MetricRegistry registry, MqProducer producer) { this.registry registry; this.producer producer; } Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { // 1. 指标打点告警系统感知线程池拒绝 registry.counter(threadpool.rejected.total).inc(); // 2. 如果任务实现了任务上下文接口可以序列化后发MQ延时重试 if (r instanceof ContextTask) { ContextTask task (ContextTask) r; producer.send(task.toMessage()); } // 3. 保留现场便于排查为什么线程池会满 String poolInfo String.format(pool%s, active%d, poolSize%d, queueSize%d, e.getClass().getSimpleName(), e.getActiveCount(), e.getPoolSize(), e.getQueue().size()); log.warn(thread pool rejected. poolInfo: {}, poolInfo); } }这个策略的核心理念是拒绝不要紧要紧的是拒绝之后有没有补偿链路。日志类、缓存预热类任务丢了也就丢了订单状态更新、支付回调这类任务如果真的被拒一定要有重试和告警。自定义策略把这种“业务降级开关”掌握在你的手里而不是依赖默认策略去裸奔。4.3 submit() 会让“拒绝策略”静默失效这个坑必须提前知道同样是提交任务execute()和submit()在异常传播上有一个非常大的差异。submit()会把Runnable包装成FutureTask如果任务因线程池拒绝而无法执行异常被存在FutureTask内部不会抛给提交者。你提交完任务后如果不调用future.get()那个RejectedExecutionException就无声无息地消失了。这就导致一个很有趣的排查场景线上某个线程池明明配置了AbortPolicy日志里却找不到任何异常但任务就是丢了。原因大概率是业务代码用submit()提交且从不检查Future。解决思路有两个一是提交任务时统一使用包装后的execute()接口并在包装层处理异常二是对Future的获取设置超时避免无限期阻塞同时能拿到拒绝异常。5. 监控和动态调参让线程池从“拍脑袋配置”变成“数据驱动”5.1 先用线程池自带指标做一轮基础监控很多人配置线程池时是“拍脑袋”定的core10、max20从来不看运行数据。其实ThreadPoolExecutor本身就暴露了一组很有用的监控指标配合一个定时任务就能搭一套基础监控。getPoolSize()当前线程数观察是否长时间贴在上限。getActiveCount()活跃线程数注意这是个近似值通过Thread的状态估算不一定精确。getQueue().size()队列深度这是最需要盯的指标之一持续上涨说明消费能力跟不上。getTaskCount()和getCompletedTaskCount()累计任务总数和已完成数两者之差可以看出积压趋势。拒绝计数通过自定义RejectedExecutionHandler埋点获取。一个简单可行的实践每10秒采集一次队列深度和活跃线程数如果队列深度持续上升且活跃线程数接近maximumPoolSize就要考虑扩容或优化消费逻辑如果队列长期为空且活跃线程数很低说明配置可能过于保守可以把core调小释放系统资源。5.2 动态调参线程池不是配置完就一劳永逸ThreadPoolExecutor提供了三个非常实用的动态方法setCorePoolSize、setMaximumPoolSize、setKeepAliveTime。这意味着线程池的参数并不需要在创建时写死完全可以在运行期根据实时流量调整。动态调整的原理并不复杂调小corePoolSize时线程池会通过interruptIdleWorkers()中断那些空闲的工作线程让它们退出循环调大时则会尽量把线程数补到新阈值附近。注意setMaximumPoolSize调小的时候不会打断正在执行的任务只会等线程自然退出不用担心暴力中断导致任务半途而废。一个常见的动态调参思路是“队列深度阈值触发”public void adjustPool(ThreadPoolExecutor executor, int currentQueueSize) { int max executor.getMaximumPoolSize(); if (currentQueueSize 100 max 64) { executor.setMaximumPoolSize(max 8); executor.setCorePoolSize(executor.getCorePoolSize() 4); } else if (currentQueueSize 10 executor.getCorePoolSize() 4) { executor.setCorePoolSize(executor.getCorePoolSize() - 2); executor.setMaximumPoolSize(Math.max(executor.getMaximumPoolSize() - 4, 8)); } }这里要特别强调所有动态调参都必须建立在压测结果之上不要拿生产环境当试验田。先通过压测找到“RT拐点”和“队列积压拐点”确定参数上下限再让动态调整规则在这个区间内活动。很多团队配置动态线程池组件结果调爆了就是因为没有给调整范围设边界。5.3 既然有了虚拟线程线程池还那么重要吗JDK 21正式引入了虚拟线程这确实让很多人开始重新思考线程池的价值。虚拟线程是JVM调度的轻量级线程适合大量短生命周期、且经常阻塞的任务场景可以把平台线程从“每个任务一个线程”的模式里解放出来。但在存量的Java服务里ThreadPoolExecutor依然是最核心的异步调度基础设施各种中间件、业务框架、Spring的Async底层都是它。虚拟线程并不能完全替代线程池它更适合那种“任务数巨大但每个任务很快”的场景而线程池通过队列和参数控制为资源使用提供了确定性边界。对绝大多数业务来说把ThreadPoolExecutor用明白收益仍然远大于追新。6. 优雅关闭线程池从 shutdown 到“服软”的完整姿势6.1 shutdown、shutdownNow、awaitTermination 之间的差别线程池的关闭比创建更容易翻车。先看三者的定位方法行为隐患shutdown()不再接受新任务但已提交任务会继续执行如果任务不结束线程池永远不会终止shutdownNow()停止接受新任务清空队列并返回未执行任务中断正在执行的任务中断只是设置标志位任务不响应中断就无效awaitTermination(timeout, unit)等待线程池进入TERMINATED状态超时返回false但不会主动“杀死”线程很多人以为shutdownNow()是一把“终止线程池”的万能钥匙其实它只做两件事把队列里还没执行的任务清空并返回以及给正在工作的线程发中断信号。如果你的任务里没有处理中断的逻辑比如没有检查Thread.currentThread().isInterrupted()线程该跑完还是会跑完shutdownNow()压根杀不死它。6.2 一个生产可用的优雅停机流程我通常把优雅停机拆成五个步骤顺序很重要在流量入口摘除当前节点的流量让线程池不再接收新任务。这一步很多人会漏直接在RPC服务还在接流量时shutdown线程池结果新任务全部被拒。调用shutdown()让已提交的任务继续完成。调awaitTermination等待一段时间比如总预留时间的70%观察是否自然终止。如果超时仍未终止调用shutdownNow()把队列里未执行的任务列表保存下来用于日志或补偿。再次awaitTermination等待剩余时间如果还活在工作状态记录告警保留现场。public static void shutdownPool(ThreadPoolExecutor pool, long timeout, TimeUnit unit) throws InterruptedException { pool.shutdown(); long totalNanos unit.toNanos(timeout); long waitNanos totalNanos * 7 / 10; if (!pool.awaitTermination(waitNanos, TimeUnit.NANOSECONDS)) { ListRunnable dropped pool.shutdownNow(); log.warn(pool shutdown timed out, dropped {} tasks, dropped.size()); if (!pool.awaitTermination(totalNanos - waitNanos, TimeUnit.NANOSECONDS)) { log.error(pool still not terminated after force shutdown); } } }shutdownNow()返回的ListRunnable只是个任务对象列表如果任务类没有覆盖toString()你根本看不出被丢的是什么。这也是为什么很多团队会做一个统一的ContextTask包装类把业务身份、traceId、提交时间都打进去既方便排障也方便失败补偿。6.3 Spring 容器关闭时线程池没关的典型翻车现场如果你是Spring应用把线程池定义成BeanSpring会在容器销毁时自动调用它的close方法吗不一定。ThreadPoolExecutor并没有实现DisposableBeanSpring默认不会自动关闭它。很多人把线程池new出来放到一个静态Map里全局使用等应用发布重启时旧节点明明还有任务在执行JVM却被强制退出任务丢得不明不白。稳妥做法是把线程池声明为Spring Bean配置destroyMethodshutdown或者在PreDestroy里显式调用优雅关闭流程。这样在容器销毁时还有机会把存量任务处理完。对执行时间很短的任务这一步可能无所谓但对跑批、异步导入导出这类长任务场景这一步省了线上事故就离你不远了。7. 生产环境踩过的线程池大坑建议收藏7.1 线程池里 ThreadLocal 串值服务调用链全乱线程复用的另一面是线程局部变量被“带”到下一个任务里。你在线程池里跑任务A任务A设置了traceId到ThreadLocal任务A执行完线程回到池子里下一个任务B被同一个线程执行它读到的还是任务A的traceId。线上排查请求时发现日志链路串了十有八九就是这个原因。解决方案是在每个任务执行完后的finally块里强制remove()对应的ThreadLocal。如果上下文需要跨线程传递推荐使用阿里开源的TransmittableThreadLocal它能在提交任务时快照父线程的上下文执行时再注入子线程。无论用哪种方案核心原则是一致的线程池里的ThreadLocal必须“谁设置谁清理”不能依赖线程结束来自然清扫。7.2 任务异常被吞execute 和 submit 的“黑匣子”execute()提交的任务如果内部抛了RuntimeException线程池会打印到System.err然后这个线程就“死”了线程池会新建一个线程顶上。麻烦在于调用方完全感知不到异常任务等于悄悄失败了。如果你在日志里只查log4j输出可能根本看不到线索。submit()更隐蔽异常会被封装在Future内部。有人线程池用的是submit()提交完又不去拿返回值异常直接被吞——连stderr都没有。排查“任务数量不对”的时候第一步就去看是不是这种静默异常导致的。生产上建议对核心业务任务做统一包装在Runnable外面包一层try/catch finally记录异常上下文、耗时、以及是哪个业务池在执行这样任何异常都会进入你的监控体系。7.3 父子任务互相等待导致线程池“死锁”线程池死锁不像数据库死锁那么广为人知但真实发生时会让人非常头疼。典型场景线程池核心线程只有2个任务A在运行中向同一个线程池提交了任务B并且为了拿B的结果A调用了future.get()。A占住了线程B却排在了队列里——队列需要有空闲线程来消费而两个线程都被A阻塞在get()上。结果就是谁都跑不动线程池死锁。这个坑在CompletableFuture或ForkJoinPool里也常见在compute()里继续fork()子任务然后join()等待如果池子里的线程都被阻塞工作流就卡死了。解决办法很简单也很粗暴不要在任务内部等待同一个线程池执行的任务。如果必须在任务里做异步拆分就把父子任务放到不同线程池或者把“等待结果”这件事从阻塞变成回调组合。7.4 “CPU核数1”和“CPU核数*2”都是起点压测才是终点线程数到底设多少网上一堆公式计算密集型用“CPU核数1”IO密集型用“CPU核数*(1等待时间/计算时间)”。这些公式不全是错的但很多人拿着公式就直接当生产配置完全不验证。实际情况是一个服务的任务里既可能有计算也有锁竞争还有IO等待等待/计算的比值根本不是一个静态值。更靠谱的做法是先按一个初始值配置然后压测。固定QPS压测观察RT和队列深度。你会发现线程数增加到某个点之后因为上下文切换和锁竞争RT不降反升那个点才是你的真实推荐参数。所谓“调参”本质上是在找吞吐和延时的平衡点而不是套公式。7.5 线程不命名排查问题先输一半线程池如果不设置ThreadFactory线程名就是pool-1-thread-1。线上出现CPU飙高或线程泄漏时你拿着线程dump去看根本不知道这是哪个业务池的线程。建议所有线程池统一使用带业务语义的命名工厂ThreadFactory factory new ThreadFactory() { private final AtomicInteger idx new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(order-search-pool- idx.getAndIncrement()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, ex) - log.error(thread {} terminated by uncaught exception, thread.getName(), ex)); return t; } };这段代码里有两件事值得注意第一线程名里带上业务模块名线上排查时一眼能定位第二设置UncaughtExceptionHandler防止线程因为未捕获异常“不明不白”死掉。如果不想手写用Guava的ThreadFactoryBuilder也行但核心原则是一样的线程必须有名异常必须有记录。7.6 局部创建线程池比线程泄漏更难发现有些新手会把线程池定义在方法内部每次调用都new ThreadPoolExecutor(...)用完之后不shutdown。这个做法的后果是线程池对象和线程都成了无根对象GC无法回收线程数随调用次数无限累积最终线程耗尽或内存溢出。排查这类问题时线程dump里会出现大量同名线程每个池的线程数虽小但池的数量已经爆炸。解决思路也很明确线程池必须是全局共享的资源要么是Spring Bean要么是静态单例。它的生命周期必须跟随应用而不是跟随一次方法调用。所有业务代码里提交任务就是提交任务创建和销毁线程池的职责应该收敛在底层组件里而不是散落在业务各处。如果让我重新设计项目里的线程池我会把它拆成几件互相独立的事参数全部走配置中心下发不在代码里写死线程工厂统一命名并挂上UncaughtExceptionHandler任务提交统一走一个包装类负责异常捕获、TraceId透传和耗时打点监控指标接入现有监控平台拒绝和积压都要能告警应用停机流程里必须包含优雅关闭模板。这些事单拿出来都不起眼但合在一起线程池才真正变成了系统里一个可观测、可治理的组件。每次线上排查线程池问题我都会提醒自己线程池本身没有错错的是使用方式以及使用它的人有没有把它当成一套完整的调度系统来对待。
返回列表