ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

线程池生产级调优实战:参数计算、拒绝策略与异常处理解析

线程池生产级调优实战:参数计算、拒绝策略与异常处理解析 这个系列前两篇把线程池的基础用法和生命周期讲完了能动手写ThreadPoolExecutor、知道execute和submit区别的人应该不少。但说实话我见过太多项目里线程池是“配了就行”的状态——队列用无界的核心线程数拍脑袋填个 8拒绝策略压根没想过。这玩意儿在低并发下怎么跑都行一旦流量上来要么内存被打爆要么任务静默丢失线上排查的时候你甚至不知道问题出在线程池上。这篇我打算换个角度把真正影响线程池运行效果的东西聊透阻塞队列怎么选、七个参数的计算逻辑到底是什么、任务被拒绝之后怎么办、线程池里的异常为什么经常“消失”以及我在生产环境调优时踩过的一些坑。如果你已经会用ThreadPoolExecutor但总觉得配置得不够稳或者面试被问“线程池参数怎么设置”时只能背公式那这篇就是写给你的。1. 从 execute 到 run线程池内部的任务流转逻辑我们先不急着上配置先把线程池处理任务的全过程在脑子里过一遍。这一步没想清楚后面调参就是瞎猜。一个任务交给executor.execute(task)之后线程池的执行逻辑是固定的先看当前运行的线程数是否小于核心线程数是就直接新建线程跑任务不是就尝试把任务丢进工作队列队列满了之后再判断当前线程数是否小于最大线程数是就继续新建非核心线程如果连最大线程数都满了才会触发拒绝策略。这个顺序一定要背清楚它是理解一切参数配置的基础。我见过不少程序员以为“任务来了先判断队列有没有位置没有位置就加线程”其实不对。线程池是先尝试用核心线程消化任务消化不了才往队列里塞塞不下了才扩容线程。这个顺序直接决定了队列在有界情况下的行为只要队列没满线程数就永远不会超过核心线程数。另一个容易忽略的点是非核心线程不是任务来了立刻创建而是ThreadPoolExecutor内部通过addWorker方法用CAS更新workCount状态来控制创建流程的。这个workCount和线程池状态被打包在同一个原子整型ctl里高 3 位表示运行状态低 29 位表示线程数。为什么要这样做因为线程池经常要同时判断“当前状态是否允许新建线程”和“当前线程数是否已满”用同一个变量做原子操作就能避免加锁带来的性能损耗。从这个角度看线程池的本质就是一套“有限线程 任务队列 状态机”的协作机制。线程是稀缺资源不能无限创建队列是缓冲地带用来吸收突发的任务积压状态机则负责处理生命周期管理从 RUNNING 到 SHUTDOWN、STOP、TIDYING、TERMINATED每一步都有严格的触发条件。后续章节里所有参数调整本质上都是在调整这套机制在不同压力下的“应对策略”。核心线程数决定日常处理能力队列长度决定缓冲能力最大线程数决定极限处理能力拒绝策略决定兜底行为。四者缺一不可。我额外建议你在项目中重写ThreadFactory给线程池里的线程起一个见名知意的名字比如order-async-thread-这种前缀。后面线上用jstack抓线程栈排查问题或者用Arthas看线程状态时没有名字的线程会让你在几百条线程栈里大海捞针有名字三秒钟就能定位到是哪个线程池出的问题。这个习惯越早养成越好。2. 阻塞队列怎么选线程池吞吐量的隐形开关工作队列是线程池里最容易被一笔带过、实际上极其影响性能的参数。JDK 提供好几种实现各有各的脾气选错了在特定业务形态下会非常难受。2.1 五种队列的对比与适用场景我把常用的队列整理成了表格方便对照。这里说一句网上很多文章喜欢直接给结论比如“用有界队列安全”但从来不讲为什么也不讲各自的坑。我在这把每种队列的优缺点和典型场景都列出来你自己对照业务选。队列实现是否有界特点典型使用场景需要注意的问题LinkedBlockingQueue可指定有界但默认无界链表结构吞吐量较高JDK 默认Executors.newFixedThreadPool使用默认无界任务堆积时可能导致 OOM务必显式设置容量ArrayBlockingQueue必须有界数组结构可预分配内存性能稳定需要严格控制队列大小的场景容量在构造时固定无法动态扩容需合理预估值SynchronousQueue不存储任务不缓存任务直接交给线程处理Executors.newCachedThreadPool使用适合任务量大且执行时间很短的场景没有队列缓冲线程数会快速膨胀到最大线程数PriorityBlockingQueue无界支持优先级排序需要按优先级执行任务的场景无界队列同样存在 OOM 风险且优先级由任务自身实现Comparable决定DelayedWorkQueue无界延迟队列任务到时间才执行ScheduledThreadPoolExecutor内部使用非线程池直接配置但理解后能更好地理解定时线程池行为LinkedBlockingQueue和ArrayBlockingQueue是最常用的两个选择。LinkedBlockingQueue由于链表节点是动态创建的只有在任务真正入队时才分配内存对容量的容忍度更高ArrayBlockingQueue则是预先分配好数组空间虽然少了扩容和节点分配的开销但队列大小必须一开始估准。如果你的队列需求比较稳定比如长期维持 1000 左右的积压量ArrayBlockingQueue省内存且性能更可预期如果队列容量可能有较大的季节性波动用LinkedBlockingQueue并设一个较大的上限更稳妥。2.2 SynchronousQueue 为什么不适合所有场景SynchronousQueue值得单独说一下。这个队列内部不存储任何任务生产者线程往队列里放任务时必须有一个消费者线程正在等待接收否则这个放操作就会阻塞。换句话说它强行让每一个任务都必须被一个线程立刻处理。在CachedThreadPool里配合无上限的最大线程数SynchronousQueue能做到“有多少任务就创建多少线程”。听上去很美好但隐患在于如果任务执行时间稍长而任务提交速率一直很高线程数会不停上涨直到达到maximumPoolSize。如果你是用Executors.newCachedThreadPool这个最大值是Integer.MAX_VALUE和无限基本没区别。任何线程创建都是有开销的几千个线程同时跑光上下文切换就能让 CPU 打满。所以我的建议是SynchronousQueue只适合那种“任务本身非常轻、执行时间极短、提交速率可控”的场景比如转发一个内部事件、写入一个内存缓存。如果任务涉及 RPC 调用、数据库操作、文件 IO别碰它。这类场景任务耗时不可控队列的意义恰恰在于吸收不可控的波动。2.3 队列选择和最大线程数之间的微妙关系这节算是我在实际项目里总结出来的一个规律队列长度和最大线程数共同决定了一个线程池在突发流量下的行为模式。队列比较长、最大线程数比较小时突发流量会被队列吸收线程数波动小整体延迟会变高但系统稳定队列比较短、最大线程数比较大时突发流量会更多地转化为线程创建线程数波动大短时吞吐量高但系统负载可能被拉得很高。具体怎么选我通常遵循一个原则核心线程数对应的处理能力如果能覆盖平均负载队列长度设置为平均负载下允许的最大排队时间最大线程数设置为能承受极限负载的线程数。比如一个处理订单的任务平均耗时为 50ms你希望响应时间增加不超过 5 秒那么队列里的任务最多只能是 100 个左右。这时候队列长度定为 100、最大线程数按极限压力的 2 倍来配就能在延迟和资源之间取得平衡。3. 线程数配置不是拍脑袋七个参数的计算逻辑很多同学面试背得下“CPU 密集型配 N1IO 密集型配 2N”但真到项目里就露馅了因为N指的是什么是宿主机 CPU 核数还是容器限制的核数IO 等待时间比怎么算多个线程池之间怎么分配这些问题背公式解决不了。3.1 核心线程数的通用估算法先把最基础的公式理一遍。CPU 密集型任务计算为主几乎没有阻塞的核心线程数建议设置为CPU 核数 1。加的这个 1 是为了应对某个线程因缺页中断或系统调用等原因暂停时能有一个额外的线程顶上保证 CPU 利用率不打折。IO 密集型任务有大量阻塞等待比如 RPC、数据库、磁盘操作建议设置为CPU 核数 * 2。这个公式其实是对更精确的N * (1 WT/CT)的简化其中WT是等待时间CT是计算时间。如果你的任务里 IO 占比特别高比如等待 100ms、计算只有 1ms那WT/CT接近 100线程数甚至可以远远超过 2N。一个直观的理解是线程在等 IO 的时候 CPU 是空闲的多开线程就是为了让 CPU 在等待间隙也有活儿干。这里有三个容易踩的坑第一N不能简单取Runtime.getRuntime().availableProcessors()的返回值。在容器环境里这个方法不一定能正确读到cgroup的 CPU 限制。有些 JDK 版本在容器里会读到宿主机核数导致你的线程数按物理机 32 核去配而实际给你用的只有 4 核。我建议线上通过-XX:ActiveProcessorCount4显式指定或者读取容器配额来自动计算。第二核心线程数和最大线程数不要设置成同样的值。如果两者相同队列满的时候线程池不会创建新线程所有的积压都会堆在队列里。真要遇到流量尖峰延迟会瞬间飙高甚至拖垮下游。留出最大的余量相当于给了线程池一定的“爆发力”。第三所有公式算出来的都只是初始值线程池参数必须在压测中持续调整。先按公式算再用压测数据校准这才是正规流程。3.2 keepAliveTime 的设置细节keepAliveTime决定非核心线程在空闲多久后被回收。这个参数看似简单但有两个细节值得注意。第一个细节如果在线程池构造时通过allowCoreThreadTimeOut(true)开启了核心线程超时那么核心线程在空闲时间超过设定值后也会被回收。开了之后理论上的“核心线程常驻”就不存在了线程池在低峰期会释放几乎所有线程。这个配置适合那种业务有明显峰谷期的系统省电省内存但代价是流量突增时线程池需要重新创建线程短时间内的响应速度会打折。第二个细节keepAliveTime不是越小越好。线程创建本身是有开销的包括内核线程的创建、栈空间的分配。如果你的业务每隔几秒钟就会有一波小流量把超时时间设成 5 秒就很容易导致线程被回收后又立刻创建反复震荡反而增加了系统负担。这时候设成 30 秒或 60 秒会更平滑。3.3 多线程池部署时的容量规划一个系统往往不止一个线程池。常见的情况是RPC 调用一个池、异步消息处理一个池、定时任务一个池。如果每个池都按“最大负载”来配置叠加起来的总线程数会非常可观甚至超过系统承载能力。我通常的做法是先给整个应用设定一个线程总数预算比如 200 个然后按业务优先级分配。核心链路比如支付、下单分 60%非核心链路比如日志上报、消息通知分 30%预留 10% 给紧急扩容或者额外线程池。预算定好后再去各个业务方沟通他们的 QPS、耗时和可接受的排队时间反推每个池的参数。这个过程中你会发现很多业务方自己都不知道自己的峰值 QPS 是多少这时候就需要拉监控数据来支撑。另一个经验是尽量统一线程池的命名和管理。项目里最好有一个线程池管理类集中创建和持有所有线程池不要散落在各个业务代码里。这样以后调整参数、接入监控、排查问题时只需找一个入口就行。4. 拒绝策略不只是四选一场景与实际处理当队列满了、线程数也到了最大值新提交的任务就会交给RejectedExecutionHandler。JDK 自带了四种策略大部分人只知道默认的AbortPolicy一旦任务被拒直接抛异常然后呢很多项目里异常没有被捕获任务就这样没了。4.1 四种内置拒绝策略的行为对比四种策略的行为和适用场景完全不同我整理成了表格策略名行为风险适用场景AbortPolicy直接抛出RejectedExecutionException异常处理不当会造成任务丢失默认策略适用于不允许丢弃任务的场景但必须配合上层捕获处理CallerRunsPolicy任务在提交线程中直接执行提交线程会被阻塞影响自身响应写入优雅降级逻辑的关键场景既能减缓压力又能保证任务不丢DiscardPolicy静默丢弃新提交的任务任务无声无息消失排查困难几乎不推荐除非业务明确允许丢弃DiscardOldestPolicy丢弃队列中最老的任务然后重新提交当前任务可能会丢弃未执行的重要任务拉取最新数据的场景如实时行情、日志采集实际项目里我最常用的是CallerRunsPolicy。想象一个场景没有它的时候线程池饱和后新任务直接抛异常用户看到的是 500 错误有它的时候提交任务的线程自己把任务跑完用户可能只是觉得响应慢了那么几十毫秒但请求没有失败。这就是“用延迟换成功率”的思路。当然CallerRunsPolicy也有问题。如果提交任务的是 Web 请求线程且任务本身比较耗时那么线程池饱和时请求线程会被任务拖住导致后续请求在 Tomcat 容器里排队最终可能把整个接口拖死。这时候需要结合CallerRunsPolicy和上层限流一起用不能只靠它兜底。4.2 实现一个带降级的自定义拒绝策略在一些不能丢失任务的场景里内置策略不够用需要自己实现兜底逻辑。我分享一个处理订单回调任务的自定义策略思路。RejectedExecutionHandler handler (r, executor) - { if (r instanceof OrderCallbackTask) { OrderCallbackTask task (OrderCallbackTask) r; // 方案一写入本地可靠消息表由定时任务扫描重投 callbackMessageStore.save(task.getPayload()); // 方案二投递到消息队列由另一个专门处理削峰的系统消费 mqProducer.send(task.getPayload()); // 记录业务侧降级日志方便追踪 log.warn(callback task rejected, payloadId{}, downgraded to store, task.getPayloadId()); } else { // 其他类型任务走默认策略 throw new RejectedExecutionException(Task rejected: r.toString()); } };这个策略的核心思路是任务被线程池拒绝不可怕可怕的是没有后续补救措施。写可靠消息表或者投递 MQ本质上是在线程池之外再包一层缓冲机制让任务不会丢。代价是延迟变高、链路变长所以降级路径里一定要有日志和监控否则哪天降级策略失效了都发现不了。4.3 拒绝策略和监控告警必须配套我最想强调的一点是拒绝策略不能只做“处理动作”必须同时触发告警。无论你用的是内置策略还是自定义策略只要发生了拒绝说明系统已经处于过载状态。如果不在这一刻把问题暴露出来过载会持续蔓延最终导致更严重的雪崩。一个简单的做法是在自定义拒绝策略里做三件事记录被拒任务的关键信息类型、提交时间、业务 ID累加一个AtomicLong类型的指标定期上报到监控系统当一定时间窗口内的拒绝次数超过阈值时通过短信或企业微信推送告警。有了这三个动作下次线程池再饱和时你就能第一时间知道而不是等用户反馈才去查日志。顺带说一下getActiveCount()、getPoolSize()、getQueue().size()这几个方法拿来告警也很有用。队列积压数持续上升是一个比拒绝次数更早的预警信号可以在拒绝发生之前就调整流量或者扩容线程池。把队列积压数设置为阈值告警可以比“拒绝策略触发告警”提前几分钟发现问题。5. 线程池里的异常处理最容易漏的坑线程池里任务抛异常和我平时try-catch的习惯完全是两码事。很多线上故障的根因就是这里没处理好。5.1 execute 与 submit 的异常行为差异使用execute提交任务时如果任务内部抛出了RuntimeException这个异常会被线程池内部捕获然后交给线程的UncaughtExceptionHandler。如果线程池是用默认ThreadFactory创建的UncaughtExceptionHandler为空异常会直接被打印到控制台然后线程被销毁重建。问题在于如果你没有看控制台日志的习惯这个异常就像没发生一样任务后面的逻辑不会继续执行业务结果却是错乱的。使用submit提交任务时任务被包装成FutureTask异常不会立刻抛出而是被捕获后存到FutureTask内部等到你调用future.get()时才重新抛出包装成ExecutionException。这意味着如果你提交任务后没有调用get()异常会被无限期搁置直到Future对象被回收异常信息直接消失。这个差异在排查问题时非常坑。我遇到过的最典型情况是一段线上代码将所有任务用submit提交但业务上并不关心返回值就直接忽略了Future。后来系统出现数据不一致查了很久才发现异步任务里有一个 NPE因为从来没人调get()异常日志一个都没打出来。5.2 正确捕获异常的三种方式我总结了三种处理线程池异常的方式按推荐程度从高到低排列。第一种也是最推荐的方式任务内部自己处理在 Runnable 的run()方法里对整个业务逻辑做try-catch捕获后记录日志并触发监控。这种方式把异常处理逻辑和业务绑在一起代码意图最清晰问题定位最直接。第二种使用自定义ThreadFactory设置UncaughtExceptionHandler。它适合处理那些无法在业务代码中显式捕获的异常例如 JVM 层面的OutOfMemoryError。不过要注意submit提交的任务异常不会走到这个处理器它只能兜住execute路径的异常。第三种确保提交任务并获取Future后显式调用future.get()并捕获异常。这样做虽然需要写更多代码但能拿到完整的异常栈且在需要等待任务完成时自然获得。三个方式不是互斥的生产环境里我通常都会做任务内捕获业务异常做记录ThreadFactory里设置兜底处理器Future路径手动获取结果并捕获异常。三层防护下来再也没出现过“异常消失”的问题。5.3 ThreadLocal 在线程池中的污染问题线程池里的线程是复用的这给ThreadLocal带来了一个很隐蔽的坑。线程执行完任务后ThreadLocal中的值并不会自动清除。如果这个线程下一个任务恰好复用了同一个ThreadLocal但业务上没有重新设置就会读到上一个任务残留的旧值。最常见的案例是使用ThreadLocal存储用户登录信息或请求链路 ID。在一个线程池中任务 A 设置了用户 ID 为 100执行完后没清理任务 B 复用了这个线程但没有设置用户 ID那么它在ThreadLocal里读到的就是 100。如果任务 B 恰好用这个用户 ID 做了数据查询或者写操作轻则数据错乱重则产生越权。解决办法很简单在任务最外层finally中显式调用ThreadLocal.remove()。如果你使用框架的异步包装功能也要确认框架有没有帮你清理。没有清理机制的话自己动手是最稳妥的。再补充一个相关的坑父子线程传递上下文。比如主线程创建了一个子线程池去处理任务但子线程池里的线程和主线程不在同一个线程实例上ThreadLocal默认无法跨线程传递。这会导致子线程拿不到主线程的用户信息、traceId 等上下文。解决办法是用TransmittableThreadLocal阿里开源或者手动把上下文参数传过去。这类问题在微服务链路追踪里尤其常见。6. 生产环境动态线程池与监控方案线程池参数配完不是一劳永逸的。业务增长、流量变化、依赖服务的性能波动都会让原有的参数变得不合理。如果不做动态调整和监控线程池迟早会在某个流量高峰变成性能瓶颈。6.1 为什么 JDK 原生线程池缺少动态调优能力ThreadPoolExecutor支持通过setCorePoolSize()、setMaximumPoolSize()动态调整参数但调整的时机和判断依据需要我们自己掌握。JDK 原生提供的getQueue().size()等方法只能让我们看到快照数据无法持续追踪趋势也没有告警能力。更重要的是原生线程池没有“队列长度自动扩容”的能力ArrayBlockingQueue的容量在构造时就固定了无法在运行时修改。如果你初期把队列设为 100后来业务量翻倍这个 100 就成了硬瓶颈只能靠拒绝策略兜底但这种方式很容易丢任务。所以生产环境里我更建议大家具备两种能力一是基于监控指标实时调整线程池参数二是必要时可以替换队列为动态容量的实现。6.2 用配置中心实现线程池参数动态调整动态线程池的核心思路是把核心线程数、最大线程数、队列容量等参数放到配置中心通过监听配置变更来在线修改线程池。Component public class DynamicThreadPool { private final ThreadPoolExecutor executor; public DynamicThreadPool() { this.executor new ThreadPoolExecutor(8, 16, 30L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new NamedThreadFactory(biz-async), new ThreadPoolExecutor.CallerRunsPolicy()); // 监听配置中心变更例如 Apollo / Nacos ConfigService configService ConfigServiceFactory.get(); configService.addListener(thread-pool-config, changeEvent - { int core Integer.parseInt(changeEvent.getNewValue(core-size)); int max Integer.parseInt(changeEvent.getNewValue(max-size)); executor.setCorePoolSize(core); executor.setMaximumPoolSize(max); }); } }这里有一个容易踩的坑直接调用setMaximumPoolSize()时如果新值小于当前活跃线程数只会将限制调低不会立即终止多余线程。这些多余线程要等到任务执行完毕后才会在空闲超时后被回收。也就是说动态缩容不会立刻生效需要等待一段时间。要想立刻看到效果可以结合executor.prestartCoreThread()或者配合手动回收策略不过大多数业务场景下等待空闲回收是可以接受的。另外动态调整队列容量时要更加小心。如果新的容量比当前积压的任务数还小多出来的任务不会自动被拒绝或丢弃它们仍然留在队列里只是新任务直到队列清出位置之前无法继续进入。这种存量已入队、增量被阻塞的状态很容易造成“线程池假死”的假象排查的时候需要先看队列积压数和活跃线程数不要盲目增大队列长度。6.3 线程池监控的指标定义与告警阈值监控线程池并不需要多么复杂的框架。核心思路是周期性采集线程池的关键指标并计算变化趋势。我建议关注以下指标指标名称获取方式含义建议告警阈值活跃线程数getActiveCount()正在执行任务的线程数持续高于最大线程数的 80% 持续 5 分钟队列积压数getQueue().size()队列中等待执行的任务数超过队列容量的 60% 持续 3 分钟任务完成数/拒绝数getCompletedTaskCount()/ 自定义计数器判断线程池整体压力和拒绝频率拒绝数 1 分钟内大于 0 即告警线程池最大线程数getLargestPoolSize()池中同时存在的最大线程数接近最大线程数上限时提醒有了这些指标之后你可以在监控大盘上画出线程池的“水位线”。观察一段时间后就能总结出业务的规律比如每天哪个时段线程池水位最高、哪个时段队列积压最严重。再结合业务活动日历基本可以预判哪些活动会触发线程池压力提前调整参数比事后扩容靠谱得多。还有一个不算技巧的技巧在线程池任务执行的关键路径上埋点和打日志。例如任务开始和结束时记录任务 ID、线程名、耗时、队列等待时间。这些日志在排查“任务为什么慢”时价值极大。如果没有这类日志线程池数据只能说明“有多少任务在排队”定位不到具体的慢任务。6.4 线程池关闭的正确姿势线程池的关闭也是一个高频踩坑点。很多程序员直接调用shutdown()以为调完就万事大吉了有的则用shutdownNow()结果正在执行的任务被打断业务数据只处理了一半。正确姿势是先调用shutdown()停止接收新任务然后调用awaitTermination()等待已提交任务执行完毕如果等待超时再调用shutdownNow()强制中断最后检查返回的未完成任务列表决定是否需要人工处理。executor.shutdown(); if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { ListRunnable unfinished executor.shutdownNow(); log.warn(thread pool forced shutdown, unfinished tasks: {}, unfinished.size()); }这里尤其要注意的是awaitTermination的返回值。如果它返回false说明在指定时间内任务没有全部执行完这时候强制中断可能会导致数据不一致。合理的设计是在应用停机前预留足够的关闭时间并且把线程池的关闭逻辑放到Spring的优雅停机钩子里避免进程突然退出导致任务丢失。7. 面试官最爱问的线程池问题从八股到实战这个系列写到这里前两篇把线程池的基础用法和生命周期梳理了一遍。作为一个经常面试别人、也被别人面试过的 Java 开发者我很清楚面试官问线程池时到底在考什么——不是你能不能背出ThreadPoolExecutor的七个参数而是你有没有真正用线程池解决过实际问题有没有踩过坑有没有自己的思考。7.1 为什么不允许用 Executors 创建线程池这是阿里的 Java 开发手册里明确规定的也是面试高频题。原因有两个Executors.newFixedThreadPool和newSingleThreadExecutor使用了无界的LinkedBlockingQueue任务堆积过多时会导致 OOMExecutors.newCachedThreadPool的最大线程数是Integer.MAX_VALUE如果任务执行时间过长线程数会飙升到系统无法承受的程度。面试时如果能再补一句“手动创建线程池可以显式控制队列长度和拒绝策略让系统在过载时具备降级能力”基本就把这个问题的分数拿满了。如果还能顺带提到自己如何用配置中心实现动态线程池那就更有区分度了。7.2 核心线程数设置为多少最合适这个问题没有标准答案。面试官想听的不是某个具体数字而是你的推导思路。你可以这样回答先判断任务是 CPU 密集型还是 IO 密集型再给出计算思路N1 或 2N再强调生产环境要结合压测结果微调。如果还能提到容器环境下读取 CPU 核数可能不准、需要显式指定ActiveProcessorCount那基本就是加分项了。7.3 队列满了之后线程池的执行顺序是什么这个问题的关键在于描述执行顺序而非最终结果。线程池在任务提交后依次判断核心线程是否已满、队列是否已满、最大线程数是否已满最后才触发拒绝策略。不要把这个顺序说错。很多人只回答“满了就拒绝”完全没提到队列先于最大线程数扩容的机制这在面试官眼里就是基础不扎实的信号。7.4 如何拿到线程池中任务执行的异常这个问题我会先分情况回答execute方式提交的任务异常会抛出到线程本级未捕获时会打印日志submit方式提交的任务异常被包装进Future只有调用get()才会抛出。然后我要补充“异常可能被吞掉”的坑以及自己的三种兜底方案。回答到这里面试官基本就能判断出你是真正写过线程池代码的。7.5 计数器类问题如何让多个线程池任务都执行完再继续这个需求在日常开发中经常遇到。主流做法有三种用CountDownLatch等待所有任务完成用Future列表逐个get()用ExecutorCompletionService按完成顺序处理结果。三选一即可。需要注意的是无论选哪种都要防止任务中异常导致countDown()或get()被中断否则主线程可能会永久阻塞。面试问到这儿我一般会加一句“可以用CompletionService做先完成先消费的模型避免某些任务长时间阻塞导致后续任务无法处理”这句话一提面试官就知道你用框架思考过问题而不只是背 API。8. 附常见问题与排查技巧速查表现象可能原因排查技巧任务丢失没有异常日志submit后未调用get()异常被吞加兜底日志ThreadFactory设置UncaughtExceptionHandler线程池线程数不停上涨SynchronousQueue配合过大最大线程数检查getPoolSize()和getActiveCount()限流或换有界队列任务延迟很高但 CPU 空闲核心线程数过少任务大量排队查看getQueue().size()调大核心线程数应用启动时 OOM线程池队列无界且任务积压过多显式设置LinkedBlockingQueue容量或换有界队列使用CallerRunsPolicy后响应变慢提交线程被任务拖住结合上层限流或接入消息队列削峰相同业务数据被并发写坏线程池 ThreadLocal 残留数据任务结束finally中执行remove()排队任务执行时间远超预期队列 核心线程数配置不合理给任务打时间戳日志分析排队耗时排查线程池问题时我通常先用jstack打印线程栈看线程名和线程状态。如果是 WAITING 状态的线程过多说明线程都在等待队列中的任务可能核心线程数偏少如果是 RUNNABLE 但 CPU 占用高需要看具体业务逻辑是否过于消耗计算资源。然后再结合线程池的监控指标定位问题。这一套流程走完大部分线程池问题都能找到根因。最后说点个人体会。线程池本身并不复杂核心就是一套线程复用和任务排队机制但真正把它用好需要你对自己的业务有足够清晰的认识任务是什么类型、耗时要多久、峰值流量有多大、可以接受的排队时间是多少。把这些数据摸清楚了再去配参数你会发现线程池没那么玄。而面试里那些八股题本质上也是考察你有没有形成这种“从业务出发思考资源分配”的思维方式。
返回列表