一、写在前面:为什么线程池是面试的必争之地
在 Java 后端开发的面试中,线程池几乎是每一场面试都绕不开的核心考点。无论是初级开发、高级开发还是架构师岗位,面试官都喜欢从线程池切入,层层追问到并发编程、JUC 源码、JVM 内存模型,甚至操作系统层面的线程调度。原因很简单:线程池是 Java 并发编程中最常用的基础设施之一,几乎每一个生产级项目都会用到它,但真正能把线程池用对、用透的人却并不多。
很多候选人背熟了线程池的七个参数,能说出「核心线程数、最大线程数、存活时间、时间单位、任务队列、线程工厂、拒绝策略」,也能背出线程池的工作流程「先创建核心线程,队列满了再创建非核心线程,最后走拒绝策略」。但当面试官把场景抛出来,问一句「如果核心线程数是 5,最大线程数是 10,队列容量是 100,此时突然来了 200 个任务,线程池会怎么处理?」很多人就开始支支吾吾。如果再追问一句「你项目里的线程池参数是怎么定的?」「为什么用这个队列?」「拒绝策略为什么选它?」「任务执行时抛出异常会发生什么?」「ThreadLocal 和线程池一起用有什么坑?」就更难招架了。
这篇文章的定位,不是让你把线程池的 API 再背一遍,而是带你站在「实战踩坑」和「面试追问」的双重视角下,把线程池最容易被忽视、最容易用错、最容易被面试官挖坑的 10 个关键点讲深讲透。每一个坑都会包括:错误示范、原理剖析、正确写法、源码佐证、面试追问。建议你把这篇文章当作一份线程池的「避坑手册」和「面试题库」,读完后能做到:知其然,更知其所以然;既能写出生产可用的线程池代码,也能从容应对面试官的连环追问。
在正式开始之前,我们先快速回顾一下线程池的核心参数和工作流程,作为后续所有分析的地基。如果你对这些基础知识已经非常熟悉,可以直接跳到第二章开始看具体的坑。
1.1 线程池七大参数回顾
Java 中线程池的核心实现类是java.util.concurrent.ThreadPoolExecutor,它的完整构造函数接收七个参数:
java
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> 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 拒绝策略来处理这个新提交的任务。
这段流程对应的核心源码如下:
java
public 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 是 10,maximumPoolSize 是 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的源码如下:
java
public static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>()); }它的 corePoolSize 和 maximumPoolSize 都是 nThreads,这意味着它只会创建固定数量的线程。问题出在任务队列上:它使用的是无参构造的 LinkedBlockingQueue,其默认容量是Integer.MAX_VALUE,相当于一个无界队列。
这意味着:当任务提交速度大于线程处理速度时,任务会无限堆积在队列中。由于队列是无限大的,线程池永远不会走到「创建非核心线程」和「拒绝策略」这两步,所有任务都堆在内存里。在流量高峰或任务处理变慢时,堆积的任务会持续占用 JVM 堆内存,最终触发OutOfMemoryError,导致整个服务崩溃。
3.2 newCachedThreadPool:线程数无上限的定时炸弹
newCachedThreadPool的源码如下:
java
public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }这个线程池有两个极端特点,分别对应两类非常典型的生产事故:
corePoolSize 为 0,maximumPoolSize 却高达 Integer.MAX_VALUE:理论上线程数量没有上限。只要任务提交速度足够快,线程池就会不断创建新线程。
使用 SynchronousQueue:这是一个不存储元素的阻塞队列,每个插入操作必须等待另一个线程的移除操作,反之亦然。
正是这两个特点叠加,让 newCachedThreadPool 变成一颗"定时炸弹"。当任务到来时,线程池先尝试把任务交给 SynchronousQueue,但 SynchronousQueue 根本不存储元素,如果当前没有空闲线程可以立即接手,线程池就会创建一个新线程来执行任务。当并发请求量突然增大时,大量任务同时到达,线程池会在极短时间内创建大量线程。每个线程都需要占用约 1MB 左右的栈内存(不同 JVM 版本和参数略有差异),大量线程不仅会导致内存迅速耗尽,还会带来严重的上下文切换开销。轻则响应时间急剧升高,重则直接抛出 OutOfMemoryError。
3.3 newSingleThreadExecutor 和 newScheduledThreadPool 同样不省心
除了上面两个最常见的工厂方法,Executors 还提供了其他几个便捷方法,它们的隐患同样不能忽视。
newSingleThreadExecutor 的源码如下:
java
public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }它固定只使用 1 个线程来顺序执行任务,这个"单线程顺序执行"的语义本身没有问题,非常适合需要串行化处理的场景。但问题依然出在队列上:它使用的还是无参构造的 LinkedBlockingQueue,队列容量是 Integer.MAX_VALUE。一旦任务提交速度快于这 1 个线程的处理速度,任务同样会无限堆积,最终 OOM。更隐蔽的是,很多人以为单线程池并发低、不会出大问题,其实恰恰是这种"看似无害"的线程池最容易在流量高峰悄悄把内存吃满。
newScheduledThreadPool 的源码如下:
java
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); }继续往下追 ScheduledThreadPoolExecutor 的构造方法:
java
public 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。maximumPoolSize=100 永远用不到,因为队列永远不会拒绝任务,创建非核心线程的条件永远不会成立。这会让系统的并发处理能力远低于预期,同时内存占用持续攀升。
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 是一种很经典的"背压"策略:线程池忙不过来时,让提交任务的调用线程去执行,从而降低任务提交速度,避免任务被丢弃。它常用在业务允许调用线程"帮忙干活"的场景。
java
ThreadPoolExecutor 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 自定义拒绝策略:把拒绝变成可观测事件
在生产环境中,推荐自定义拒绝策略,在被拒绝时至少做好三件事:记录日志、发出告警、执行业务降级。示例:
java
public 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 提交,异常会被未捕获异常处理器感知:
java
executor.execute(() -> { int result = 1 / 0; });方案二:如果必须用 submit,要么调用 get(),要么在任务内部自己 try-catch:
java
// 主动 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 把任务包装成 FutureTask,FutureTask.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 暴露,各业务类通过注入使用:
java
@Configuration 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 默认线程名的困扰
在生产环境排查问题时,经常需要看线程 dump(jstack)。如果所有线程都叫pool-N-thread-M,你根本分不清哪个线程属于哪个业务,哪个线程池的线程在干什么。
10.2 自定义线程工厂
java
ThreadFactory factory = new ThreadFactoryBuilder() .setNameFormat("order-pool-%d") .setDaemon(false) .setUncaughtExceptionHandler((t, e) -> log.error("线程 {} 发生未捕获异常", t.getName(), e)) .build();或者手写一个:
java
public 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 典型坑场景
java
private static final ThreadLocal<UserContext> CURRENT_USER = new ThreadLocal<>(); public void handleRequest(UserContext user) { CURRENT_USER.set(user); // 设置当前用户 doSomeWork(); // 业务处理 // 忘记 remove! }在普通的非线程池场景下,线程销毁后 ThreadLocal 的值也随之释放。但在线程池场景下,线程会被复用,下一次请求进来如果读取 CURRENT_USER,可能读到上一次请求残留的用户信息,造成越权或数据泄露。
11.2 正确写法
必须在 finally 中调用 remove:
java
public void handleRequest(UserContext user) { try { CURRENT_USER.set(user); doSomeWork(); } finally { CURRENT_USER.remove(); // 关键:必须在 finally 中清理 } }推荐把 ThreadLocal 的 get/set/remove 封装成一个工具类,避免业务代码忘记清理:
java
public class UserContextHolder { private static final ThreadLocal<UserContext> 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 密集用核数 + 1,IO 密集用核数 × 2 起步,并结合压测调整。
禁止使用 Executors 创建线程池,显式 new ThreadPoolExecutor。
队列必须是有界的,容量根据任务耗时、峰值 TPS、可接受延迟来估算。
拒绝策略必须显式选择,推荐自定义拒绝策略并配套日志、告警、降级。
CPU 密集和 IO 密集任务拆分线程池,避免相互干扰。
不要混用 submit 和 execute,两种方式的异常处理行为完全不同。
任务内部必须 try-catch,防止异常导致线程终止或异常被吞。
线程池要统一管理,通过 Spring Bean 集中配置,禁止散落各处。
必须自定义线程工厂,线程名包含业务信息和序号,方便线上排查。
ThreadLocal 用完必须 remove,避免数据串号和内存泄漏。