ARTICLE DETAIL

资讯详情

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

Java线程池从入门到实战:七个核心参数、阻塞队列与拒绝策略全解析

Java线程池从入门到实战:七个核心参数、阻塞队列与拒绝策略全解析 我刚接手一个老项目的时候第一件事不是看业务代码而是先被线上告警炸了一轮CPU 飙到 90% 多接口平均耗时从 80ms 涨到 3s日志里密密麻麻全是线程创建异常。查了半天根因就是前人写的代码——每个请求进来都直接new Thread(...).start()高峰期一秒几百个请求线程数直接失控。后来我把这堆临时线程全部改造成 Java 线程池问题才算彻底压下去。这篇文章就是围绕那次重构和后续几年的线程池使用经验整理的。全文会从零基础视角把 Java 线程池拆开揉碎讲清楚七个核心参数、任务执行流程、阻塞队列选型、拒绝策略、Executors 的坑、参数配置公式以及我在真实项目里踩过的五个典型问题。适合三类人看刚学 Java 不久、对线程池只停留在会用 Executors.newFixedThreadPool阶段的初学者被线上 OOM、线程数暴涨折磨过的开发以及准备 Java 面试、想从背八股升级到讲原理的求职者。1. 从来一个请求 new 一个线程开始的噩梦为什么我们对线程池又爱又怕1.1 手动创建线程的三个隐蔽痛点很多人刚开始写多线程的时候觉得创建线程特别简单new Thread(() - {...}).start()一行搞定。确实写起来简单但放到生产环境里问题会接二连三冒出来。第一个痛点是线程的创建和销毁成本并不低。Java 的线程在底层会映射到操作系统线程创建一个线程涉及 JVM 栈空间分配、操作系统内核资源的申请和初始化销毁时要走反向流程。如果一个任务只需要执行几十毫秒创建线程的开销可能比任务本身还高。这种开销在低并发下感知不到一旦请求量上来CPU 时间片大量浪费在线程创建和销毁上业务响应自然变慢。第二个痛点是线程数量完全不可控。手动 new 线程代码里写多少就有多少但谁也没法保证流量高峰期 concurrent 请求不会翻倍。线程数超出 CPU 核心数太多时操作系统会频繁进行上下文切换每次切换都要保存和恢复线程的寄存器、程序计数器、栈指针等状态这部分开销会严重挤压真正的业务计算时间。更夸张的情况是无限制创建线程导致内存耗尽直接进程崩溃。第三个痛点是没有统一的排队机制。手动创建线程任务来了要么直接执行要么丢弃没有先来后到、等一会儿再处理的概念。一旦下游服务变慢大量请求会同时打到下游造成连锁故障排障的时候你还无从下手因为线程是散落在代码各个角落的。1.2 线程池的本质一个自带调度规则的任务处理车间线程池解决上面三个问题的思路用一句话概括把线程的创建和管理从业务代码里剥离出来统一交给一个调度器。你可以把线程池想象成一家餐厅的后厨。核心厨师corePoolSize固定在那负责日常出餐忙的时候后厨最多再临时请一些帮工maximumPoolSize帮工干完活闲一会儿就会被请走keepAliveTime来吃饭的客人太多超出厨房接待能力就在门口排队等位workQueue店里实在坐不下了新来的客人只能被礼貌告知客满下次再来拒绝策略。这个类比基本涵盖了线程池的全部核心要素。它本质上就是一个线程复用 任务排队 流量控制的管理器。线程用完后不销毁回到池子里继续接下一个任务任务多了先排队而不是直接把系统压垮系统确实处理不过来了还有明确的手段决定放弃哪些任务。理解到这层再去看线程池的源码、参数、面试题全部都能串起来。2. 七个核心参数拆解线程池的体检报告就看这七项Java 线程池最正统的创建方式是使用ThreadPoolExecutor的完整构造方法一共七个参数。很多面试题喜欢让候选人解释这些参数但实际上很多人只是背了名词不知道它们怎么联动。我一个个拆开讲。public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)2.1 corePoolSize 与 maximumPoolSize线程数的两端corePoolSize是核心线程数也就是线程池平时保持存活的线程数量。即使这些线程空闲也不会被回收。maximumPoolSize是最大线程数是线程池在极端情况下的天花板。这两个参数组合起来定义了线程池的动态伸缩范围。当任务量增加核心线程全部繁忙且任务队列也满了的时候线程池会尝试把线程数从 corePoolSize 向 maximumPoolSize 扩展。新增的这部分线程称为非核心线程它们空闲超过 keepAliveTime 后会被回收而核心线程默认不会因空闲被回收除非设置了allowCoreThreadTimeOut(true)。这里有个常见的认知误区以为核心线程数就是最低存活数最大线程数就是最多能同时执行的线程数。实际上 maximumPoolSize 确实表示最多能同时干活的数量但 corePoolSize 并不代表进程里线程池最少保留这么多线程它只是一个水位线触发扩容的判断基准是当前线程数是否小于 corePoolSize。当前线程数小于 corePoolSize 时来了新任务直接新建线程超过 corePoolSize 之后来了任务先去排队队列满才继续扩线程到 maximumPoolSize。2.2 keepAliveTime 和 TimeUnit非核心线程的裁员标准keepAliveTime指的是非核心线程空闲后存活的最大时间TimeUnit就是时间单位。超过这个时间非核心线程还没有接到新任务就会被回收。这个参数对资源利用率的平衡很关键。设得太短瞬时流量暴涨时刚扩容出来的线程很快就销毁流量还没过去又来一波线程池就要反复扩缩容产生不必要的线程创建开销。设得太长闲时大量非核心线程白白占用内存和内核资源。在配置 IO 密集型应用时我一般习惯把 keepAliveTime 设为 30~60 秒既保证流量毛刺能扛住又不会让空闲线程长期占着资源。2.3 workQueue任务排队区workQueue是线程池里的阻塞队列用于存放核心线程繁忙后提交进来的任务。这是七个参数里最容易选错、也最容易导致线上故障的一个后面我会单独用一整章来讲队列选型。这里先记住一个关键点queue 是有界还是无界直接决定了线程池会不会触发拒绝策略也决定了 maximumPoolSize 这个参数还有没有意义。如果用一个无界队列比如LinkedBlockingQueue不指定容量任务永远排得下线程数永远不会超过 corePoolSizemaximumPoolSize 和拒绝策略就全部形同虚设。这也是 Executors 工具类里newFixedThreadPool最被诟病的地方之一。2.4 threadFactory 与 RejectedExecutionHandler常被忽略的两个参数threadFactory用于创建线程看似可有可无但如果不自定义线程池创建的线程名字会是pool-1-thread-1这种。线上排查问题或者用 jstack 导线程快照的时候看到这种名字根本不知道是哪个业务模块的线程定位效率极低。我建议任何项目里都自定义 ThreadFactory给线程加上业务前缀比如order-async-worker-。同时最好在里面设置线程的daemon属性如果确认这些线程不阻塞 JVM 退出就设为 true避免应用停机时被非守护线程卡住。RejectedExecutionHandler是拒绝策略处理器当线程池已经满负荷线程数达到 maximumPoolSize 且队列已满或者线程池已关闭时新的任务会交给它处理。JDK 内置四种实现后面我会逐一讲解它们的适用场景也可以自定义。2.5 参数之间的联动关系为什么说七个参数不是孤立的这七个参数里面最核心的关系链是新任务提交时线程数 corePoolSize → 新建线程执行线程数 corePoolSize → 任务入队队列已满 且 线程数 maximumPoolSize → 新建非核心线程执行队列已满 且 线程数 maximumPoolSize → 触发拒绝策略。这条链路如果你能自己默写出来线程池的基本原理就掌握了一半。我见过太多人面试的时候把七个参数背得滚瓜烂熟一追问队列满了先扩容还是先拒绝核心线程会不会回收就卡壳问题就出在只记了参数定义没有在脑子里形成这条联动执行链。这条链路我下面的章节还会配合源码再走一遍。3. 一次任务提交后线程池内部到底干了什么3.1 完整执行流程拆解我当初学线程池的时候最大的障碍是记不住 execute 的流程后来对着源码画了一条执行链一下就通了。核心入口是ThreadPoolExecutor.execute(Runnable command)逻辑大致如下public void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); // 1. 当前线程数小于 corePoolSize直接新建核心线程执行任务 if (workerCountOf(c) corePoolSize) { if (addWorker(command, true)) return; c ctl.get(); } // 2. 线程数已到核心数尝试把任务放入阻塞队列 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); } // 3. 队列已满尝试创建非核心线程执行任务 else if (!addWorker(command, false)) // 4. 已到 maximumPoolSize拒绝任务 reject(command); }四个分支分别对应我上一节说的四种情况。注意第 2 步里的workQueue.offer(command)用的是offer而不是put。offer是非阻塞的队列满就立刻返回 false不会让提交任务的车卡在那里这也是线程池设计的一个细节——提交任务的动作本身不应该被阻塞太久。第 2 步还有一个recheck的细节。任务入队之后线程池可能在这瞬间被 shutdown 了或者刚刚创建的核心线程因为异常退出了导致队里排了任务但没有线程去消费。所以线程池在入队成功后会重新检查一次运行状态和线程数如果发现异常立即补救。这个设计我读源码的时候印象很深属于典型的并发下处处需要兜底的思维。3.2 最容易理解错的场景先到最大线程数还是先排队很多教程或者面试答案里都有一句话任务多了先把队列填满队列满了再扩线程到最大线程数再满就拒绝。这句话大方向没错但容易让人产生一个误解以为队列必须完全填满才能扩容。实际上流程是核心线程满了之后每次新任务先尝试入队。只要队列还有容量任务就进队列线程池不会扩容。当某一次入队操作失败队列已满时才会触发扩容逻辑。也就是说扩容的时机是入队失败而不是队列被填满后的下一批任务突然扩容。两者从外部看起来没差别但从源码角度判断依据是workQueue.offer()的返回值。另外还有一个极端场景如果用SynchronousQueue后面队列章节会细讲这个队列内部不存储任何任务offer 永远只在恰好有线程正在等待获取任务时才成功。所以线程池的行为会变成核心线程满 → 尝试入队没有消费者线程等待就直接失败→ 立即扩容非核心线程。这跟普通队列的行为差异非常大使用前一定要心里有数。3.3 从源码理解 Worker线程池里的线程到底是怎么跑起来的再往深挖一层线程池里的每个线程被包装成一个Worker对象它本身实现了Runnable。addWorker 成功后Worker 会启动一个新的线程然后进入一个while循环不断从队列里取任务执行final void runWorker(Worker w) { Thread wt Thread.currentThread(); Runnable task w.firstTask; w.firstTask null; while (task ! null || (task getTask()) ! null) { // 执行任务的同步锁、钩子方法、异常处理... try { task.run(); } finally { task null; } } }getTask()就是线程池线程复用的核心。它内部会调用workQueue.take()阻塞或workQueue.poll(keepAliveTime, TimeUnit)超时等待具体用哪个取决于当前线程数是否超过 corePoolSize 以及 coreThreadTimeOut 设置。非核心线程在poll超时后拿不到任务getTask()返回 nullWorker 循环退出线程随之结束。这就是 keepAliveTime 能回收线程的底层机制。看到这里你应该明白了线程池里的线程不是执行完一个任务就结束而是循环去队列里取下一个任务。复用就是这个 while 循环实现的。4. 阻塞队列怎么选从底层数据结构看四种队列的脾气队列选型是线程池配置里最容易翻车的地方。我在 3.2 节提过队列类型会反过来影响线程池到底怎么扩容而且队列选错了还会引发 OOM这里展开讲。4.1 队列选错会引发什么最经典的事故就是使用无界队列。Executors.newFixedThreadPool内部用的是LinkedBlockingQueue但没指定容量默认是Integer.MAX_VALUE。换句话说任务可以无限排队。一旦下游服务变慢任务生产的速率大于消费速率队列里积压的任务会越来越多这些任务本身持有的对象、上下文数据都在内存里很快堆内存就被撑爆触发OutOfMemoryError。这是生产环境里最常见的线程池 OOM 场景之一。队列选错还会让线程池的弹性扩容失效。核心线程满了之后所有任务都进无界队列排队线程数永远停在 corePoolSizemaximumPoolSize 配再大也没用。应用在流量高峰期没有额外线程扛压延迟会急剧上升。4.2 四种常用队列的底层差异JDK 里常见的阻塞队列有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue还有ScheduledThreadPoolExecutor用的DelayedWorkQueue。它们的差异可以这样理解队列底层结构是否有界特性典型场景ArrayBlockingQueue数组有界容量固定必须指定大小公平/非公平可配置手动创建线程池时的首选有界队列LinkedBlockingQueue链表可无界可有界默认容量 Integer.MAX_VALUE吞吐量通常比 Array 队列高作为无界队列有 OOM 风险须显式指定容量SynchronousQueue无存储天然有界为 0不存任务直接递给消费线程线程池行为表现为无排队希望任务不等待、立即执行/立即拒绝的场景PriorityBlockingQueue堆无界支持优先级排序任务需实现 Comparable有优先级要求的任务队列DelayedWorkQueue堆无界支持延迟执行按延迟时间排序定时/延迟任务调度这里特别提一下SynchronousQueue。它内部不存储元素每个 put 必须等待一个 take反之亦然。用它做线程池队列时提交的任务要么立刻被某个空闲线程拿走执行要么就触发创建新线程的逻辑。所以搭配 SynchronousQueue 时线程数会急剧增长到 maximumPoolSize适合对延迟极其敏感、又不希望排队积压的场景比如你宁可拒绝部分请求也不愿意让请求在队列里等很久。4.3 队列选型的三个原则我自己的经验手动创建线程池时通常遵循三个原则第一优先使用有界队列并且根据业务可接受的排队时间来估算容量。比如 QPS 峰值 1000单任务平均耗时 200ms希望任务最多等待 1 秒那么队列容量大约可以设在1000 × 1 1000左右再留一点余量。第二不要选无界队列除非你明确知道任务量有硬性上限。没有任何保护和兜底的无界队列本质上是拿内存换时间风险太高。第三队列类型要跟任务特性匹配。任务有明确的优先级要求就选 PriorityBlockingQueue但要注意它是无界的得配合其他机制控制总量任务需要延迟执行就选 DelayedWorkQueue任务要求尽最大努力快速执行、不能排队就选 SynchronousQueue。5. 饱和之后怎么办四种拒绝策略与实际使用场景当线程池的线程数已经到 maximumPoolSize队列也满了再进来任务就会触发拒绝策略。很多人以为拒绝策略只有一个AbortPolicy其实 JDK 内置了四种加上自定义应用场景差别很大。5.1 内置策略逐个点评JDK 的ThreadPoolExecutor内部定义了四种RejectedExecutionHandler实现策略行为适用场景AbortPolicy直接抛RejectedExecutionException默认策略适合对丢失任务敏感、宁可报错也不静默丢弃的场景CallerRunsPolicy在调用者线程里直接执行该任务适合需要削峰填谷、不想丢任务但希望放慢任务提交速度的场景DiscardPolicy静默丢弃任务适合允许丢失部分非关键任务的场景比如日志上报DiscardOldestPolicy丢弃队列里最老的未执行任务然后重试提交当前任务适合追求最新数据、旧任务可以牺牲的场景比如实时行情刷新AbortPolicy是最常用的默认策略但它最暴力任务被拒后抛异常如果调用方没处理好可能导致请求直接失败。线上如果对任务完整性要求高一般不推荐直接用它除非配合完善的异常捕获和告警。CallerRunsPolicy是我个人在内部系统里用得比较多的策略。它的实现特别简单executor.execute(runnable)在拒绝时不是抛异常而是回退到调用者线程执行任务。它的优雅之处在于天然的背压——当线程池饱和时提交任务的那个线程会被任务本身占用提交方变慢任务产生的速率也随之下降等于让生产者承担了一部分压力。这个机制在流量突刺时特别管用不会抛弃任务只是让链路慢一些。DiscardOldestPolicy则适合那种只要最新值的场景。比如缓存刷新的任务如果队列里已经积压了 10 个刷新任务现在又来一个那前面排队的旧刷新任务可能数据都已经过期了丢旧保新是合理的。5.2 自定义拒绝策略的思路内置策略不满足时可以自己实现RejectedExecutionHandler接口。比如我希望被拒绝的任务先尝试存到本地文件等线程池恢复后再重新加载执行就可以这样写public class FileBackupPolicy implements RejectedExecutionHandler { private final Path backupDir; public FileBackupPolicy(Path backupDir) { this.backupDir backupDir; } Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 将任务信息序列化后写入本地文件 // 后续由补偿任务扫描目录重新提交到线程池执行 } }这种策略适合对任务可靠性要求高的场景比如订单状态同步、对账等。但要注意落盘补偿只是兜底手段频繁触发说明线程池配置大概率不合理要回到参数配置上找根本原因。6. Executors 的快捷方法为什么大厂规范第一条就是禁用如果你在网上搜过线程池相关的文章大概率会看到阿里巴巴 Java 开发规范里一条明确约定线程池不允许使用 Executors 去创建而是通过 ThreadPoolExecutor 的方式。很多人不理解Executors 用起来多方便啊为什么不让用我来说说背后的原因。6.1 Executors 到底提供了什么Executors 相当于线程池的快捷入口常见的方法有这么几个方法线程池类型队列newFixedThreadPool(n)固定线程数无界 LinkedBlockingQueuenewCachedThreadPool()可缓存线程池最大线程数 Integer.MAX_VALUESynchronousQueuenewSingleThreadExecutor()单线程线程池无界 LinkedBlockingQueuenewScheduledThreadPool(n)定时/延迟任务线程池DelayedWorkQueue其中newFixedThreadPool和newSingleThreadExecutor用的是无界队列newCachedThreadPool的最大线程数是Integer.MAX_VALUE。这两个设计隐患分别对应两类线上事故。6.2 两个经典OOM场景复盘场景一newFixedThreadPool(10)处理下游系统推送的消息结果下游系统出故障消息量激增。线程池里 10 个线程处理不过来新任务全部堆进无界 LinkedBlockingQueue队列长度一路涨到几百万。每个任务对象就算只占几百字节堆内存也被迅速耗尽应用 OOM。场景二newCachedThreadPool()在突发流量下因为队列是 SynchronousQueue任务一来就尝试创建新线程。允许的最大线程数是Integer.MAX_VALUE也就是说理论上可以创建无限多个线程。线程不断创建最终把内存和文件描述符耗尽而且这种故障往往在几分钟内就会把整个 JVM 拖垮连 dump 都来不及。正确做法是手动new ThreadPoolExecutor(...)把所有关键参数都显式管控起来队列指定有界容量线程数给出明确上限拒绝策略按业务选择。代码是多几行但换来的是对资源耗尽风险的控制能力。我见过太多团队上线初期图省事最后在流量高峰被无界队列坑得痛不欲生。7. 线程池参数到底怎么配从理论公式到落地场景参数配置是零基础入门到精通里面最实务的一环。网上流传着各种计算公式但直接抄的人很容易被坑因为公式只是起点关键要理解背后的假设。7.1 两个经典公式怎么用最常见的是根据任务类型分两个公式CPU 密集型任务核心线程数 CPU 核数 1IO 密集型任务核心线程数 CPU 核数 × (1 平均等待时间 / 平均计算时间)CPU 密集任务加 1 是为了在某个线程因缺页中断、系统调用等原因偶尔让出 CPU 时仍然能有一个额外的线程顶上去提高 CPU 利用率。IO 密集型任务的公式本质上是根据任务里花在等待 IO 上的时间占比来推算需要多少个线程并行发出 IO 请求才能把 CPU 的等待时间也利用起来。但这个公式有个前提你能准确估算等待时间和计算时间。很多业务任务里既有 CPU 计算又有 DB 访问、RPC 调用、Redis 操作平均值不好估所以公式更多是用来给一个初始值然后靠压测调整。7.2 三类业务场景的配置参考说实话工程里的线程池配置与其迷信公式不如按场景分阶段设定再用压测验证。我给出三类典型场景的配置参考场景一CPU 密集计算比如数据处理、图片压缩int cpuCore Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor new ThreadPoolExecutor( cpuCore, cpuCore 1, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new NamedThreadFactory(cpu-task), new ThreadPoolExecutor.CallerRunsPolicy() );这里核心和最大相差不大因为 CPU 密集任务太多线程反而因上下文切换降低吞吐。场景二IO 密集型比如 Web 接口异步化、调用下游 RPCThreadPoolExecutor executor new ThreadPoolExecutor( 16, 32, 60, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), new NamedThreadFactory(io-task), new ThreadPoolExecutor.CallerRunsPolicy() );核心线程数可以根据CPU 核数 × (1 IO等待/计算耗时)先算个初值再压测调整。这里我给了 16 作为初始值是因为很多业务场景下 IO 等待占比大约在 70%~90%按照公式算出来往往在核数的 5~10 倍之间。场景三高并发流量入口比如网关转发这种场景最怕任务排队太久同时不希望线程无限膨胀。队列容量通常设得比较小甚至直接用 SynchronousQueue最大线程数配合降级开关和拒绝策略使用。ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 30, TimeUnit.SECONDS, new SynchronousQueue(), new NamedThreadFactory(gateway-task), new ThreadPoolExecutor.CallerRunsPolicy() );7.3 压测才是最终答案不管初始参数怎么配最后都要回到压测验证。我建议压测时重点看三个数据任务排队时长、线程池活跃线程数、拒绝任务数。如果活跃线程长期接近 maximumPoolSize说明线程数可能不够要么增大 maximumPoolSize要么优化任务本身的耗时。如果队列经常积压几百上千个任务说明消费速度跟不上生产速度这时候要关注下游瓶颈而不是一味调大队列。如果出现拒绝先看是不是配置过于保守再决定是调参还是优化业务链路。参数配置不是一次性的事线上流量结构变化之后需要重新评估。8. 真实项目里最容易踩的坑几乎做过线程池的人都会遇到8.1 线程池没有业务命名排障全靠猜没有自定义 ThreadFactory 的线程池jstack 出来全是pool-1-thread-1、pool-2-thread-1线上有几十个线程池的时候根本无法区分。诊断 CPU 飙高、线程阻塞、死锁问题都极其费劲。自定义 ThreadFactory 很简单三行代码的事ThreadFactory factory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(0); Override public Thread newThread(Runnable r) { Thread t new Thread(r); t.setName(biz-order-thread- seq.incrementAndGet()); t.setDaemon(false); return t; } };这一步花不了两分钟但排查问题的时候能省半天到一天的时间。8.2 任务异常被吞掉日志里什么都看不到这是一个非常隐蔽的坑。execute()提交的任务如果抛出RuntimeException线程池会捕获异常然后把该 Worker 线程销毁重建。如果你没有在任务内部自行 try-catch在线程池的外部日志里很可能什么都看不到异常信息会被吞掉故障定位无从谈起。解决方式有几个层面。第一任务内部不要裸奔核心逻辑必须 catch 异常并记录日志第二线程池的afterExecute钩子方法里可以拿到任务异常或者干脆给任务统一封装一层 try-catch 结构第三如果用的是submit()提交任务异常会被封装进Future不调用future.get()的话异常依然不会抛出来。所以submit 之后一定要记得处理 Future 的结果和异常。8.3 线程池里的 ThreadLocal 内存泄漏ThreadLocal 在线程池里是最容易出问题的。普通线程用完销毁ThreadLocal 的 value 跟着线程一起没了但线程池里的线程是复用的如果任务里往 ThreadLocal 塞了数据执行完之后没有移除下个任务从同一个线程里可能会读到上一个任务遗留的数据造成数据串线。更严重的是如果 ThreadLocal 里放的是一个重量级对象而线程长期存活这个对象无法被回收就会造成内存泄漏。我处理这个问题一般是在任务执行完成后在 finally 块里手动调用remove()try { traceContext.set(traceId); // 业务逻辑 } finally { traceContext.remove(); }这也是为什么有些团队会禁止在线程池任务里直接使用 ThreadLocal而是改用显式的上下文对象传递。8.4 父子任务共用线程池容易死锁第一次遇到这种死锁时我排查了很久。业务场景是这样的主线程提交一个任务到线程池任务内部又向同一个线程池提交子任务并且调用subTaskFuture.get()等待子任务结果。当线程池所有线程都在执行这类等待子任务的任务时就形成了一个循环等待——没有空闲线程去执行子任务父任务永远等不到结果线程池整体死锁。这种问题最直接的做法是禁止在任务内部提交并同步等待同一个线程池的任务或者至少使用不同的线程池隔离。如果无法避免可以把线程池的队列容量调大、maximumPoolSize 调大但这只是缓解不是根治。设计阶段就要规避任务套任务的同步依赖。8.5 优雅关闭线程池不要但凡停机就丢任务应用关停时如果直接调用shutdownNow()正在执行的任务会被中断队列里还没开始执行的积压任务会直接丢失。正确做法是给线程池一个缓冲期先shutdown()停止接收新任务让已经提交的任务继续跑完然后awaitTermination等待一段时间超时再shutdownNow()强制关闭。executor.shutdown(); // 不再接收新任务 try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); // 超时强制关闭 } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }这个模式在微服务滚动发布、定时任务结束场景里特别重要能避免优雅停机时丢失队列里未处理完的任务。9. 面试官视角的线程池连环问答好这几道题就稳了互联网大厂面试里线程池基本是必考题而且喜欢连环追问。我整理了几个高频问题附上我从源码出发的回答思路。问线程池有哪些状态很多人只知道RUNNING和TERMINATED实际线程池有 RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED 五种状态。RUNNING 能接收新任务SHUTDOWN 不再接收新任务但处理队列里的任务STOP 不再接收新任务也不处理队列里的任务还会中断正在执行的任务TIDYING 是过渡状态表示任务全部终止TERMINATED 是最终状态。线程池用一个ctl字段的低 29 位存线程数高 3 位存状态这是线程池里一个很经典的原子状态设计。问核心线程数设置为 0 会怎么样这个冷门问题不少面试官喜欢问。核心线程数为 0 时提交第一个任务会先尝试入队队列如果空且没有空闲线程线程池会创建一个临时线程执行任务。理解了这个场景你就能清楚addWorker(null, false)在 execute 里第二分支中被调用的原因——入队后如果发现一个线程都没有可以抢救一下建一个空 Worker 去队列取任务。问submit 和 execute 的区别execute 提交任务后没有返回值异常会被线程池内部吞掉submit 返回 Future可以通过 Future.get() 获取任务结果或异常但也正因为有这个返回对象submit 任务的异常状态被包装在 Future 中不显式 get() 依然不会抛出。另一个细节是 submit 支持 Callable有返回值execute 只接受 Runnable。问如何动态调整线程池参数ThreadPoolExecutor 提供了setCorePoolSize、setMaximumPoolSize等方法运行时调整是可行的。对于核心线程数调小的情况线程池不会立即销毁多余线程而是等这些线程空闲后慢慢回收。结合配置中心可以在不重启应用的情况下动态调整参数这也就是所谓动态线程池的基础思路。问如何实现线程池监控线程池本身提供getPoolSize()、getActiveCount()、getCompletedTaskCount()、getQueue().size()等方法通过定时任务把这些指标采集到监控系统再配上告警就能做到基本的线程池监控。如果想让监控更全面可以重写beforeExecute、afterExecute和terminated钩子方法记录任务耗时、异常率和线程池生命周期。面试的时候能把以上问题从源码层面讲清楚而不是背几个概念通常就能超出大多数候选人的水平。关键还是要把前面章节的原理概念融会贯通面试官其实不是要你把源码背下来而是想看到你有从现象到原理的思考习惯。写到这里我想起自己刚接触线程池时的状态也是被各种高级线程核心线程拒绝策略这些名词搞得头晕。后来真正弄懂不是靠背而是靠一次线上故障扎扎实实地排查。那次教训让我明白线程池不是一个可以随便配几个参数就上线的工具它背后是一整套线程管理和资源控制的哲学。对初学者我的建议是花一个周末自己动手写一个 ThreadPoolExecutor把七个参数都试一遍观察线程数、队列长度和拒绝次数比看十篇博客都管用。等你能清楚地说出一个任务从提交到执行整个生命周期经历了什么这个问题时这篇内容也就真正消化了。
返回列表