1. 为什么我劝你别偷懒:Exectors工具类里的隐形坑
先讲个我自己的经历。早几年接手一个内部报表系统,上游接口偶尔会突然慢几秒,由于当时赶进度,同事图省事,创建线程池直接用Executors.newFixedThreadPool(8),任务提交用的是无界的LinkedBlockingQueue。表面看一切正常,但线上有一次上游接口连续超时,大量请求任务塞进队列,导致内存持续上涨,最后老年代占满,触发频繁 Full GC。重启之后排查半天,问题根源就是那个随手写下的线程池。
那件事之后,我给自己定了一条规矩:线上代码里,线程池一定要手动创建,参数必须在代码里明确写出来,更重要的是,线程池里的线程必须要通过自定义ThreadFactory来命名和配置。这个习惯后来帮我省了无数次定位线上问题的力气。
很多人初次接触线程池,会先学Executors工具类,因为确实太方便了:
ExecutorService pool = Executors.newFixedThreadPool(5);但在实际项目中,这套默认配置是远远不够的。它主要存在三个问题:
第一,默认线程名没有业务信息。假设你的应用里同时有订单处理线程池、日志推送线程池、数据清洗线程池,它们崩了或者卡住了,打印出来的线程栈全长一个样:pool-1-thread-1、pool-2-thread-3。你盯着日志完全分不清是哪条业务链路出的问题,只能一个个去猜。
第二,默认线程不是守护线程。Executors内部用的是DefaultThreadFactory,创建出来的线程是非守护线程。如果某些任务需要驻留后台轮询,你希望 JVM 能正常退出,但因为这个线程池里的线程还活着,进程就退不掉。这在开发环境不明显,在部署脚本和容器场景里非常容易踩中。
第三,newFixedThreadPool和newSingleThreadExecutor默认用了无界队列。任务可以无限堆积,线程数却固定不变。一旦任务积压速度超过处理速度,队列里的对象会一直积压,轻则延迟飙升,重则内存溢出。newCachedThreadPool则完全相反——最大线程数是Integer.MAX_VALUE,任务一多就疯狂建线程,直接把系统资源耗尽。
所以这次这个 demo,我们就彻底绕开Executors,从构造器层面手动组装一个线程池,再配一个自定义的ThreadFactory。这个组合拳几乎是所有中大型项目里最通用的做法,看懂这个 demo,以后看任何框架源码里的线程池配置,都会觉得眼熟。
2. 先把线程池的七个参数和ThreadFactory的原理掰开揉碎
2.1 七个核心参数,每一个都决定了线程池的生死
ThreadPoolExecutor最完整的构造器长这样:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)逐个说清楚,这七个参数缺一不可:
corePoolSize(核心线程数):线程池里常驻的线程数量。即使线程空闲,默认也不会销毁(除非设置了allowCoreThreadTimeOut(true))。可以理解成一家餐厅的固定员工:不管忙不忙,这几个人都在岗。
maximumPoolSize(最大线程数):线程池最多能创建的线程数量。当核心线程全部忙碌,且任务队列也满了,线程池才会继续创建新线程,直到达到这个上限。对应到餐厅,就是高峰期临时叫来的兼职服务员的上限。
keepAliveTime(存活时间):当线程数超过核心线程数时,多余的空闲线程最多存活多久。如果超过这个时间还没有新任务进来,这些额外的线程就会被回收。它只在“线程数大于核心线程数”的时候才有意义。
unit(时间单位):keepAliveTime的时间单位,一般用TimeUnit.SECONDS或TimeUnit.MINUTES。
workQueue(任务队列):核心线程全部忙不过来的话,新任务先放进队列等待。队列选型是最容易踩坑的地方:
LinkedBlockingQueue:无界队列,任务可以无限堆积,不会触发 maximumPoolSize 扩容,风险大;ArrayBlockingQueue:有界队列,必须指定容量,队列满了之后才触发扩容;SynchronousQueue:不存储任务,直接把任务交给新线程,适合任务处理速度极快的场景。
threadFactory(线程工厂):这是本次 demo 的重头戏。它负责实际“生产”线程对象,包括线程命名、优先级、是否守护线程、是否自定义异常处理器等。
handler(拒绝策略):当任务队列已满,线程数也达到上限,新提交的任务就会被拒绝。四种内置拒绝策略如下:
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy | 直接抛RejectedExecutionException | 默认策略,希望立刻感知问题 |
CallerRunsPolicy | 由提交任务的线程自己执行该任务 | 不想丢弃任务,且能容忍提交线程被阻塞 |
DiscardPolicy | 静默丢弃新提交的任务 | 允许丢失部分任务,例如打点日志 |
DiscardOldestPolicy | 丢弃队列里最旧的任务,再尝试提交新的 | 希望保留最新任务,可容忍旧任务被丢弃 |
2.2 ThreadFactory 源码到底做了什么
直接看 JDK 自带的DefaultThreadFactory源码,逻辑非常清晰:
public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadNumber.getAndIncrement(), 0); if (t.isDaemon()) t.setDaemon(false); // 强制设置非守护线程 if (t.getPriority() != Thread.NORM_PRIORITY) t.setPriority(Thread.NORM_PRIORITY); // 统一设置标准优先级 return t; }JDK 默认实现里,线程命名格式是pool-N-thread-M,N 是线程池编号,M 是这个池里的线程编号。这种命名最大问题就是——多业务共存时毫无辨识度。
自定义ThreadFactory本质上就是重写这一个方法。我们只需要做三件事:
- 实现
ThreadFactory接口; - 在
newThread(Runnable r)里创建Thread对象; - 给线程取一个能一眼分辨出业务归属的名字,并设置是否守护。
来写第一版最简单的实现,线程名带固定前缀加自增序号:
import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String namePrefix; private final AtomicInteger threadNumber = new AtomicInteger(1); public NamedThreadFactory(String namePrefix) { this.namePrefix = namePrefix; } @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r, namePrefix + "-" + threadNumber.getAndIncrement()); return thread; } }AtomicInteger保证多线程环境下序号自增是线程安全的,不用加锁。namePrefix建议直接带上业务模块名,例如"order-async-pool"、"file-upload-pool"。这样将来 jstack 查看线程栈时,哪个业务线的线程一目了然。
3. 自定义线程池demo:从配置到运行的全过程
3.1 demo 的思路与整体结构
这个 demo 我设计了三个部分:
- 一个模拟耗时业务的任务类;
- 一个刚刚写好的
NamedThreadFactory; - 主程序里手动创建线程池,提交一批任务,并验证线程命名、拒绝策略和动态扩容的效果。
同时我设置了一个偏小的队列ArrayBlockingQueue<>(3),核心线程 2 个,最大线程 4 个,故意制造“队列满员 + 线程扩容”的场景,这样现象更直观。
3.2 完整可运行的 demo 代码
首先是模拟任务类,每个任务打印当前执行线程的名称和任务编号,然后睡 200 毫秒模拟业务处理:
import java.util.Date; public class DemoTask implements Runnable { private final int taskId; public DemoTask(int taskId) { this.taskId = taskId; } @Override public void run() { String threadName = Thread.currentThread().getName(); System.out.printf("%s 任务ID=%d 开始执行,时间=%s%n", threadName, taskId, new Date()); try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.printf("%s 任务ID=%d 执行完成%n", threadName, taskId); } }接着是核心的线程池配置与提交逻辑:
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolDemo { public static void main(String[] args) { // 1. 手动创建线程池,绕开 Executors ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // corePoolSize 4, // maximumPoolSize 10, // keepAliveTime TimeUnit.SECONDS, new ArrayBlockingQueue<>(3), // 有界队列,容量3 new NamedThreadFactory("order-async-pool"), // 自定义线程工厂 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 ); // 2. 提交10个任务 for (int i = 1; i <= 10; i++) { executor.submit(new DemoTask(i)); } // 3. 关闭线程池 executor.shutdown(); try { // 最多等60秒 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } System.out.println("全部任务执行完毕"); } }3.3 运行结果解析:队列、扩容、拒绝策略是怎么协作的
跑一次上面的 demo,控制台会出现一大串order-async-pool-1和order-async-pool-2开头的日志。仔细观察会发现几件事:
第一,并非所有任务都用到了新线程。前两个任务被核心线程 1 和 2 接走,任务 3 到 5 进入容量为 3 的有界队列等待。这个阶段线程池没有扩容,因为队列还没满。
第二,任务 6 到来时队列已满,线程数开始往 max 增长。此时线程池创建了 3 号和 4 号线程(取决于线程工厂已经编号到几),任务 6 和 7 被新线程直接执行。这验证了ThreadPoolExecutor的一个关键规则:先填核心线程,再进队列,队列满了才尝试扩容到 maxThreadNum。
这里我用的是CallerRunsPolicy,所以当任务 8、9、10 提交时,活跃线程数已经达到最大 4、队列也满了,再往池子里塞任务就会被拒绝策略接住。CallerRunsPolicy会把剩余任务交还给提交任务的 main 线程去执行,所以运行日志里你还会看到main线程直接执行任务的现象,而后面的任务处理时长也会因此变慢一些。
这个现象很重要,很多初次接触线程池的人会误以为“只要核心线程忙,就会立刻建新线程”。实际上必须结合队列饱和度来判断:队列没满时,线程池是一副“忍让”的姿态,只有队列满了才会继续扩大规模。
为了验证线程池整体行为,我把这 10 个任务的执行日志按顺序整理成了一张假想表,方便你对照参数理解现象:
| 提交顺序 | 当时状态 | 被哪个线程执行 |
|---|---|---|
| 任务1 | 核心线程空闲 | order-async-pool-1 |
| 任务2 | 核心线程空闲 | order-async-pool-2 |
| 任务3-5 | 核心线程忙,队列未满 | 进入队列等待 |
| 任务6-7 | 队列已满,线程未到上限 | 扩容线程 order-async-pool-3/4 |
| 任务8-10 | 线程已满,队列已满 | CallerRunsPolicy:由 main 线程直接执行 |
3.4 各种参数调整后的效果对比
如果你有精力,可以试着改变几个参数对比现象,我建议至少试这么三组:
- 核心线程数改成 4,最大线程数不变 4:此时不会发生扩缩容,任务 6 之前全部由核心线程处理,多余任务全部排队;
- 队列换成
LinkedBlockingQueue无界队列:不会触发 3 号和 4 号线程创建,任务 6-10 全部排队等待,线程池永远不会达到 max 值; - 拒绝策略换成
AbortPolicy:当任务 8 提交时,会直接抛RejectedExecutionException,后面的任务提交全部中断。
每一项改动都能帮你加深对线程池参数协作的记忆。
4. 线程命名和异常捕获:ThreadFactory 在真实项目里到底能做什么
4.1 线程名前缀:排查线上故障的“指路牌”
先说说命名这件事。你在线上遇到问题,第一步往往是先抓线程栈。比如我用 jstack 打印出来的线程片段:
"order-async-pool-1" prio=5 Id=12 RUNNABLE at com.demo.service.OrderService.process(OrderService.java:88)你一眼就知道这是订单异步池里的线程,再往下翻个几行,就能定位到具体的代码位置。而如果用的是默认命名:
"pool-3-thread-1" prio=5 Id=12 RUNNABLE at com.demo.service.OrderService.process(OrderService.java:88)你得先去代码里搜索这个pool-3到底是哪段逻辑创建的线程池,无形中增加了很多排查成本。我实际经历里最夸张的一次,一个应用中有 9 个线程池,全部长这样,排查一个死锁问题花了整整一个下午。之后把所有线程池的命名规范化,同类问题定位时间压缩到了十几分钟。
所以ThreadFactory里最重要的一条实践就是:命名前缀必须包含业务模块名 + 用途关键词。例如:
order-pay-notify-poolreport-export-async-poolmq-consume-pool
同事一看就知道这个线程是干什么的,出了事也知道往哪个模块方向查。
4.2 设置守护线程:解决 JVM 无法正常退出的坑
自定义ThreadFactory还能顺手把线程设为守护线程。这里必须澄清一个概念:
守护线程不是“优先级更低的线程”,它的核心特点是:JVM 中只要还有非守护线程在运行,JVM 就不会退出;而当只剩下守护线程时,JVM 会不管这些线程是否在执行任务,直接退出。
如果你的线程池是给后台定时任务用的,比如某个轮询检查任务,它常驻后台且不需要阻塞主流程退出,就可以把线程设为守护线程。在NamedThreadFactory里加一行:
Thread thread = new Thread(r, namePrefix + "-" + threadNumber.getAndIncrement()); thread.setDaemon(true);不过我要多提醒一句:不要在需要异步持久化重要数据的场景里使用守护线程。比如你有一个异步写数据库的线程池,主线程执行完正在退出,如果线程池里的线程是守护线程,JVM 会直接退出,你的未落库数据就无声无息地丢了。这种场景应该保证线程是非守护的,并且配合shutdown()+awaitTermination()优雅关闭。
4.3 自定义线程的异常处理:避免异常被吞掉
ThreadPoolExecutor里有个非常隐蔽的坑:如果用execute()提交任务,任务内部抛出的RuntimeException能打到控制台,但你在外面根本没机会捕获它;如果用submit()提交任务,你拿到的是一个Future,异常会被封装在Future里,如果你不主动调用future.get(),异常就会被静默吞掉。
现实中大多数团队并没有养成“每次提交任务都记住拿返回值并调用 get()”的习惯,于是线上任务挂了半天,日志里什么都看不出来。自定义ThreadFactory可以在这里帮上大忙——创建线程时给线程挂一个UncaughtExceptionHandler:
public Thread newThread(Runnable r) { Thread thread = new Thread(r, namePrefix + "-" + threadNumber.getAndIncrement()); thread.setUncaughtExceptionHandler((t, e) -> System.err.println("线程 " + t.getName() + " 抛出未捕获异常: " + e.getMessage())); return thread; }这样即使上层没有及时调用future.get(),异常也会被统一记到某个日志通道,不至于彻底失明。更规范的做法是把异常打印到统一日志文件里,而不是System.err,但 demo 里这一点足以说明思路。
5. 参数选择与效果对比:核心线程数、队列容量怎么定才靠谱
5.1 任务类型决定线程池参数
网上很多教程会把核心线程数的计算公式吹得神乎其神,什么 CPU 密集型就CPU核数 + 1,IO 密集型就CPU核数 * 2。我个人的经验是:这些公式只能当作起步值,绝对不能当作最终答案。
实际项目里需要先分清楚任务类型:
- CPU 密集型任务:比如图像处理、加解密、大规模计算。这类任务占满 CPU,线程太多反而因为上下文切换导致性能下降,初始值可以设为
CPU核数 + 1,然后压测再调整。 - IO 密集型任务:比如调用外部接口、读写数据库、传输文件。线程大部分时间在等待 IO,CPU 比较闲,线程数可以设大一些,比如
CPU核数 * 2作为起点,再往上试探。 - 混合型任务:两个线程池分开跑更合理,一个池处理 CPU 密集部分,一个池处理 IO 密集部分,避免互相拖累。
不过我更推崇的实际做法是:确定一个保守的初始参数,然后通过压测观察线程池活跃线程数、队列积压量、任务处理时延这三个指标,再反复调整到合适值。没有压测数据支撑的线程池参数,都是拍脑袋。
5.2 队列容量和使用场景的关系
队列选型这块,我认为最核心的判断标准是:你能否接受任务积压带来的一系列连锁反应。
- 如果你要求“任务一个都不能丢,可以接受排队延迟”,选有界队列,容量给一个合理上限,比如几百到几千,结合任务堆积速度来定;
- 如果你希望“来了就立刻处理,不要排队等待”,可以考虑
SynchronousQueue,它不存任务,线程数会快速拉高; - 如果你明确知道偶尔的任务积压可接受,但绝不能因为积压拖垮 JVM,那
ArrayBlockingQueue是稳妥选择,配合一个合理的拒绝策略兜底。
我见过不少团队把队列容量设置成 Integer.MAX_VALUE,理由居然是“怕任务被拒绝”。这种做法等于把内存风险无限放大,一旦流量尖峰出现,最先挂的往往是 JVM。
5.3 合理兜底:手动拒绝策略通常会怎么做
内置的CallerRunsPolicy在 demo 里用起来很直观,但它也不是万能的。如果提交任务的线程本身处理任务会拖慢主链路,甚至引发连环阻塞,那就得考虑更保守的方案。
实际项目中比较通用的做法是重写RejectedExecutionHandler,把被拒绝的任务写进本地文件或消息队列,稍后再补偿重放。比如:
public class CustomRejectedHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 将任务序列化后写入补偿表或文件 System.err.println("任务被拒绝,进入补偿处理: " + r.toString()); } }不过这个方案要视具体业务而定,核心精神是:被拒绝不能意味着任务永久丢失,至少要留下可恢复的线索。
6. 进阶:嵌套命名、优雅关闭和容器环境里的线程池特别注意点
6.1 线程池里再建线程池的命名连贯性问题
业务复杂了之后会遇到一种情况:线程池的任务里又需要提交子任务到另一个线程池。比如订单处理线程池里的任务,需要调用一个内部异步发送消息的线程池。这时候如果两个池分别命名为order-process-pool和order-msg-push-pool,是基本要求。更进一步,你可以在处理类的toString()或日志上下文里带上父线程名称,这样从日志里能完整还原调用链:
public class AuditTask implements Runnable { @Override public void run() { String lineage = Thread.currentThread().getName(); // 父线程名 // 提交子任务时,可以把 lineage 一起传入 } }6.2 shutdown、shutdownNow、awaitTermination 的正确用法
线上很多故障都是线程池关闭方式不当造成的。我在这里分享一套相对稳妥的标准流程:
// 第一阶段:停止接收新任务,等待已提交任务执行完 executor.shutdown(); // 第二阶段:给一个合理的等待时间,比如30秒 if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { // 超时说明还有任务没执行完,强制中断 executor.shutdownNow(); // 再等一小段时间确认线程全部退出 if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { System.err.println("线程池未能完全停止"); } }值得留意的是shutdownNow()并非魔法子弹。它会给正在跑的线程发中断信号,但线程如果不响应中断,依然会继续执行。而且shutdownNow()会返回一个List<Runnable>,里面是被取消但没执行的任务,你可以把这些任务保存下来,之后补偿重放。
6.3 Spring 环境里别自己管线程池生命周期
如果你在框架项目里开发,比如常用的 Spring Boot,那还有一条额外的建议:把自定义线程池声明成一个容器管理的 Bean,并实现DisposableBean或者配合@PreDestroy调用关闭方法,让容器帮你统一管理生命周期。
业务代码里每次直接new ThreadPoolExecutor(...)是实践中的坏味道,它会带来两个问题:
- 线程池对象散落在各个业务类里,资源无法统一管理;
- 容器关闭时没有人负责优雅关停,任务容易丢失。
一个简单的 Spring 配置 Bean 大概长这样:
@Configuration public class ThreadPoolConfig { @Bean("orderAsyncExecutor") public ThreadPoolExecutor orderAsyncExecutor() { ThreadPoolExecutor executor = new ThreadPoolExecutor( 4, 8, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500), new NamedThreadFactory("order-async-pool"), new ThreadPoolExecutor.CallerRunsPolicy() ); return executor; } }也可以补充一个@PreDestroy方法,在应用停止时执行第 6.2 节中的优雅关闭流程。这样开发和运维都省心很多。
7. 我实际使用中积累的三条操作经验
最后聊几个容易被人忽略的小细节,算是我踩坑换来的经验。
第一条:谨慎给线程池的大小设置动态调节逻辑。有人喜欢根据队列积压量动态调整 corePoolSize 或 maximumPoolSize。虽然ThreadPoolExecutor提供了setCorePoolSize()和setMaximumPoolSize()方法,但动态调参需要非常谨慎,因为频繁扩容缩容是有代价的——线程创建和销毁都有开销,而且参数突变容易引起任务分配不均。我在生产环境里更倾向于固定参数 + 合理的拒绝策略,只在有充分压测数据时才考虑动态方案。
第二条:线程名一定要包含业务唯一标识。例如订单处理线程池,可以用order-async-pool做前缀;如果同一模块有多个池子,最好区别到具体用途,比如order-async-pool-pay、order-async-pool-refund。别嫌名字长,当你需要根据线程名从几百行日志里筛选问题时,每多一个信息词,排查效率都翻倍。
第三条:监控线程池的活跃线程数和队列大小,比调优参数更重要。很多线上问题并不是线程池参数设得“不对”,而是业务量涨了或者上游变慢了,你完全没有察觉。我现在的习惯是给每个线程池加监控指标,至少包括核心线程数、活跃线程数、队列积压数、拒绝任务数。一旦队列积压数持续上涨,说明消费能力跟不上生产速度,这时候就该考虑是不是要去调参数,而不是等系统报警了再仓促处理。
关于自定义线程池和自定义ThreadFactory的完整思路,到这儿就算讲透了。其实整件事的核心并不复杂:手动指定七参数管好线程池的行为边界,重写ThreadFactory管好线程的身份信息。这两个习惯一旦养成,你会发现自己排查线上并发问题的速度明显上了一个台阶。