细数线程池的10个坑,面试线程不怕不怕啦
一、写在前面为什么线程池是面试的必争之地在 Java 后端开发的面试中线程池几乎是每一场面试都绕不开的核心考点。无论是初级开发、高级开发还是架构师岗位面试官都喜欢从线程池切入层层追问到并发编程、JUC 源码、JVM 内存模型甚至操作系统层面的线程调度。原因很简单线程池是 Java 并发编程中最常用的基础设施之一几乎每一个生产级项目都会用到它但真正能把线程池用对、用透的人却并不多。很多候选人背熟了线程池的七个参数能说出「核心线程数、最大线程数、存活时间、时间单位、任务队列、线程工厂、拒绝策略」也能背出线程池的工作流程「先创建核心线程队列满了再创建非核心线程最后走拒绝策略」。但当面试官把场景抛出来问一句「如果核心线程数是 5最大线程数是 10队列容量是 100此时突然来了 200 个任务线程池会怎么处理」很多人就开始支支吾吾。如果再追问一句「你项目里的线程池参数是怎么定的」「为什么用这个队列」「拒绝策略为什么选它」「任务执行时抛出异常会发生什么」「ThreadLocal 和线程池一起用有什么坑」就更难招架了。这篇文章的定位不是让你把线程池的 API 再背一遍而是带你站在「实战踩坑」和「面试追问」的双重视角下把线程池最容易被忽视、最容易用错、最容易被面试官挖坑的 10 个关键点讲深讲透。每一个坑都会包括错误示范、原理剖析、正确写法、源码佐证、面试追问。建议你把这篇文章当作一份线程池的「避坑手册」和「面试题库」读完后能做到知其然更知其所以然既能写出生产可用的线程池代码也能从容应对面试官的连环追问。在正式开始之前我们先快速回顾一下线程池的核心参数和工作流程作为后续所有分析的地基。如果你对这些基础知识已经非常熟悉可以直接跳到第二章开始看具体的坑。1.1 线程池七大参数回顾Java 中线程池的核心实现类是java.util.concurrent.ThreadPoolExecutor它的完整构造函数接收七个参数javapublic ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)这七个参数的含义分别是corePoolSize核心线程数线程池中长期存活的线程数量。即使这些线程处于空闲状态也不会被回收除非设置了allowCoreThreadTimeOut(true)。maximumPoolSize最大线程数线程池中允许存在的最大线程数量。当任务队列已满且核心线程都在忙碌时线程池会创建额外的线程即非核心线程直到达到 maximumPoolSize。keepAliveTime空闲存活时间非核心线程在空闲状态下超过这个时间后会被回收销毁。如果设置了allowCoreThreadTimeOut(true)核心线程也会受此时间约束。unit时间单位keepAliveTime 的时间单位如TimeUnit.SECONDS、TimeUnit.MILLISECONDS等。workQueue任务队列用于存放待执行任务的阻塞队列。常用实现有ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue等。threadFactory线程工厂用于创建线程。通过自定义工厂可以设置线程名称、是否守护线程、优先级、未捕获异常处理器等。handler拒绝策略当线程池无法接受新任务时线程数达到 maximumPoolSize 且队列已满对新提交任务采取的处理策略。JDK 内置四种AbortPolicy、CallerRunsPolicy、DiscardPolicy、DiscardOldestPolicy。1.2 线程池任务执行流程当调用execute(Runnable command)提交一个任务时线程池会按照下面的顺序处理如果当前运行的线程数小于 corePoolSize则直接创建新的核心线程来执行任务即使其他核心线程处于空闲状态也会优先创建新线程。如果当前运行的线程数已达到 corePoolSize则将任务放入 workQueue 排队等待执行。如果 workQueue 已满无法再放入任务且当前运行的线程数小于 maximumPoolSize则创建新的非核心线程来执行任务。如果 workQueue 已满且当前运行的线程数已达到 maximumPoolSize则触发 handler 拒绝策略来处理这个新提交的任务。这段流程对应的核心源码如下javapublic void execute(Runnable command) { if (command null) throw new NullPointerException(); int c ctl.get(); // 1. 当前线程数小于核心线程数直接创建核心线程 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)) reject(command); }请务必把这个流程刻在脑子里因为后面 10 个坑中有至少一半都源于对这个流程的理解偏差。1.3 本文的阅读约定本文所有代码示例默认基于 JDK 8涉及到的源码均为java.util.concurrent包下的ThreadPoolExecutor、BlockingQueue等类的核心逻辑。为了让代码示例更贴近生产实践我会尽量给出完整可运行的代码片段并标注出其中的「坑点」。文中所有结论都尽可能附上源码依据方便你在面试时由表及里地展开论述。好的地基已经打好下面正式进入本文的核心内容——线程池的 10 个坑。二、坑一线程池参数配置不合理凭感觉拍脑袋第一个坑也是最基础的坑就是线程池参数配置不合理。很多开发者在创建线程池时对 corePoolSize、maximumPoolSize、队列容量这三个参数完全凭感觉或者直接照搬别人代码里的数字比如「核心 10、最大 20、队列 100」却不知道为什么是这个数。这种做法在测试环境可能看不出问题一旦上线遇到流量洪峰轻则响应变慢重则直接 OOM 或服务雪崩。2.1 典型的错误配置下面这段代码是很多项目的真实写照java// 错误示范参数完全凭感觉配置 ThreadPoolExecutor executor new ThreadPoolExecutor( 8, // corePoolSize不知道为什么要 8 50, // maximumPoolSize和 core 差这么多队列多大 60, // keepAliveTime TimeUnit.SECONDS, new LinkedBlockingQueue(), // 无界队列 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() );这段代码至少有三个配置上的问题corePoolSize 和 maximumPoolSize 差距过大8 到 50这种配置在正常情况下问题不大但如果任务具有突发性线程数量会在短时间内从 8 猛增到 50。线程的创建和销毁都是有成本的频繁地创建、销毁线程会导致系统性能抖动甚至引发上下文切换风暴。keepAliveTime 设置过长60 秒非核心线程在空闲 60 秒后才会被回收。如果业务高峰期已过这 42 个非核心线程会白白占用内存和系统资源长达 1 分钟对资源敏感的场景来说是不必要的浪费。使用了无界队列LinkedBlockingQueue 默认容量 Integer.MAX_VALUE这是最致命的问题具体原因会在「坑三」中单独展开。简单说就是无界队列会让 maximumPoolSize 形同虚设并且在极端情况下导致内存耗尽。2.2 参数之间的联动关系你真的理解了吗线程池的 corePoolSize、maximumPoolSize 和队列容量并不是三个孤立的数字它们共同决定了一组任务到来时线程池的「承载力曲线」。理解这个联动关系是配置好参数的前提。回顾 1.2 节的执行流程我们可以得出一个重要的结论当任务量小于 corePoolSize 时所有任务都由核心线程直接执行不经过队列严格来说第 1 个任务之后、核心线程数未满时新任务会直接建线程执行也可能走 addWorker 先建线程只有核心线程数已满后续任务才会入队。当任务量在 corePoolSize 和 corePoolSize 队列容量之间时核心线程数保持不变多余任务进入队列等待。当任务量超过 corePoolSize 队列容量但小于 maximumPoolSize 队列容量时线程池开始创建非核心线程来「消化」队列外的任务。当任务量超过 maximumPoolSize 队列容量时触发拒绝策略。换句话说线程池在「队列满了」之前一直只有 corePoolSize 个线程在工作而 maximumPoolSize 只有在「队列也满了」的时候才发挥作用。很多开发者误以为把 maximumPoolSize 调到 200系统就能并发处理 200 个任务但实际上如果队列容量设置得足够大可能永远用不到这 200 个线程因为任务早就先在队列里排起了长队。这个误区在面试中经常被追问比如面试官你说你 corePoolSize 是 10maximumPoolSize 是 50队列容量是 1000。那现在来了 500 个任务线程池最多会创建多少个线程候选人错误50 个。面试官正确引导为什么不是 10 个正确答案因为 500 个任务中前 10 个由核心线程执行剩下 490 个先进入容量为 1000 的队列队列还没满所以不会创建非核心线程线程数始终是 10。如果候选人能独立讲清楚这个逻辑说明他对线程池的理解已经超过了大多数「背参数」的竞争者。2.3 正确的配置思路参数配置不应该拍脑袋而应该从业务模型和资源约束出发。下面给出一个相对完整的配置思路明确任务类型任务到底是 CPU 密集型、IO 密集型还是混合型这决定了核心线程数的量级。具体计算公式会在「坑五」中展开。明确任务特性任务的平均执行时长是多少有没有突发流量极端情况下最多会同时提交多少任务有没有背压机制评估队列容量队列容量 可接受的最大等待任务数。设置的原则是既不能太小太小会过早触发拒绝或创建过多线程也不能太大太大会导致任务长时间排队、响应变慢、内存占用过大。留出余量maximumPoolSize 应该是 corePoolSize 的 1.5 到 2 倍左右而不是 5 倍、10 倍。过大的差距意味着系统可能在高峰期创建大量线程加剧资源竞争。结合监控调优上线前通过压测观察线程池的活跃线程数、队列积压数、拒绝次数等指标基于真实数据调整参数而不是拍脑袋定一个数就再也不管。一个相对合理的配置示例如下java// 正确示范基于业务模型配置参数 int cpuCount Runtime.getRuntime().availableProcessors(); ThreadPoolExecutor executor new ThreadPoolExecutor( cpuCount * 2, // IO 密集型任务核心线程数约为 CPU 核数的 2 倍 cpuCount * 2 4, // 最大线程数稍大于核心线程数留出弹性空间 30, // 非核心线程空闲 30 秒后回收 TimeUnit.SECONDS, new ArrayBlockingQueue(200), // 有界队列容量 200防止任务无限堆积 new ThreadFactoryBuilder().setNameFormat(order-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时由调用线程执行起到背压作用 );注意上面代码中ThreadFactoryBuilder来自 Guava生产中也常用它来定制线程名这个点会在「坑九」中详细说明。2.4 面试追问你的项目里线程池参数是怎么定的依据是什么——考察候选人是否真的思考过而不是背模板。corePoolSize 和 maximumPoolSize 为什么不设成一样的——考察对非核心线程创建条件的理解。什么情况下核心线程会被回收——考察allowCoreThreadTimeOut的知识点。线程池每次创建线程都会立即执行任务吗——考察addWorker源码中firstTask参数的作用。三、坑二贪图方便使用 Executors 快捷方法创建线程池第二个坑是使用Executors工具类提供的便捷工厂方法来创建线程池。在早期的很多教程和网上的示例代码中我们经常能看到这样的写法java// 错误示范使用 Executors 工厂方法 ExecutorService fixedPool Executors.newFixedThreadPool(10); ExecutorService cachedPool Executors.newCachedThreadPool(); ExecutorService singlePool Executors.newSingleThreadExecutor(); ExecutorService scheduledPool Executors.newScheduledThreadPool(10);这种写法确实简洁但简洁的背后隐藏着巨大的生产事故隐患。阿里巴巴《Java 开发手册》中明确强制规定【强制】线程池不允许使用 Executors 去创建而是通过 ThreadPoolExecutor 的方式这样的处理方式让写的同学更加明确线程池的运行规则规避资源耗尽的风险。下面逐个拆解这些工厂方法的隐患。3.1 newFixedThreadPool无界队列带来的 OOM 风险newFixedThreadPool的源码如下javapublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable()); }它的 corePoolSize 和 maximumPoolSize 都是 nThreads这意味着它只会创建固定数量的线程。问题出在任务队列上它使用的是无参构造的 LinkedBlockingQueue其默认容量是Integer.MAX_VALUE相当于一个无界队列。这意味着当任务提交速度大于线程处理速度时任务会无限堆积在队列中。由于队列是无限大的线程池永远不会走到「创建非核心线程」和「拒绝策略」这两步所有任务都堆在内存里。在流量高峰或任务处理变慢时堆积的任务会持续占用 JVM 堆内存最终触发OutOfMemoryError导致整个服务崩溃。3.2 newCachedThreadPool线程数无上限的定时炸弹newCachedThreadPool的源码如下javapublic static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueueRunnable()); }这个线程池有两个极端特点分别对应两类非常典型的生产事故corePoolSize 为 0maximumPoolSize 却高达 Integer.MAX_VALUE理论上线程数量没有上限。只要任务提交速度足够快线程池就会不断创建新线程。使用 SynchronousQueue这是一个不存储元素的阻塞队列每个插入操作必须等待另一个线程的移除操作反之亦然。正是这两个特点叠加让 newCachedThreadPool 变成一颗定时炸弹。当任务到来时线程池先尝试把任务交给 SynchronousQueue但 SynchronousQueue 根本不存储元素如果当前没有空闲线程可以立即接手线程池就会创建一个新线程来执行任务。当并发请求量突然增大时大量任务同时到达线程池会在极短时间内创建大量线程。每个线程都需要占用约 1MB 左右的栈内存不同 JVM 版本和参数略有差异大量线程不仅会导致内存迅速耗尽还会带来严重的上下文切换开销。轻则响应时间急剧升高重则直接抛出 OutOfMemoryError。3.3 newSingleThreadExecutor 和 newScheduledThreadPool 同样不省心除了上面两个最常见的工厂方法Executors 还提供了其他几个便捷方法它们的隐患同样不能忽视。newSingleThreadExecutor 的源码如下javapublic static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable())); }它固定只使用 1 个线程来顺序执行任务这个单线程顺序执行的语义本身没有问题非常适合需要串行化处理的场景。但问题依然出在队列上它使用的还是无参构造的 LinkedBlockingQueue队列容量是 Integer.MAX_VALUE。一旦任务提交速度快于这 1 个线程的处理速度任务同样会无限堆积最终 OOM。更隐蔽的是很多人以为单线程池并发低、不会出大问题其实恰恰是这种看似无害的线程池最容易在流量高峰悄悄把内存吃满。newScheduledThreadPool 的源码如下javapublic static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); }继续往下追 ScheduledThreadPoolExecutor 的构造方法javapublic ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue()); }可以看到ScheduledThreadPoolExecutor 的 maximumPoolSize 也是 Integer.MAX_VALUE。虽然它使用无界的 DelayedWorkQueue在多数情况下任务会先进入队列不会真的把线程数扩到最大值但最大线程数无上限这个配置本身就是隐患。如果定时任务本身执行报错或者调度逻辑设计不当线程池的行为也会变得难以预测。3.4 正确做法显式构造 ThreadPoolExecutor并不是说 Executors 工厂方法完全不能用而是在生产环境中我们更推荐显式地 new ThreadPoolExecutor把每一个参数都掌控在自己手里。这样做的好处非常明显队列必须是有界的例如使用 ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue从源头切断任务无限堆积的可能。线程数必须有上限根据业务类型和资源情况合理设置 corePoolSize 和 maximumPoolSize避免线程数失控。拒绝策略必须显式选择不要依赖默认的 AbortPolicy而是结合业务明确选择最合适的策略具体会在「坑四」中展开。线程工厂必须自定义给线程设置有意义的名字便于线上排查具体会在「坑九」中展开。一个改良后的 newSingleThreadExecutor 替代写法如下java// 正确示范显式构造使用有界队列 ThreadPoolExecutor singlePool new ThreadPoolExecutor( 1, 1, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue(100), new ThreadFactoryBuilder().setNameFormat(single-worker-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() );3.5 面试追问阿里巴巴规范为什么禁止使用 Executors 创建线程池——考察对无界队列、线程数无上限等隐患的理解。newFixedThreadPool 和 newCachedThreadPool 最大的区别在哪里——前者固定线程数 无界队列后者无限线程数 同步队列考察参数联动的理解。SynchronousQueue 的特点是什么——不存储元素插入和移除必须配对这也是 cached 线程池会疯狂扩线程的根因。如果我就是想用 Executors 的语义怎么规避风险——自己写 ThreadPoolExecutor保留语义但使用有界队列和有限最大线程数。四、坑三无界队列让 maximumPoolSize 形同虚设任务堆积到 OOM在「坑二」中我们反复提到了无界队列但它的危害远不止 Executors 工厂方法。很多开发者即使手动 new ThreadPoolExecutor也会习惯性地写一句new LinkedBlockingQueue()认为自己已经手动创建线程池了就安全了。实际上只要队列是无界的线程池的 maximumPoolSize 就几乎永远不会生效这个坑比很多人想象的更普遍。4.1 无界队列如何让 maximumPoolSize 失效回顾线程池的任务执行流程先创建核心线程核心线程满了就入队队列满了才会创建非核心线程直到 maximumPoolSize最后才走拒绝策略。现在把队列换成无界队列问题就很清晰了核心线程满了之后新任务全部进入队列因为队列永远不会满所以永远不会触发创建非核心线程maximumPoolSize 设得再大也只是一个摆设实际线程数最多到 corePoolSize队列中的任务越堆越多占用 JVM 堆内存最终 OOM。因此很多同学的配置是这样的java// 错误示范手动创建线程池但队列仍然无界 ThreadPoolExecutor executor new ThreadPoolExecutor( 10, 100, // 你以为最多能到 100 个线程 60, TimeUnit.SECONDS, new LinkedBlockingQueue(), // 实际上任务全堆在无界队列里 Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy() );在这个配置里无论任务量多大线程数最多只会到 10。maximumPoolSize100 永远用不到因为队列永远不会拒绝任务创建非核心线程的条件永远不会成立。这会让系统的并发处理能力远低于预期同时内存占用持续攀升。4.2 有界队列的容量到底怎么定有界队列虽然能防止无限堆积但容量也不是拍脑袋定一个数字就完事了。容量定得太小会过早触发拒绝策略或创建大量线程容量定得太大任务会在队列里排很长的队响应时间变长内存占用也变大。一个更工程化的思路是队列容量 可接受的最大排队任务数。可以结合下面几个因素来估算任务平均执行时长处理一个任务大概需要多少毫秒。可接受的最大等待时间业务上允许一个任务最多排队多久。峰值吞吐量高峰时每秒会提交多少个任务。简单估算公式可以写成队列容量 ≈ 每秒提交任务数 × 可接受的最大等待秒数例如接口调用的线程池希望在 1 秒内消化大部分请求高峰 TPS 约 200那么队列容量设为 200 左右就比较合理。当然这只是一个起点最终值还要通过压测和线上监控来校准。4.3 常见阻塞队列怎么选线程池中的 workQueue 使用阻塞队列JDK 为我们提供了多种实现选择时需要理解各自的特点ArrayBlockingQueue基于数组的有界阻塞队列需要显式指定容量内存占用可控是生产环境最常见的选择之一。LinkedBlockingQueue基于链表的阻塞队列如果不指定容量则默认 Integer.MAX_VALUE。使用时一定要显式传入容量否则等同于无界队列。SynchronousQueue不存储元素的同步队列每个插入操作必须等待一个移除操作。配合较大 maximumPoolSize 时能实现任务来一个就消费一个但线程数控制不当容易爆炸典型场景就是 newCachedThreadPool。PriorityBlockingQueue支持优先级的无界队列任务需要实现 Comparable 或传入 Comparator。适合有优先级调度需求的场景但由于无界依然要警惕堆积。DelayedWorkQueue用于定时任务线程池的内部队列基于堆实现支持延迟执行通常由 ScheduledThreadPoolExecutor 内部使用。大多数普通业务场景下优先选择显式指定容量的 ArrayBlockingQueue 或 LinkedBlockingQueue 即可。4.4 面试追问无界队列为什么会让 maximumPoolSize 失效——考察对执行流程和队列满才创建非核心线程的理解。队列容量定多大比较合适——考察是否结合任务耗时、峰值 TPS、可接受延迟等工程因素思考。ArrayBlockingQueue 和 LinkedBlockingQueue 怎么选——考察对两种队列底层结构、锁机制和内存特性的理解。SynchronousQueue 和容量为 1 的队列是一回事吗——不是SynchronousQueue 不存储元素容量为 0必须严格配对。五、坑四拒绝策略理解不透任务被静默丢弃当线程数达到 maximumPoolSize 且工作队列已满再有新任务提交时线程池就会触发拒绝策略。拒绝策略是线程池的最后一道防线选错了可能直接导致业务数据丢失而且线上难以察觉。5.1 JDK 内置的四种拒绝策略JDK 在 ThreadPoolExecutor 中提供了四种内置拒绝策略它们都实现了 RejectedExecutionHandler 接口AbortPolicy默认策略。拒绝任务时直接抛出 RejectedExecutionException由调用方感知并处理。CallerRunsPolicy拒绝任务时由提交任务的调用线程自己执行这个任务。它不会丢弃任务也不会真正拒绝只是把压力转移到调用方起到一定的背压作用。DiscardPolicy静默丢弃被拒绝的任务不抛异常也不做任何记录。DiscardOldestPolicy丢弃队列中等待最久的那个任务然后尝试重新提交当前任务。5.2 AbortPolicy默认不是免死金牌AbortPolicy 是默认策略它的优点是显式失败调用 submit 或 execute 时会立即抛出 RejectedExecutionException。但很多同学在调用 submit 时并没有处理这个异常一旦线程池饱和异常会直接抛到业务代码甚至穿过 Controller 变成 500 错误。更麻烦的是如果不了解 submit 和 execute 的差异可能连异常在哪里抛出都搞不清楚。java// 错误示范拒绝时异常直接抛到调用方未做任何处理 String result executor.submit(() - process(order)).get();如果线程池已经饱和上面的 submit 会抛 RejectedExecutionException。因此使用 AbortPolicy 时一定要在调用方显式捕获异常并做降级处理。5.3 CallerRunsPolicy背压思想但不是万能药CallerRunsPolicy 是一种很经典的背压策略线程池忙不过来时让提交任务的调用线程去执行从而降低任务提交速度避免任务被丢弃。它常用在业务允许调用线程帮忙干活的场景。javaThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadFactoryBuilder().setNameFormat(order-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 调用线程兜底执行 );但它也有明显的副作用调用线程被阻塞如果拒绝的是任务提交线程如 main 线程、Tomcat 工作线程它会被迫执行任务可能影响其他请求的响应。任务可能被重复执行某些框架里如果任务提交方在感知到执行后还会做额外处理可能出现时序上的重复处理问题。执行顺序可能错乱调用线程执行任务时任务不再按照队列顺序执行对顺序敏感的业务要谨慎。5.4 DiscardPolicy 和 DiscardOldestPolicy静默丢弃是大忌DiscardPolicy 会静默丢弃被拒绝的任务不抛异常、不记日志。如果业务对任务丢失非常敏感例如下单、支付、消息发送等场景使用这个策略会造成严重的数据一致性问题而且极难排查因为系统表面上一切正常。DiscardOldestPolicy 会丢弃队列中最老的、等待最久的任务然后重新提交当前任务。它虽然不会丢弃最新的任务但那批等待很久的老任务会被直接扔掉同样存在数据丢失风险。这类策略只适合允许丢失部分旧任务、且新任务优先级更高的特殊场景。5.5 自定义拒绝策略把拒绝变成可观测事件在生产环境中推荐自定义拒绝策略在被拒绝时至少做好三件事记录日志、发出告警、执行业务降级。示例javapublic class LoggingRejectedExecutionHandler implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { String poolName Thread.currentThread().getName(); System.err.printf([%s] 拒绝任务queueSize%d, activeCount%d%n, poolName, executor.getQueue().size(), executor.getActiveCount()); // 可按需接入监控告警或把任务写入 MQ、数据库等待重试 throw new RejectedExecutionException(线程池饱和任务被拒绝: r); } }通过自定义策略我们可以把拒绝这个异常事件显式化接入监控和告警避免任务被静默丢弃后无人知晓。5.6 面试追问四种拒绝策略分别是什么默认是哪个——考察基础记忆。CallerRunsPolicy 有什么优缺点——考察对背压思想和副作用的理解。什么时候可以用 DiscardPolicy——通常没有绝对安全的使用场景只有允许数据丢失、只关心尽力而为的业务才可能接受。如何设计一个生产级的拒绝策略——至少包含日志、告警、降级或重试。六、坑五CPU 密集型和 IO 密集型任务用同一套参数线程数的计算公式不是普适的CPU 密集型和 IO 密集型任务需要不同的配置策略。这是面试中最容易拉开差距的一个问题。6.1 CPU 密集型任务CPU 密集型任务的特点是线程大部分时间都在做计算很少阻塞。典型场景包括复杂算法、加解密、压缩解压、序列化反序列化等。对这类任务线程数应该设置为CPU 核数 1。原因是线程数超过核数没有意义多出来的线程只会增加上下文切换开销。加 1 是为了在某个线程偶尔因为缺页中断或调度暂停时仍能保持 CPU 满载。6.2 IO 密集型任务IO 密集型任务的特点是线程大部分时间在等待 IO网络、磁盘、数据库CPU 利用率低。典型场景包括 HTTP 调用、数据库查询、文件读写、消息收发等。对这类任务线程数可以设置得比 CPU 核数大得多因为线程在等待 IO 时让出了 CPU。常用的估算公式是线程数 CPU 核数 × (1 平均等待时间 / 平均计算时间)或者更简单的经验公式线程数 CPU 核数 × 2或者根据阻塞系数计算线程数 CPU 核数 / (1 - 阻塞系数)阻塞系数通常在 0.8 到 0.9 之间。6.3 混合型任务的拆分思路如果一个线程池同时处理 CPU 密集和 IO 密集任务更合理的做法是把它们拆成两个独立的线程池分别配置参数。这样做的好处是IO 密集任务阻塞时不会影响 CPU 密集任务执行两类任务的隔离性更好。6.4 参数不是算出来的是压测出来的无论用什么公式最终参数一定要结合压测和线上监控来调优。推荐的做法是按公式估算一个初始值在测试环境用目标 QPS 压测观察 CPU 使用率、队列积压、响应时间、拒绝次数根据指标逐步微调直到找到吞吐和延迟的平衡点上线后持续监控业务量变化时重新评估。6.5 面试追问CPU 密集型和 IO 密集型线程数怎么定——考察是否理解公式背后的原理。为什么 CPU 密集型推荐核数 1——考察对上下文切换开销的理解。如果线上 CPU 使用率不高但吞吐上不去可能是什么原因——考察是否有实战排查经验通常是 IO 等待或锁竞争导致的。七、坑六submit 和 execute 混用导致异常被吞ThreadPoolExecutor 提供了两个提交任务的方法execute(Runnable)和submit(Callable/Runnable)。它们看起来差不多但在异常处理上有本质区别混用很容易踩坑。7.1 核心差异execute直接执行任务任务中抛出的异常会被未捕获异常处理器UncaughtExceptionHandler捕获如果没有设置异常会被打印到控制台。submit把任务包装成 FutureTask 提交任务中抛出的异常被 FutureTask 捕获并封装在 Future 中。只有调用 Future.get() 时才会抛出异常如果不调用 get异常会被静默吞掉。7.2 典型坑场景java// 错误示范submit 抛异常没人知道 executor.submit(() - { int result 1 / 0; // 抛出 ArithmeticException System.out.println(result); });这段代码提交后任务会抛异常但异常被封装在 Future 里调用方并没有拿到这个 Future所以异常被静默吞掉日志中看不到任何错误。这在生产环境是灾难性的——任务执行失败了但没有任何痕迹。7.3 正确写法方案一使用 execute 提交异常会被未捕获异常处理器感知javaexecutor.execute(() - { int result 1 / 0; });方案二如果必须用 submit要么调用 get()要么在任务内部自己 try-catchjava// 主动 get 获取异常 Future? future executor.submit(() - { int result 1 / 0; }); try { future.get(); } catch (ExecutionException e) { log.error(任务执行失败, e.getCause()); }java// 任务内部自己处理异常 executor.submit(() - { try { int result 1 / 0; } catch (Exception e) { log.error(任务执行失败, e); } });7.4 面试追问submit 和 execute 有什么区别——考察对异常处理差异的理解。submit 提交的任务抛异常如果不调用 get异常会怎样——会被静默吞掉这是最危险的场景。生产环境推荐用哪个——如果不需要返回值推荐 execute 任务内部 try-catch或者 submit 任务内部 try-catch。八、坑七线程池中的任务抛出异常后线程会怎样这是一个经典的追问点考察对 ThreadPoolExecutor 源码的理解。8.1 任务异常对线程的影响对于 ThreadPoolExecutor 来说一个 Worker 线程执行任务时抛出未捕获异常这个线程会立即终止然后线程池会创建一个新线程来替代它。这一点很多人有误解以为线程会继续存活。实际上线程池是通过processWorkerExit来清理死亡的 Worker 并补充新线程的。8.2 为什么 execute 提交的任务抛异常会导致线程终止Worker 线程的 runWorker 方法会循环调用 getTask 获取任务如果任务抛出未捕获异常runWorker 的 try-catch 会捕获它并重新抛出导致 runWorker 退出Worker 线程随之终止。线程池在 processWorkerExit 中会检查线程池状态如果仍在运行且 Worker 数小于核心线程数会补充一个新 Worker。8.3 为什么 submit 提交的任务抛异常不会导致线程终止submit 把任务包装成 FutureTaskFutureTask.run 内部会捕获异常并保存到 outcome 字段而不是抛出。所以 Worker 线程不会感知到异常也就不会终止。这也是为什么 submit 的异常必须通过 Future.get 才能拿到。8.4 生产环境的启示如果使用 execute任务内部必须做好异常处理否则线程会频繁创建和销毁影响性能。如果使用 submit任务内部也要做好异常处理因为异常可能被静默吞掉导致问题难以排查。推荐在任务包装器里统一加上 try-catch把异常记录到日志并上报监控。8.5 面试追问线程池中的任务抛出未捕获异常线程会怎样——考察对 runWorker 和 processWorkerExit 的理解。execute 和 submit 在异常处理上有什么不同——考察对 FutureTask 包装机制的理解。如果任务频繁抛异常线程池会怎样——会频繁创建和销毁线程浪费资源。九、坑八线程池被藏起来出问题无法排查很多项目的线程池创建代码散落在各个业务类里没有统一管理导致出现问题很难定位。这是工程实践中最容易被忽视但影响最大的问题之一。9.1 常见反模式java// 反模式每个 Service 都自己 new 一个线程池 Service public class OrderService { private final ExecutorService executor Executors.newFixedThreadPool(10); } Service public class PaymentService { private final ExecutorService executor Executors.newFixedThreadPool(10); }这种做法的坏处是每个 Service 都有一份线程池线程数总量不可控。多个线程池之间的参数不一致难以统一治理。出问题时无法从监控大盘看到全局情况。无法统一调整参数改一处要改多处。9.2 正确做法统一管理推荐把线程池集中到一个配置类里通过 Bean 暴露各业务类通过注入使用javaConfiguration public class ThreadPoolConfig { Bean(orderExecutor) public ThreadPoolExecutor orderExecutor() { return new ThreadPoolExecutor( 8, 16, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(200), new ThreadFactoryBuilder().setNameFormat(order-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); } Bean(paymentExecutor) public ThreadPoolExecutor paymentExecutor() { return new ThreadPoolExecutor( 4, 8, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(100), new ThreadFactoryBuilder().setNameFormat(payment-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() ); } }9.3 配套监控和治理统一管理后还需要配套监控通过 Micrometer 或自定义指标采集每个线程池的 activeCount、queueSize、completedTaskCount、rejectedCount。把这些指标推到 Prometheus Grafana配置告警阈值。支持动态调整参数如通过 Nacos 配置中心热更新核心线程数和最大线程数。9.4 面试追问项目里线程池怎么管理的——考察工程实践能力回答应该体现统一管理、监控和动态调整。线程池如何做动态参数调整——ThreadPoolExecutor 提供了 setCorePoolSize、setMaximumPoolSize 等方法可以配合配置中心实现热更新。线程池监控的关键指标有哪些——activeCount、queueSize、completedTaskCount、rejectedCount、taskCount。十、坑九线程工厂没定制线上排查困难第三个工程实践坑是线程工厂的使用。很多人创建线程池时不传 threadFactory或者直接用Executors.defaultThreadFactory()结果是线程名字都是pool-1-thread-1这种毫无意义的名字。10.1 默认线程名的困扰在生产环境排查问题时经常需要看线程 dumpjstack。如果所有线程都叫pool-N-thread-M你根本分不清哪个线程属于哪个业务哪个线程池的线程在干什么。10.2 自定义线程工厂javaThreadFactory factory new ThreadFactoryBuilder() .setNameFormat(order-pool-%d) .setDaemon(false) .setUncaughtExceptionHandler((t, e) - log.error(线程 {} 发生未捕获异常, t.getName(), e)) .build();或者手写一个javapublic class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger counter new AtomicInteger(0); public NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix - counter.incrementAndGet()); t.setUncaughtExceptionHandler((thread, e) - log.error(线程 {} 发生未捕获异常, thread.getName(), e)); return t; } }10.3 面试追问为什么一定要自定义线程工厂——考察是否有线上排查经验。线程名的命名规范是什么——建议包含业务名、池名和序号例如order-pool-1。自定义线程工厂还能做哪些事——设置守护线程、优先级、异常处理器、线程组等。十一、坑十ThreadLocal 和线程池一起用导致数据串号这是最高频也最隐蔽的一个坑。ThreadLocal 的设计初衷是为每个线程维护一份独立副本但线程池的线程是复用的如果不清理ThreadLocal 中的值会被后续任务读到造成数据串号甚至内存泄漏。11.1 典型坑场景javaprivate static final ThreadLocalUserContext CURRENT_USER new ThreadLocal(); public void handleRequest(UserContext user) { CURRENT_USER.set(user); // 设置当前用户 doSomeWork(); // 业务处理 // 忘记 remove }在普通的非线程池场景下线程销毁后 ThreadLocal 的值也随之释放。但在线程池场景下线程会被复用下一次请求进来如果读取 CURRENT_USER可能读到上一次请求残留的用户信息造成越权或数据泄露。11.2 正确写法必须在 finally 中调用 removejavapublic void handleRequest(UserContext user) { try { CURRENT_USER.set(user); doSomeWork(); } finally { CURRENT_USER.remove(); // 关键必须在 finally 中清理 } }推荐把 ThreadLocal 的 get/set/remove 封装成一个工具类避免业务代码忘记清理javapublic class UserContextHolder { private static final ThreadLocalUserContext CONTEXT new ThreadLocal(); public static void set(UserContext user) { CONTEXT.set(user); } public static UserContext get() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }11.3 为什么 remove 能避免内存泄漏ThreadLocalMap 的 key 是弱引用、value 是强引用。线程池中的线程长期存活如果不清除value 会一直无法回收造成内存泄漏。调用 remove 会同时清理 key 和 value是根本的解决方案。11.4 面试追问ThreadLocal 和线程池一起用会有什么问题——考察对线程复用和数据串号的理解。为什么 ThreadLocal 会导致内存泄漏——考察弱引用 key 强引用 value 的设计。怎样保证 ThreadLocal 在异步任务中正确传递——可以使用 InheritableThreadLocal但线程池场景下不生效、TransmittableThreadLocal阿里 TTL或者显式传参。十二、总结线程池的十条军规回到本文的主题我们把线程池最容易踩的 10 个坑总结成十条军规参数配置要基于业务模型不要拍脑袋CPU 密集用核数 1IO 密集用核数 × 2 起步并结合压测调整。禁止使用 Executors 创建线程池显式 new ThreadPoolExecutor。队列必须是有界的容量根据任务耗时、峰值 TPS、可接受延迟来估算。拒绝策略必须显式选择推荐自定义拒绝策略并配套日志、告警、降级。CPU 密集和 IO 密集任务拆分线程池避免相互干扰。不要混用 submit 和 execute两种方式的异常处理行为完全不同。任务内部必须 try-catch防止异常导致线程终止或异常被吞。线程池要统一管理通过 Spring Bean 集中配置禁止散落各处。必须自定义线程工厂线程名包含业务信息和序号方便线上排查。ThreadLocal 用完必须 remove避免数据串号和内存泄漏。