☰
细数线程池的10个坑,面试线程不怕不怕啦
2026/10/8 8:26:33 网站建设 项目流程

一、写在前面:为什么线程池是面试的必争之地

在 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)提交一个任务时,线程池会按照下面的顺序处理:

  1. 如果当前运行的线程数小于 corePoolSize,则直接创建新的核心线程来执行任务(即使其他核心线程处于空闲状态,也会优先创建新线程)。

  2. 如果当前运行的线程数已达到 corePoolSize,则将任务放入 workQueue 排队等待执行。

  3. 如果 workQueue 已满(无法再放入任务),且当前运行的线程数小于 maximumPoolSize,则创建新的非核心线程来执行任务。

  4. 如果 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 面试追问

  1. 你的项目里线程池参数是怎么定的?依据是什么?——考察候选人是否真的思考过,而不是背模板。

  2. corePoolSize 和 maximumPoolSize 为什么不设成一样的?——考察对非核心线程创建条件的理解。

  3. 什么情况下核心线程会被回收?——考察allowCoreThreadTimeOut的知识点。

  4. 线程池每次创建线程都会立即执行任务吗?——考察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 面试追问

  1. 阿里巴巴规范为什么禁止使用 Executors 创建线程池?——考察对无界队列、线程数无上限等隐患的理解。

  2. newFixedThreadPool 和 newCachedThreadPool 最大的区别在哪里?——前者"固定线程数 + 无界队列",后者"无限线程数 + 同步队列",考察参数联动的理解。

  3. SynchronousQueue 的特点是什么?——不存储元素,插入和移除必须配对,这也是 cached 线程池会疯狂扩线程的根因。

  4. 如果我就是想用 Executors 的语义,怎么规避风险?——自己写 ThreadPoolExecutor,保留语义但使用有界队列和有限最大线程数。

四、坑三:无界队列让 maximumPoolSize 形同虚设,任务堆积到 OOM

在「坑二」中我们反复提到了无界队列,但它的危害远不止 Executors 工厂方法。很多开发者即使手动 new ThreadPoolExecutor,也会习惯性地写一句new LinkedBlockingQueue<>(),认为自己已经"手动创建线程池"了,就安全了。实际上,只要队列是无界的,线程池的 maximumPoolSize 就几乎永远不会生效,这个坑比很多人想象的更普遍。

4.1 无界队列如何让 maximumPoolSize 失效

回顾线程池的任务执行流程:先创建核心线程,核心线程满了就入队,队列满了才会创建非核心线程,直到 maximumPoolSize,最后才走拒绝策略。现在把队列换成无界队列,问题就很清晰了:

  1. 核心线程满了之后,新任务全部进入队列;

  2. 因为队列永远不会满,所以永远不会触发"创建非核心线程";

  3. maximumPoolSize 设得再大,也只是一个"摆设",实际线程数最多到 corePoolSize;

  4. 队列中的任务越堆越多,占用 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 面试追问

  1. 无界队列为什么会让 maximumPoolSize 失效?——考察对执行流程和"队列满才创建非核心线程"的理解。

  2. 队列容量定多大比较合适?——考察是否结合任务耗时、峰值 TPS、可接受延迟等工程因素思考。

  3. ArrayBlockingQueue 和 LinkedBlockingQueue 怎么选?——考察对两种队列底层结构、锁机制和内存特性的理解。

  4. 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 面试追问

  1. 四种拒绝策略分别是什么?默认是哪个?——考察基础记忆。

  2. CallerRunsPolicy 有什么优缺点?——考察对背压思想和副作用的理解。

  3. 什么时候可以用 DiscardPolicy?——通常没有绝对安全的使用场景,只有允许数据丢失、只关心"尽力而为"的业务才可能接受。

  4. 如何设计一个生产级的拒绝策略?——至少包含日志、告警、降级或重试。

六、坑五: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 参数不是算出来的,是压测出来的

无论用什么公式,最终参数一定要结合压测和线上监控来调优。推荐的做法是:

  1. 按公式估算一个初始值;

  2. 在测试环境用目标 QPS 压测,观察 CPU 使用率、队列积压、响应时间、拒绝次数;

  3. 根据指标逐步微调,直到找到吞吐和延迟的平衡点;

  4. 上线后持续监控,业务量变化时重新评估。

6.5 面试追问

  1. CPU 密集型和 IO 密集型线程数怎么定?——考察是否理解公式背后的原理。

  2. 为什么 CPU 密集型推荐核数 + 1?——考察对上下文切换开销的理解。

  3. 如果线上 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 面试追问

  1. submit 和 execute 有什么区别?——考察对异常处理差异的理解。

  2. submit 提交的任务抛异常,如果不调用 get,异常会怎样?——会被静默吞掉,这是最危险的场景。

  3. 生产环境推荐用哪个?——如果不需要返回值,推荐 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 面试追问

  1. 线程池中的任务抛出未捕获异常,线程会怎样?——考察对 runWorker 和 processWorkerExit 的理解。

  2. execute 和 submit 在异常处理上有什么不同?——考察对 FutureTask 包装机制的理解。

  3. 如果任务频繁抛异常,线程池会怎样?——会频繁创建和销毁线程,浪费资源。

九、坑八:线程池被"藏"起来,出问题无法排查

很多项目的线程池创建代码散落在各个业务类里,没有统一管理,导致出现问题很难定位。这是工程实践中最容易被忽视但影响最大的问题之一。

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 面试追问

  1. 项目里线程池怎么管理的?——考察工程实践能力,回答应该体现统一管理、监控和动态调整。

  2. 线程池如何做动态参数调整?——ThreadPoolExecutor 提供了 setCorePoolSize、setMaximumPoolSize 等方法,可以配合配置中心实现热更新。

  3. 线程池监控的关键指标有哪些?——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 面试追问

  1. 为什么一定要自定义线程工厂?——考察是否有线上排查经验。

  2. 线程名的命名规范是什么?——建议包含业务名、池名和序号,例如order-pool-1。

  3. 自定义线程工厂还能做哪些事?——设置守护线程、优先级、异常处理器、线程组等。

十一、坑十: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 面试追问

  1. ThreadLocal 和线程池一起用会有什么问题?——考察对线程复用和数据串号的理解。

  2. 为什么 ThreadLocal 会导致内存泄漏?——考察弱引用 key + 强引用 value 的设计。

  3. 怎样保证 ThreadLocal 在异步任务中正确传递?——可以使用 InheritableThreadLocal(但线程池场景下不生效)、TransmittableThreadLocal(阿里 TTL)或者显式传参。

十二、总结:线程池的十条军规

回到本文的主题,我们把线程池最容易踩的 10 个坑总结成十条军规:

  1. 参数配置要基于业务模型,不要拍脑袋,CPU 密集用核数 + 1,IO 密集用核数 × 2 起步,并结合压测调整。

  2. 禁止使用 Executors 创建线程池,显式 new ThreadPoolExecutor。

  3. 队列必须是有界的,容量根据任务耗时、峰值 TPS、可接受延迟来估算。

  4. 拒绝策略必须显式选择,推荐自定义拒绝策略并配套日志、告警、降级。

  5. CPU 密集和 IO 密集任务拆分线程池,避免相互干扰。

  6. 不要混用 submit 和 execute,两种方式的异常处理行为完全不同。

  7. 任务内部必须 try-catch,防止异常导致线程终止或异常被吞。

  8. 线程池要统一管理,通过 Spring Bean 集中配置,禁止散落各处。

  9. 必须自定义线程工厂,线程名包含业务信息和序号,方便线上排查。

  10. ThreadLocal 用完必须 remove,避免数据串号和内存泄漏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询