很多人学并发编程,都是从new Thread起步的。我也一样,早期写多线程代码基本就是一把梭:要并发?new Thread就行。直到有一天线上服务出了问题,线程数飙到几百,每个线程都在那空转,CPU 被打满,日志里全是各种超时,那次事故之后我才真正意识到,无脑创建线程这条路走不通了。
线程池不是锦上添花的东西,它是生产环境下管理线程的必需品。但线程池这个概念对初学者来说确实有点抽象,什么核心线程数、最大线程数、阻塞队列、拒绝策略,光听名字就容易劝退。我在这个系列里打算用最简单的方式,从一个最小可运行的例子出发,带着大家把线程池真正跑起来,再去拆解它内部的那些参数和规则。这篇是第一期,目标只有一个:用十几行代码,让你看到线程池到底在干什么。
1. 很多人的第一课:new Thread 很爽,然后线上就炸了
在写线程池例子之前,我想先聊聊为什么要换掉new Thread。如果你还没经历过线上事故,可能觉得线程池是多余的设计——毕竟new Thread确实简单直接,两行代码就能跑起一个异步任务。
1.1 new Thread 的三宗罪
第一宗罪:线程的生命周期开销被完全忽略。一个线程从创建到销毁,涉及 JVM 分配栈空间、操作系统创建内核线程、线程调度等一系列操作。JVM 默认的线程栈大小是 1MB,也就是说你每new一个线程,光栈内存就预留了 1MB。你写个循环for (int i = 0; i < 1000; i++) new Thread(...),高峰期几千个线程同时存在,光栈内存就是几个 GB,服务器不炸才怪。
第二宗罪:线程完全不受控。new Thread出来的线程,你没有办法限制它的数量,也没有办法统一管理它的生命周期。任务来了就创建,任务结束就销毁,这些线程就像散养的孩子,没人知道它们现在在干嘛、有多少个、是否已经僵死。线上排查问题时,线程数不可控往往是最大的噩梦。
第三宗罪:没有复用机制。线程创建和销毁的成本都付了,结果每个线程只干一个活就没了。你想想,去银行办业务,如果每个人来了银行都给配一个专属柜员,办完这一个人的业务柜员就辞职,银行早就倒闭了。线程池就是那个"柜员复用"机制:柜员固定几个,在窗口等着,来一个客户办一个,办完了继续等下一个。
1.2 线程池到底解决的是什么
线程池本质上是线程的复用和调度中心。它解决了三个核心问题:
- 控制线程数量,避免无限制创建导致资源耗尽。
- 复用线程,降低创建和销毁的开销。
- 提供任务队列,把任务缓冲起来,平滑处理峰值请求。
你可以把线程池想象成一家餐厅的后厨。核心厨师是固定的几个人,忙着炒菜;如果客人太多,核心厨师忙不过来,菜单就先放在"待处理区"排队(这就是阻塞队列);排队区也满了,店长就临时把帮工也叫来炒菜(这就是创建新线程到最大线程数);如果连帮工都忙不过来,再来的客人就只能在门口等着或者直接不接了(这就是拒绝策略)。这个类比虽然简单,但和线程池的运行机制几乎一一对应,后面拆解参数的时候你可以随时回来看这张图。
提示:理解线程池,不要先陷入源码细节,先把"后厨模型"吃透,后面所有参数都是在给这个模型打补丁。
2. 先让例子跑起来:最小可运行的线程池 Demo
说再多理论不如直接跑一个例子。我写代码一向主张先跑通,再深入。下面这个例子是整个系列的地基,我建议你自己动手敲一遍,不要复制粘贴,敲完运行一下,这和你只看代码的感受完全不同。
2.1 一个能看出门道的 Demo
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; public class ThreadPoolDemo01 { public static void main(String[] args) throws InterruptedException { // 自定义线程工厂,给每个线程起个名字,方便观察 ThreadFactory namedThreadFactory = new ThreadFactory() { private final AtomicInteger seq = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { Thread t = new Thread(r); t.setName("worker-" + seq.getAndIncrement()); return t; } }; // 核心线程数 2,最大线程数 4,队列容量 8 ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // corePoolSize 4, // maximumPoolSize 10, // keepAliveTime TimeUnit.SECONDS, // 单位 new ArrayBlockingQueue<>(8), // 有界阻塞队列,容量 8 namedThreadFactory, // 线程工厂 new ThreadPoolExecutor.AbortPolicy() // 拒绝策略 ); for (int i = 1; i <= 15; i++) { final int taskNo = i; executor.execute(() -> { System.out.println("[" + Thread.currentThread().getName() + "] 拿到第 " + taskNo + " 号任务"); try { Thread.sleep(2000); // 模拟业务处理 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } System.out.println("---- 所有任务已提交完毕 ----"); executor.shutdown(); } }这段代码是这个系列里最核心的一个例子,建议你编译运行一下。我先把预期结果写在下面,方便你在运行前心里有个底。注意,因为有 15 个任务,但线程池最多容纳"2 个核心线程 + 8 个队列任务 + 最多额外创建 2 个非核心线程",总共最多同时接受 14 个任务,所以第 15 个任务会被拒绝,控制台会抛RejectedExecutionException。
2.2 运行它,你会看到什么
我运行这段代码,控制台输出大概是这样的(每次运行线程调度顺序可能略有不同):
[worker-1] 拿到第 1 号任务 [worker-2] 拿到第 2 号任务 [worker-1] 拿到第 8 号任务 [worker-2] 拿到第 9 号任务 [worker-3] 拿到第 11 号任务 [worker-4] 拿到第 12 号任务 [worker-1] 拿到第 3 号任务 [worker-2] 拿到第 4 号任务 ... Exception in thread "main" java.util.concurrent.RejectedExecutionException这里有几个现象值得注意:
第一,出现了worker-3和worker-4,说明在某个时刻,线程池确实创建了非核心线程来处理任务。第二,任务编号和线程的对应关系不是严格按顺序来的,因为任务在线程池里有一个"排队+调度"的过程。第三,第 15 个任务被拒绝了,抛出了异常。
如果你运行后看到了RejectedExecutionException,不要慌,这是刻意设计的。这个异常信息非常关键,它是触发拒绝策略的信号。你可以试着把for循环的 15 改成 10,再运行一次,看看会有什么不同——你会发现没有异常了,而且始终只有worker-1和worker-2两个线程在干活。这个对比实验能让你直观感受到线程池"先排队、后加人"的决策过程。
3. 逐参数拆解:这个例子里每个配置都不是摆设
刚才那个 Demo 虽然只有十几行核心代码,但里面每一个构造函数参数都有讲究。很多人用线程池只敢抄配置,不敢自己调,就是因为没搞懂这些参数的含义。这一节我们把 7 个参数逐个拆开。
3.1 核心线程数 corePoolSize 与最大线程数 maximumPoolSize
corePoolSize是线程池的常驻劳动力。只要线程池活着,这 2 个线程就不会被回收,哪怕它们空闲着、没有任何任务可做。这就是"核心"二字的含义——它们是这个线程池的基本盘。
maximumPoolSize是线程池在极端情况下的最大劳动力数量。当核心线程全忙、队列也全满的时候,线程池才会允许自己"加班招人",把线程数从 core 往 maximum 方向扩展。在上面的例子里,maximumPoolSize为 4,意味着worker-3、worker-4就是临时叫来的"帮工"。
这里有一个非常关键、也是初学者最容易搞错的点:线程数从 2 往 4 扩张的时机,不是"线程忙了"就立刻扩,而是必须等到队列也满了才扩。也就是说,任务提交的优先级是:核心线程 → 队列 → 非核心线程,而不是核心线程 → 非核心线程 → 队列。这个顺序搞反,后面的所有理解都会出错。
3.2 keepAliveTime 与 TimeUnit:临时工的生存期
keepAliveTime是非核心线程(也就是超过 core 的那部分线程)允许空闲的最大时长。如果worker-3处理完手头的任务后,接下来 10 秒内都没有新任务可接,它就会被回收销毁,线程数重新回到 2。
这也解释了为什么叫"临时工"——活儿多的时候叫你过来帮忙,活儿少了你闲着超过一定时间,就得走人。反过来,核心线程无论闲多久都不会被回收,除非你显式调用了allowCoreThreadTimeOut(true),生产环境下一般没人这么干。
3.3 workQueue 阻塞队列:任务的缓冲区
刚才说核心线程忙不过来的任务,不会马上创建新线程,而是先放进队列。这个队列就是ArrayBlockingQueue,容量 8,有界。
队列的作用是削峰填谷。想象一下,核心线程每秒只能处理 2 个任务,但瞬时来了 20 个任务,如果没有队列,要么任务丢失,要么疯狂创建线程;有了队列,任务可以先排队,线程有空了再从队列里取。这就是线程池能平滑应对突发流量的关键。
有界队列和最大线程数的配合公式是这样的:
线程池能同时"接受"的任务数 =
corePoolSize+队列容量+(maximumPoolSize - corePoolSize)
注意这里我把最后一部分括起来了,因为非核心线程只有在队列满之后才可能创建,创建后它也在同时处理任务。所以严格来说,最多同时接受的任务数是corePoolSize + 队列容量 + (maximumPoolSize - corePoolSize),也就是maximumPoolSize + 队列容量。当提交的任务数超过这个值,就会触发拒绝策略。
3.4 threadFactory:给线程一个交代
说实话,很多人刚学线程池的时候会忽略threadFactory这个参数,直接不传或者用默认的。默认的线程池生成的线程名是pool-1-thread-1这种,线上排障的时候几乎没法用。一旦出问题,你看到线程 dump,满屏的pool-1-thread-1、pool-1-thread-2,根本不知道这个线程是哪个业务模块创建的。
我强烈建议,从一开始就养成自定义ThreadFactory的习惯,给线程起一个有意义的名字,比如order-worker-、pay-callback-worker-。这个习惯在排查线上问题时能省下大量时间。你可以把刚才 Demo 里的namedThreadFactory稍微改改,名字换成你业务模块的名字,线程 dump 里一眼就能定位。
3.5 RejectedExecutionHandler:拒绝策略,最后的防线
当提交的任务数超过了maximumPoolSize + 队列容量,线程池就用拒绝策略来兜底。默认的AbortPolicy直接抛RejectedExecutionException,这也是上面例子抛异常的原因。
JDK 内置了 4 种拒绝策略,我整理了一张表:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛出 RejectedExecutionException | 默认策略,适合对任务丢失零容忍的场景,但要注意调用方的异常处理 |
| CallerRunsPolicy | 谁提交的任务谁自己跑 | 降级执行,不丢任务,但会阻塞调用方线程 |
| DiscardPolicy | 静默丢弃新任务 | 可接受部分任务丢失的场景,比如非关键日志上报 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,然后重新尝试提交当前任务 | 希望任务尽量"新鲜"的场景,常用于定时批量任务 |
刚上手的时候,你只需要知道AbortPolicy是默认的、CallerRunsPolicy是一种优雅降级就够了,剩下两个等到你深入使用线程池处理有界队列时再研究不迟。
4. 提交 15 个任务,看线程池如何一步步做出选择
参数拆完了,我们把上一节的规则串起来,用真正的任务提交过程走一遍"后厨流程"。还是以那个 Demo 为例:核心 2,最大 4,队列容量 8,共提交 15 个任务。
4.1 第一个任务到第 10 个任务:排队优先于加人
任务 1、2 被提交时,核心线程worker-1、worker-2直接认领,开始执行。注意,执行期间任务 3、4、5……还在继续提交。
任务 3 到任务 10 提交时,核心线程还忙着,于是按照规则先检查队列——队列容量是 8,此时队列从头到尾只放进去了部分任务,还没满。所以任务 3~10 会被放进队列排队,线程池不会创建worker-3。这是我在前面反复强调的重点:只要队列没满,就算核心线程忙到冒烟,线程池也绝不额外加人。
很多初学者在这里会产生一个误解,他们以为最大线程数是用来"加速处理"的——任务多了,多开几个线程处理得快一点。实际上完全不是这样。线程池的策略是"我先把活接下来排着队,实在排不下了,才叫人帮忙"。这个设计背后的逻辑是:线程创建和上下文切换是有成本的,能用排队解决的,绝不轻易加线程。所以maximumPoolSize不是为了快,而是为了不丢任务、不崩系统。
4.2 第 11 个任务到第 14 个任务:队列满了,开始扩编
当任务 11 提交过来时,核心线程还在处理任务 1、2(因为每个任务都要 sleep 2 秒),队列里的任务也排到了第 8 个位置——队列已经满了。这时候,线程池才会做出判断:来活了,但排队区满了,那就把临时工也叫来。于是创建worker-3,接管任务 11。
同样,任务 12 也让线程池继续扩编,创建worker-4,接管任务 12。任务 13、14 提交时,worker-3和worker-4如果正好空闲,就直接接手;如果也忙,任务继续排队。总之,线程数量从 2 上涨到了 4,到达了maximumPoolSize的上限。
4.3 第 15 个任务:最后的防线触发
任务 15 提交时,鼎盛状态的线程池是:4 个线程全忙 + 队列里 8 个任务在等 = 已经接受了 14 个任务。此时再提交任务,已经超过了maximumPoolSize + 队列容量的上限,线程池只能启动拒绝策略——默认的AbortPolicy直接抛出RejectedExecutionException。
这个"15"不是随手写的。你可以自己算一下:最大线程数 4 + 队列容量 8 = 12……好像算出来是 12,不是 14?这里要注意,线程池实际能同时承载的任务数是最大线程数(4 个正在执行)+ 队列容量(8 个等待执行)= 12 个"正在处理中"的任务,但为什么第 15 个才被拒?
原因是执行过程中线程不是永远忙的。任务 1、2 可能在任务 11 提交前就已经执行完了,腾出了线程位置,于是任务 11~14 实际上是由释放出来的线程承接的,队列并没有持续堆满 8 个。这个细节告诉我们:拒绝策略的触发点不是固定的"提交到第几个任务"就必然拒绝,而是取决于任务执行速度和提交速度的相对关系。我把任务的sleep时间设在 2 秒,保证任务处理够慢,让队列持续占满,你才观察到完整的决策链路。
提示:你可以试试把任务里的
Thread.sleep(2000)改成Thread.sleep(50),再提交 15 个任务,大概率就不会抛异常了。因为任务处理太快,队列永远堆不满,线程池根本不需要走到"拒绝"那一步。这就是执行速度和提交速度的博弈,也是线程池设计的精髓。
5. 阻塞队列选型入门:刚上手时不必纠结,但要知道方向
刚才的 Demo 里用的是ArrayBlockingQueue,这是一个有界队列。很多从Executors工具类入门的人,其实根本没见过这个类——因为Executors.newFixedThreadPool()底层用的是LinkedBlockingQueue,而且是无界的。这一节聊聊队列选型的方向,这是网上讨论很多、初学者也最容易困惑的点。
5.1 常见阻塞队列一览
线程池可用的队列主要有这么几种:
ArrayBlockingQueue:有界数组队列,创建时必须指定容量,公平性可配。LinkedBlockingQueue:链表队列,可指定容量,也可以不指定(无界)。SynchronousQueue:不存储任务的队列,每个插入操作必须等待另一个线程的移除操作。PriorityBlockingQueue:支持按照优先级取出任务的队列。
每种队列对应不同的业务场景。ArrayBlockingQueue有界,适合需要对资源做硬限制的服务,比如你不想让线程池累积太多任务导致内存暴涨。LinkedBlockingQueue不带容量时是无界的,任务可以无限排队,但这也意味着如果生产速度长期大于消费速度,内存会被任务对象堆满,最终 OOM。SynchronousQueue很有趣,它没有缓冲容量,任务直接递给线程,如果线程不够用,就会立刻促使线程池创建新线程——Executors.newCachedThreadPool()用的就是它,配合很大的maximumPoolSize,能实现"来一个任务就开一个临时线程,空闲了又回收"的效果。
5.2 为什么我不建议你直接用 Executors 的快捷方法
Executors提供了newFixedThreadPool、newCachedThreadPool、newScheduledThreadPool这几个工具方法。用起来确实很爽,比如:
ExecutorService pool = Executors.newFixedThreadPool(10);但配置是写死的,newFixedThreadPool用的是无界LinkedBlockingQueue,newCachedThreadPool的maximumPoolSize是Integer.MAX_VALUE。前者意味着任务可以无限排队,后者意味着线程数理论上可以无限膨胀。在生产环境里,这两种情况都可能变成灾难——队列无限导致 OOM,线程无限导致线程数失控、上下文切换开销暴涨。
我个人的建议是,无论你做什么项目,只要有条件,都直接用ThreadPoolExecutor手动构造,参数自己定。这样你被迫思考每个参数的含义,而不是躲在一个看似便利、实则危险的封装后面。刚开始不知道配多少?先用corePoolSize和 CPU 核数同量级,maximumPoolSize设为核心的两倍,队列容量设为一个可以接受的积压量,然后根据压测结果慢慢调,这是完全合理的起步姿势。
5.3 这一篇你需要知道的最简选择口诀
队列选型在后续的文章里我会专门展开,包括不同队列底层的实现差异和实战选型表。但作为第一篇,你只要记住三个方向:
- 想严格控制资源上限:用有界队列,比如
ArrayBlockingQueue,配一个明确的容量。 - 想让任务尽量不丢、宁可多等一会儿:用无界队列,但你要承担 OOM 风险,并保证生产速率不会持续大于消费速率。
- 想追求低延迟、快速响应,且任务都不重:考虑
SynchronousQueue,它让你能迅速动用额外线程处理任务,而不是排队干等。
换句话说,这一篇里我们的ArrayBlockingQueue是"省着用线程"的思路——先排队,排不下才开新线程。而SynchronousQueue是"缺了立刻补"的思路——没有缓冲,线程不够马上开。两种思路没有绝对的好坏,只有场景的适配。
6. 这个 Demo 之外的几个细节:线程数怎么估,非核心线程怎么回收
跑通了例子,看懂了参数,最后再给你塞几个值得记住的实操细节。这些细节在我刚开始用线程池时也困惑过一阵子,属于"官方文档不会讲得太细,面试却总爱问"的内容。
6.1 核心线程真的永远不会死吗
严格来说,唯一能让核心线程被回收的方式是调用threadPoolExecutor.allowCoreThreadTimeOut(true)。一旦开启,核心线程空闲超过keepAliveTime后也会被回收,线程数甚至可以降到 0。这个功能适合低频任务场景——比如一个线程池一天可能就跑几次定时任务,平时没必要养着两个线程浪费资源。开启后,线程池在长时间空闲时可以缩到 0 个线程,任务来了再创建,省一点内存和句柄开销。代价就是任务到来时可能有短暂的创建延迟。生产环境用不用,取决于你这个线程池的任务频率到底有多低。
6.2 线程数到底配多少,一个经验公式
很多文章会给出一个估算公式:
线程数 = CPU 核数 × CPU 期望利用率 × (1 + 等待时间 / 计算时间)
这个公式本来是《Java 并发编程实战》里的经典公式。它的意思是:线程大部分时间在等待 IO,等待期间 CPU 是闲的,多开线程才能把 CPU 用满;如果任务全是纯计算(CPU 密集型),开超过核数的线程反而因为上下文切换而更慢。理论很好,但实际排查我会先压测一轮,用事实说话。项目刚起步时,用可控的默认值跑起来,再渐进式压测调整参数,这比死记公式靠谱得多。
6.3 线上排障时最常用的三个命令级操作
当你用上了自定义线程名之后,排查线程池问题会变得很舒服。我常用的三板斧:
jps找出 Java 进程的 PID。jstack <pid>打印线程 dump,直接搜线程名前缀,比如worker-,就能看到这个线程池每个线程的状态。jstat -gcutil <pid>看 GC 情况,排查是不是线程池队列积压了大量任务,触发 GC 压力。
这套操作是我在定位"线程池队列堆积导致内存压力大"问题时最常组合使用的。这一篇先记住思路,后续讲到线上案例时我们还会再展开。
这是线程池系列的第一篇,从new Thread的痛点出发,带你跑了一个最小可用的ThreadPoolExecutor例子,然后逐个拆解了核心参数,最后用 15 个任务的提交过程完整还原了线程池的决策链路。你能亲手跑通并看清楚上面那个 Demo 的输出,这一篇的核心目标就达成了。
我写这一系列,不太喜欢那种上来就丢出一大堆流程图和源码分析的做法,那样看着高端,但对刚接触线程池的人来说反而容易懵。我更愿意先把"后厨是怎么运作的"用一个小例子讲清楚,等你的印象牢固了,再逐步往深处走——包括阻塞队列的底层实现细节、拒绝策略的实战选型、线程池参数在微服务场景下的压测调优、以及线程池监控与动态调整这些进阶话题。
下一篇我打算专门把阻塞队列彻底讲透,毕竟它是线程池里最容易被低估、又最影响性能的一环。到时候我们直接拿ArrayBlockingQueue和LinkedBlockingQueue的源码对比着看,顺便把SynchronousQueue这个"怪胎"的真实面目揭开来。如果你照着这篇文章的例子在自己的 IDE 里跑过一遍,对线程池的行为产生了一些自己的疑问,那就带着这些疑问看下一篇,效果会更好。