做Java开发的朋友,迟早会和线程池打交道。大部分人的第一课是用Executors快捷方法创建线程池,看起来简单,但一旦上了生产环境就会发现,默认方案根本不够用。今天想分享一个非常实用的小项目:自定义线程池,配上自定义ThreadFactory的完整实现。
这个小demo解决的是很具体的问题——当你需要针对不同业务场景精细控制线程池行为时(比如给订单系统的线程命名成order-worker-1、order-worker-2,方便排查;或者需要控制线程是否daemon、设置优先级、统一捕获线程异常),你会发现自己动手写一个ThreadFactory有多么必要。本文会从线程池原理讲到工厂实现,再到完整demo和参数选型,最后附上我踩过的坑。无论你是刚接触并发编程的新手,还是正为线上线程池问题头疼的开发者,都能找到可以直接抄作业的代码和思路。
1. 先搞清楚默认线程池差在哪:核心参数与工作流程
1.1 ThreadPoolExecutor构造参数逐个拆解
先摆出ThreadPoolExecutor那七个参数,这是理解线程池一切行为的地基:
new ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 非核心线程空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 )逐个说清楚:
- corePoolSize:哪怕线程空闲,也保底保留的线程数量。任务提交时,如果当前线程数少于这个值,就直接新建线程执行,而不是排队。
- maximumPoolSize:线程数的上限。这个上限何时触发并不是很多人想的那样"队列满了才扩容",准确说是"队列满了、且线程数还没达到最大值"时才会再建线程。注意这里的先后顺序非常容易搞错,后面专门讲。
- keepAliveTime:超过corePoolSize的线程(即非核心线程)空闲多久会被回收。默认情况下,核心线程不会被回收,除非调用
allowCoreThreadTimeOut(true)。 - workQueue:存放还没被执行的任务。线程池的工作机制,就是拿线程数去匹配任务流量,队列是中间的缓冲带。
- threadFactory:线程池创建新线程时使用的工厂。很多人会忽略它,但它决定了你以后排查问题的时候,
jstack里看到的是pool-1-thread-1还是order-biz-3。 - handler:队列满了、线程数也满了,再提交任务时触发拒绝策略。后面有专门小节来实测每种策略的行为。
这七个参数不是孤立的,它们共同决定了一个线程池的"脾气"。
1.2 任务调度的完整流程
用一个生活化的比喻来理解线程池的调度逻辑:假设你开了一家快递站,核心员工是那几个固定工(corePoolSize),门口有个大筐装件(workQueue),实在忙不过来时可以雇临时工(maximumPoolSize),筐满了、临时工也请满了,再来快递就不收了(handler)。
但快递站实际的操作顺序和很多人想象的不同,标准流程是这样的:
- 来了一件快递(提交任务),如果当前在岗人数少于固定工数量,立刻安排一个固定工去干。
- 固定工都忙不过来了,就把快递先放进门口的大筐(队列)里等着。
- 筐也装满了,再来件,如果还能招临时工(线程数未到maximumPoolSize),就招人干。
- 临时工都招满了,筐也满着,触发拒绝策略。
这里有一个非常容易踩的误区,很多教程喜欢把流程简化成"核心线程满了→扩容到最大线程→再丢队列",实际恰好相反:队列优先于扩容。也就是说,任务提交的那一刻,系统是先尝试塞进队列,塞不进去才考虑创建新线程,而不是先把所有线程拉满再去排队。理解这个顺序,后面配置参数才有据可依。
1.3 默认ThreadFactory的坑
为什么非要多此一举自定义ThreadFactory?因为Executors.defaultThreadFactory()埋了好几个暗坑。
看看它的实现逻辑:内部维护一个全局的AtomicInteger poolNumber,每创建一个线程池自增一次;再配合每个工厂实例的threadNumber,生成了pool-N-thread-M格式的名字。也就是说,如果你开了五个线程池,里面所有线程都叫pool-1-thread-1、pool-1-thread-2、pool-2-thread-1这种。线上出问题,jstack一把梭拉下来,你根本分不清哪个线程属于哪个业务模块。
更麻烦的是,默认工厂创建的线程都是非daemon线程。daemon线程和非daemon线程的区别,简单说就是前者不会阻止JVM退出。假如你有一个线程池,业务上已经不需要了,但忘了关闭,如果是非daemon线程,JVM可能无法正常退出,线上表现为"kill进程杀不死"。而自定义工厂可以显式把线程设置成daemon,避免这种僵局。
还有两个默认工厂无法满足的点:无法给线程设置统一的优先级(虽然Java里线程优先级并不是强保证,但某些场景下还是有意义);无法给线程注册统一的UncaughtExceptionHandler——线程池里的线程抛出了未捕获异常会导致线程死亡,如果不提前处理,你往往只能在日志里看到"线程什么都没干就莫名消失"。
2. 自定义ThreadFactory:代码怎么写才算合格
2.1 基础实现:线程命名与安全计数
自己写工厂,第一个要解决的就是命名问题。我的习惯是给每个业务线程池一个可读性很强的前缀,比如order-pool、etl-worker,然后把递增的序号拼在后面:
public class BizThreadFactory implements ThreadFactory { private final AtomicInteger threadSeq = new AtomicInteger(1); private final String namePrefix; private final boolean daemon; private final int priority; private final ThreadGroup group; public BizThreadFactory(String namePrefix) { this(namePrefix, false, Thread.NORM_PRIORITY); } public BizThreadFactory(String namePrefix, boolean daemon, int priority) { SecurityManager s = System.getSecurityManager(); this.group = (s != null) ? s.getThreadGroup() : Thread.currentThread().getThreadGroup(); this.namePrefix = "biz-" + namePrefix + "-"; this.daemon = daemon; this.priority = priority; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(group, r, namePrefix + threadSeq.getAndIncrement(), 0); t.setDaemon(daemon); t.setPriority(priority); t.setUncaughtExceptionHandler((thread, throwable) -> System.err.println("线程 [" + thread.getName() + "] 抛出未捕获异常: " + throwable.getMessage())); return t; } }注意几个细节。
第一,为什么计数要用AtomicInteger而不是普通的int?因为ThreadFactory会被ThreadPoolExecutor在内部多线程环境下调用,普通int多线程自增会丢失计数,两个线程拿同一个名字,日志排查时就会看到"两个线程叫同一个名字"的诡异现象。AtomicInteger保证自增的原子性,线程名不会重复。
第二,0这个数字,是直接调用Thread那个可以指定栈大小的构造函数,表示"忽略栈大小"。实际生产环境99%的情况不需要自己指定线程栈大小,用默认值就好。
第三,命名格式我故意设计成biz-前缀-序号,这样线上jstack之后,直接grep "biz-order"就能把所有订单线程拎出来,效率非常直观。
2.2 进阶设计:线程组、守护状态与异常钩子
基础版能干活,但工程上往往需要更多控制力,这里做三点增强。
线程组。ThreadGroup可以把线程归到一个组里统一管理,比如你想批量interrupt某组线程,或者批量查看活跃线程数,有组对象就很方便。创建Thread时不指定组,线程默认归属父线程的组;指定了组后,所有由这个工厂创建出来的线程都是同一个"班级"的,管理起来就顺手多了。从代码层面看,我是通过SecurityManager来获取当前系统线程组,没有权限管理器时就用当前线程的线程组,这个写法兼顾了不同运行环境。
守护状态。这是我认为最有价值的自定义项之一。t.setDaemon(daemon)里我默认给了false,但实际项目里我经常传true。什么时候用daemon?一句话:当这个线程池的生命周期不应该阻碍JVM退出的时候。典型的场景是后台定时任务、日志异步刷写、监控数据上报,这些任务丢失一两次无所谓,JVM要退你就别拦着。
异常钩子。setUncaughtExceptionHandler在工程里几乎是必需品。线程池线程如果在执行run()时抛出RuntimeException,ThreadPoolExecutor本身是感知不到的——这个线程会死掉,线程池会新建一个顶替它,但异常本身没人管。你不提前挂钩子,就等着线上排查"某个任务跑着跑着就没结果了"这种幽灵问题吧。钩子方法里建议最少做两件事:记日志、记指标(比如上报到监控系统)。
3. 完整Demo:线程池加自定义工厂一起落地
3.1 线程池参数配置与关键代码
有了工厂,就可以组装一个完整的线程池了。下面是一个我实际用过的配置模板,场景是"对账任务",任务量一天几十万笔,单笔耗时毫秒级,要求不能丢任务:
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ReconcileThreadPool { // 核心线程数:CPU密集型为主,取CPU核数 + 1 private static final int CORE_POOL_SIZE = Runtime.getRuntime().availableProcessors() + 1; // 最大线程数:允许一定程度的突发流量,但不过度透支 private static final int MAX_POOL_SIZE = CORE_POOL_SIZE * 2; // 非核心线程空闲时间:任务有波峰波谷,60秒是安全线 private static final long KEEP_ALIVE_TIME = 60L; public static ThreadPoolExecutor create() { ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2000), new BizThreadFactory("reconcile", true, Thread.NORM_PRIORITY), new ThreadPoolExecutor.CallerRunsPolicy() ); // 允许核心线程也超时回收,避免低峰期一直占着线程 executor.allowCoreThreadTimeOut(true); return executor; } }为什么选择ArrayBlockingQueue而不是无界队列?我在4.1节会细说,但这里先提到一点:对账任务如果上游故障导致大量积压,无界队列会无节制地吸收任务,最终把堆内存吃满,有界队列反而能让背压尽早暴露出来,触发拒绝策略去保护系统。
CallerRunsPolicy的选择也很关键:任务不能丢,所以当线程池满负荷时,让提交任务的线程自己来执行这个任务。这样虽然会导致提交线程被阻塞,但至少任务不会丢。代价是吞吐量下降,这在金融对账场景是可以接受的。
3.2 任务提交、监控与优雅关闭
线程池拿到手,怎么用得稳,是有讲究的。
提交任务,两种方式:
// 方式一:execute,没有返回值,异常由UncaughtExceptionHandler兜底 reconcileExecutor.execute(() -> processOne(record)); // 方式二:submit,有返回值,通过Future可以拿到执行结果和异常 Future<Boolean> future = reconcileExecutor.submit(() -> processOne(record)); try { Boolean ok = future.get(5, TimeUnit.SECONDS); } catch (TimeoutException e) { // 处理超时 }这两种方式不仅要看业务需求,也要看异常处理的心态,后面5.2节会专门讲一个异常被吞的坑。
优雅关闭,这几个方法的含义必须区分清楚:
// 1. shutdown:不再接收新任务,已提交任务继续执行,返回后线程池不会立即终止 executor.shutdown(); // 2. shutdownNow:中断所有线程,返回未被处理的任务列表 List<Runnable> pendingTasks = executor.shutdownNow(); // 3. awaitTermination:等待线程池在指定时间内完全终止,返回是否终止成功 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { executor.shutdownNow(); // 60秒还没停完,强制中断 }我见过太多线上事故,就是应用在重启时直接裸调shutdownNow(),导致一堆写库到一半的任务被腰斩,数据都不完整。正确的姿势是给一个宽限期,让在跑的任务有时间收尾,然后再强制终止。上面代码里"先shutdown等60秒,超时再shutdownNow"是工程上非常常见的关闭模式。
监控,这个属于进阶习惯。线程池在运行时,有几个关键指标值得定期打印或上报:
// 获取当前线程池状态 int activeCount = executor.getActiveCount(); int corePoolSize = executor.getCorePoolSize(); int maximumPoolSize = executor.getMaximumPoolSize(); long taskCount = executor.getTaskCount(); long completedTaskCount = executor.getCompletedTaskCount(); int queueSize = executor.getQueue().size();我通常会把queueSize和activeCount做成一个简单的JVM监控定时任务。如果队列数值长期居高不下,说明消费速度跟不上生产速度,该扩容了;如果队列一直空、线程常满,说明核心线程数设小了。
4. 队列、拒绝策略与线程数量的工程化选择
4.1 三种队列的适用场景
队列是线程池的注水池,选对了,系统能扛住波动;选错了,轻则性能瓶颈,重则OOM。
LinkedBlockingQueue:这是
Executors.newFixedThreadPool默认使用的无界队列(不传容量时Integer.MAX_VALUE)。如果业务流量平稳,用它很方便;但一旦有个瞬时高峰,任务会无限堆积,最后堆内存爆掉,进程直接挂。我的建议:除非你能100%保证任务量可控,否则不要用无界队列。ArrayBlockingQueue:有界队列,容量固定,到达上限后立刻触发背压机制(就是拒绝策略)。它是工程上最常用、也最稳妥的选择。问题在于,容量设置需要经验:太小了突发流量一来就触发拒绝,太大了容易掩盖消费能力不足的问题。我的经验是,先根据"正常峰值流量的2倍"来估算,保留一定的弹性。
SynchronousQueue:这个队列很特殊,它不存任何任务,提交的任务必须直接交接给一个空闲线程,不然就阻塞或触发拒绝策略。它配合
maximumPoolSize可以做到"没有空闲线程就新建",适合非常短平快的任务,但线程数会随流量波动得厉害,容易频繁创建和销毁线程,反而增加开销。
优先级队列PriorityBlockingQueue也有用武之地,比如希望重要任务先执行时可以用,但要注意队头任务长时间被高优任务卡住会导致低优任务饿死,得配合超时机制。
4.2 四种拒绝策略的实测对比
RejectedExecutionHandler有四个现成的实现,我全部跑过对比测试,直接给结论。
| 策略 | 行为 | 风险 | 适用场景 |
|---|---|---|---|
| AbortPolicy | 直接抛RejectedExecutionException | 调用方不知道会导致业务中断 | 明确要求"满了就失败"的接口,比如查询类接口 |
| CallerRunsPolicy | 提交任务的线程自己执行该任务 | 调用线程阻塞,吞吐下降,但是最保险的降级 | 不想丢任务的场景,比如订单创建、对账 |
| DiscardPolicy | 直接丢弃新提交的任务 | 任务静默丢失,难以发现 | 某些不重要的日常统计任务 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,然后重新提交新任务 | 老任务可能还没执行就丢了 | 适合能容忍丢旧任务的实时数据更新场景 |
实测过程中的一个关键发现是:CallerRunsPolicy虽然在性能上不占优,但它给了"反馈"——调用线程知道自己被卡住了,能感知到系统压力。我在很多分布式系统里看到的最佳实践是:对不可丢失的任务,用自己的持久化加异步重试来兜底,而不是单纯依赖某个现成的拒绝策略。如果你有非常特殊的降级需求,完全可以自己实现RejectedExecutionHandler接口,在拒绝时把任务写到本地磁盘或者消息队列。
4.3 核心线程数的估算
这是我被问得最多的问题,必须给一个可以落地的思考框架。
CPU密集型任务(计算量远大于等待耗时的任务),核心线程数建议设为CPU核数 + 1。加1是防止某个线程偶尔被页缺失等小停顿卡住时,CPU还有可用线程去跑。IO密集型任务(大量等待网络、磁盘操作),可以放宽到CPU核数 * 2,甚至更高,因为IO等待时线程不占CPU。
更理论的计算公式是:
线程数 = CPU核数 / (1 - 阻塞系数)阻塞系数介于0和1之间,纯CPU计算为0,纯IO等待接近1。比如一个任务80%的时间在等待远程服务响应,阻塞系数约0.8,8核机器算出来就是8 / (1 - 0.8) = 40个线程。
但我要泼一盆冷水:公式只能作为起步参考。线程数还受到内存、GC、数据库连接池大小等多重约束,硬套公式优化出来的线程数不一定能跑出理想效果。我常用的策略是:先按公式的80%设置一个保守值,用压测观察CPU利用率、GC频率、队列积压变化,再逐步调整。线上还可以用setCorePoolSize和setMaximumPoolSize动态调整,先扩后缩,每次调整观察10~15分钟。
5. 实战踩坑记录与排查技巧
5.1 线程名混乱:线上故障定位困难
有一次线上某个核心接口变慢,我拿着jstack想看看线程池在干什么,结果一拉线程栈,看到的全是pool-2-thread-7、pool-3-thread-3这样的名字,五个线程池共用类似命名,根本不知道是哪个业务的线程。最后只能根据线程栈里的业务类名去倒推,效率极低。
换成自定义工厂之后,业务前缀直接写在线程名里,比如biz-reconcile-12、biz-order-3,哪个线程卡在哪个环节,一目了然。这种排查效率的提升,就是花十几分钟写个工厂值的。我个人强烈建议:所有线程池,无论大小,一律自定义ThreadFactory。这已经是我团队里的代码规约。
5.2 异常被吞:execute与submit的差异
线程池的异常处理有一个隐蔽的坑:用submit提交任务时,如果任务内部抛出异常,异常不会被UncaughtExceptionHandler捕获,也不会打印日志,它会被封装在Future内部,直到你调用future.get()时才抛出ExecutionException。如果你提交了Future任务但从不调用get(),异常就无声无息地消失了。
我踩过的坑是:某次批量推送任务,中间有几十条失败,但因为一直没人调用get(),异常全部被静默吞掉,最后对账的时候才发现少了几十条数据,回查日志也看不到报错信息。排查了很久才定位到是submit吞异常。
现在的习惯是,要么用execute提交并发场景不需要返回值、只需要统一记录异常的任务;要么用submit后必须显式处理Future.get()的异常,绝不能只提交不消费。这个习惯能帮你躲掉一大批"任务神秘消失"的问题。
5.3 线程泄漏与OOM
线程池里的线程如果因为未捕获异常死亡,线程池会重新创建一个线程顶替,看起来好像没什么问题。但如果你使用了ThreadLocal,灾难就来了——线程复用意味着ThreadLocal里的值不会随着任务结束被清掉,下一次这个线程执行别的任务时,带着上一任任务的数据跑,轻则数据错乱,重则内存泄漏。
举个例子,有个任务在ThreadLocal里存了一次请求的用户信息,下个任务跑的时候没清,直接拿到了上一个用户的身份标识,权限判断就乱了。解决方案就一个核心原则:任务处理完,必须要清掉ThreadLocal,最稳妥的做法是在try-finally里remove。
另一个OOM场景就是无界队列。除非你确认任务量绝对有限,否则永远不要用Executors.newFixedThreadPool那套默认无界队列。哪怕是看起来平稳的后台任务,一个上游接口慢下来,任务就可能堆积到几千万个,堆直接爆炸。
5.4 日常排查命令记录
最后分享几个我常用的排查命令,都是Windows和Linux下最朴素的工具,但真的很救命。
# 查看JVM线程栈,重点看线程名里的业务前缀 jstack <pid> | grep -A 20 "biz-order" # 查看当前进程所有线程情况 top -Hp <pid> # 查看JVM堆内存使用,判断是否有任务积压导致内存飙升 jmap -heap <pid>拿到线程栈后,重点看两个信息:线程状态(RUNNABLE/WAITING/BLOCKED),以及栈顶的业务类名。如果一个线程池里大量线程处于WAITING状态且队列为空,说明线程池闲着太多,核心线程数可以降;如果大量线程RUNNABLE但任务完成速度很慢,再结合GC日志判断,是CPU资源不够还是锁竞争太激烈。
就我个人经验而言,自定义线程池这个demo的工程量不大,但每一个细节都是从线上事故里换来的。把ThreadFactory写进你每一次线程池创建中,把异常处理和优雅关闭形成肌肉记忆,这些看起来"多此一举"的操作,会在某个深夜让你觉得无比庆幸。
最后再分享一个小技巧:如果你用的是Spring框架,可以考虑把线程池注册成Bean,用一个专门的配置类统一管理所有线程池的生命周期。我就是这么做的,项目里所有线程池都集中在同一个配置类里,参数、命名、拒绝策略一目了然,谁改了什么,git diff里看得清清楚楚,比散落在各个业务类里好维护太多。