一次大促压测,一个平时跑得好好的 Java 服务在流量爬到峰值前先崩了——不是慢,是直接 OutOfMemoryError 倒下。dump 一拉,堆里塞满了几十万个待处理的任务对象,全卡在一个线程池的队列里。罪魁祸首是一行看起来人畜无害的代码:Executors.newFixedThreadPool(50)。固定 50 个线程没问题,问题是它背后那个没有容量上限的任务队列——任务进得比出得快时,队列就无限往里堆,直到把堆撑爆。那次之后我才真正理解:线程池从来不是 new 出来就完事的工具,它那几个构造参数,每一个都是一道闸,设错任意一道,平时风平浪静,峰值时就给你来一记暴击。
这篇不讲"线程池是什么、有什么好处"这种谁都会背的东西,而是反过来:把 ThreadPoolExecutor 的七个参数当成七个必须由你亲手做的决策来讲清楚,顺带把"为什么阿里规约明令禁止用 Executors 那几个工厂方法"这件事彻底说透。看完你应该能做到:再也不用 Executors.newXxx 一把梭,而是面对每一个池子,都能解释清楚它的核心线程数、队列、拒绝策略为什么是现在这个值。
先把七个参数和四种拒绝策略摆上台面
ThreadPoolExecutor 那个最全的构造函数有七个参数,很多人只填前两个就糊弄过去了。但真正决定一个池子在高压下是优雅降级还是直接崩盘的,恰恰是后面那几个被忽略的。先把它们和四种内置拒绝策略一次性摆清楚:
| 参数 / 策略 | 含义 | 设错的后果 |
|---|---|---|
corePoolSize |
核心线程数,常驻不回收 | 太小→任务排队;太大→空跑浪费 |
maximumPoolSize |
最大线程数(含核心) | 配无界队列时永远到不了,等于摆设 |
keepAliveTime |
非核心线程空闲多久后回收 | 太短→频繁创建销毁;太长→占资源 |
workQueue |
任务排队的队列 | 用无界队列→OOM 的头号元凶 |
threadFactory |
创建线程的工厂 | 不自定义→线程名全是 pool-1-thread-x,出事难查 |
handler |
队列满+线程满时的拒绝策略 | 不设→默认抛异常,常被忽略导致丢任务 |
| AbortPolicy(默认) | 直接抛 RejectedExecutionException |
不接住就丢任务且报错 |
| CallerRunsPolicy | 让提交任务的线程自己跑 | 能自然限流,但会阻塞调用方 |
| DiscardPolicy | 悄悄丢弃新任务 | 静默丢任务,最危险 |
| DiscardOldestPolicy | 丢掉队列里最老的,塞进新的 | 丢的是排最久的任务 |
这张表里藏着一条主线:线程池处理任务的顺序是"核心线程 → 队列 → 非核心线程 → 拒绝策略",而不是很多人以为的"先把线程开到最大再排队"。理解这个顺序,是看懂后面所有坑的前提——尤其是它直接解释了"为什么用无界队列时,maximumPoolSize 形同虚设"。下面就从这个最致命的坑说起。
第一件事:别用 Executors 的工厂方法,它们藏着 OOM 地雷
开头那次事故的直接原因,就是 Executors.newFixedThreadPool。问题不在"固定线程数"这个概念,而在它内部塞给你的那个队列。把它的源码摊开看,雷在哪一目了然:
// ❌ Executors 的两类工厂方法,各埋了一颗 OOM 地雷
// 雷一:FixedThreadPool / SingleThreadExecutor —— 用无界队列
public static ExecutorService newFixedThreadPool(int n) {
return new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // 默认容量 Integer.MAX_VALUE,几乎无界
}
// 后果:任务来得比处理快时,队列无限堆积 → 堆内存被撑爆 → OOM
// 雷二:CachedThreadPool —— 线程数无界
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()); // 不排队,来一个任务就开一个线程
}
// 后果:瞬时高并发时,线程数能冲到几万 → 每个线程默认 1MB 栈 → 直接 OOM
看懂了吗?这两类工厂方法,一个把队列放成了无界(任务无限堆),一个把线程数放成了无界(线程无限开),殊途同归都是 OOM。所以正确的姿势是永远自己 new ThreadPoolExecutor,七个参数全都显式填,尤其是给队列一个明确的容量上限、给拒绝策略一个明确的选择:
// ✅ 手动构造:每个参数都是一次清醒的决策,队列必须有界
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // corePoolSize:常驻核心线程
16, // maximumPoolSize:峰值最多开到 16
60L, TimeUnit.SECONDS, // 非核心线程空闲 60s 回收
new ArrayBlockingQueue<>(200), // ✅ 有界队列!最多积压 200 个任务
new ThreadFactoryBuilder() // 自定义线程名,出事时一眼看出是哪个池
.setNameFormat("order-handler-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()); // ✅ 满了让调用方自己跑,自然限流
这里最关键的两个改动:一是把队列从无界的 LinkedBlockingQueue 换成有界的 ArrayBlockingQueue(200)——这意味着积压到 200 个任务时,池子会触发后续逻辑(开非核心线程、最终走拒绝策略),而不是闷头往堆里塞到死;二是显式选了 CallerRunsPolicy 拒绝策略,队列和线程都满了时,让提交任务的那个线程自己去执行——这会反压到上游,自然地把流量降下来,比起默认的"抛异常丢任务"温和得多。给线程起个有业务含义的名字(order-handler-%d)也是举手之劳却极有价值的事:线上 dump 一打,你能立刻看出是哪个池子的线程卡住了,而不是面对一堆 pool-1-thread-7 干瞪眼。
把任务的流转路径看清楚:核心 → 队列 → 非核心 → 拒绝
前面反复强调线程池的处理顺序,但文字描述总不如一张图直接。一个任务 submit 进来,池子内部到底怎么一步步决定"是开新线程、还是先排队、还是直接拒绝",走一遍这张流程图就彻底清楚了——这也是理解所有参数为什么这么配的总钥匙:
盯着这张图,开头那个"无界队列让 maximumPoolSize 形同虚设"的谜题就解开了:任务入队这一步(D 的"是"分支)只有在队列满了之后,才会走到"新建非核心线程"那一格。可如果队列是无界的,它永远不会满,流程就永远卡在"入队"这一步,根本走不到 F——于是 maximumPoolSize 设成 16 还是 1600 毫无区别,线程数永远只有 core 那么多,多出来的任务全在队列里默默堆积,直到 OOM。所以"有界队列"不只是防 OOM,它更是让 maximumPoolSize 这个参数真正生效的前提。
第二件事:核心线程数到底该设多少,分两种任务来算
"线程池开几个线程合适"是被问得最多的问题,网上流传的公式也最多,但很多人记了公式却不知道为什么——结果 CPU 密集的任务套了 IO 密集的公式,越调越糟。其实只要分清你的任务是CPU 密集还是IO 密集,逻辑就很清晰:
// 拿到 CPU 核数,这是所有公式的基准
int cores = Runtime.getRuntime().availableProcessors();
// ✅ 场景一:CPU 密集型(加密、压缩、复杂计算 —— 线程几乎一直在烧 CPU)
// 线程数 ≈ 核数 + 1。多出的 1 是为了在某个线程偶发缺页中断时,有个备胎顶上,不浪费 CPU
int cpuIntensive = cores + 1;
// ✅ 场景二:IO 密集型(调下游、查数据库、读文件 —— 线程大部分时间在等,不占 CPU)
// 线程数 ≈ 核数 × (1 + 平均等待时间/平均计算时间)
// 直观理解:一个线程在等 IO 时 CPU 是空的,可以让别的线程顶上来用,所以能开远超核数的线程
double waitRatio = 9.0; // 假设等待:计算 = 9:1(典型的远程调用场景)
int ioIntensive = (int) (cores * (1 + waitRatio)); // 8 核 → 约 80 个线程
这两个公式背后是同一个朴素的道理:线程数的上限,取决于"任务有多少时间真正在占用 CPU"。CPU 密集型任务时时刻刻都在烧 CPU,开再多线程也只是让它们互相抢那几个核、徒增上下文切换开销,所以线程数贴着核数走就行;IO 密集型任务大部分时间在干等(等网络、等磁盘),这段时间 CPU 是闲的,完全可以让别的线程顶上来把 CPU 用满,所以线程数可以远超核数。但要强调:这些公式只是给你一个起算点,不是金科玉律。真实系统里一个池子可能混着不同任务,下游的延迟也会波动,最终的值一定要靠压测去校准——公式负责让你不至于离谱,压测负责把它调准。
第三件事:四种拒绝策略,按"任务能不能丢"来选
队列满了、线程也到顶了,新任务该怎么办?这就是拒绝策略要回答的问题,也是最能体现"你到底懂不懂这个池子在干什么"的地方。四种内置策略,选择标准其实就一句话:这个池子里的任务,丢得起还是丢不起?
// 选择拒绝策略的核心:任务的"可丢弃性"和"是否需要反压"
// ① 任务绝不能丢(下单、支付)→ CallerRunsPolicy:让调用方自己执行,顺带反压限流
new ThreadPoolExecutor.CallerRunsPolicy();
// ② 任务不能默默消失,要让上游知道被拒了 → AbortPolicy(默认):抛异常,由调用方接住处理
new ThreadPoolExecutor.AbortPolicy();
// ③ 任务可丢、且无所谓丢哪个(如可重复上报的监控埋点)→ DiscardPolicy:静默丢弃
new ThreadPoolExecutor.DiscardPolicy();
// ④ 新任务比老任务重要(如只关心最新的行情/状态)→ DiscardOldestPolicy:挤掉最老的
new ThreadPoolExecutor.DiscardOldestPolicy();
// ⑤ 内置的不够用?自定义:比如落库待补偿、或转发到降级队列
RejectedExecutionHandler custom = (task, executor) -> {
log.warn("线程池已满,任务转入降级队列: {}", task);
fallbackQueue.offer(task); // 不丢、不阻塞,转交给兜底链路
};
实战里我用得最多的是 CallerRunsPolicy 和自定义策略。CallerRunsPolicy 的精妙之处在于它自带限流:当池子扛不住时,提交任务的线程(通常是接收请求的 Web 线程)被迫停下来亲自执行这个任务,它在这期间就没法接收新请求了——这就天然地把上游流量压了下去,给池子喘息的机会,等任务跑完再恢复。而最该警惕的是 DiscardPolicy:它悄无声息地把任务丢掉,既不报错也不留痕,用在关键链路上就是埋了一颗"任务莫名其妙消失了"的定时炸弹。只有当任务确实可丢、且丢了无伤大雅(比如高频重复的监控上报)时,才考虑它。
第四件事:一池一用,别让不同业务共享同一个线程池
很多项目里全局只有一个"通用线程池",所有异步任务都往里扔——发邮件、调下游、跑报表,全挤在一起。这看着省事,实则埋了两颗雷。第一颗是互相拖累:某个慢任务(比如一个卡住的下游调用)占满了线程,会让同池子里本该秒回的轻量任务一起排队饿死,一个业务的抖动传染给所有业务。第二颗更隐蔽,是线程池死锁——当一个任务在执行过程中,又向同一个池子提交子任务并阻塞等待它的结果时,如果这时池子的线程刚好被这类父任务占满,就没有线程能去执行子任务了,父任务永远等不到结果,整个池子卡死:
// ❌ 线程池死锁:父任务占着线程,又等同池子里的子任务结果
ExecutorService pool = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10));
Future<Integer> f = pool.submit(() -> { // 父任务,占用线程 1
Future<Integer> sub = pool.submit(() -> 42); // 子任务排队等线程
return sub.get(); // ❌ 父任务阻塞在这等子任务,但线程都被父任务占着 → 死锁
});
// ✅ 解法一:按业务隔离,每类任务独立池子,互不抢占、互不传染
ExecutorService emailPool = newPool("email", 4, 100);
ExecutorService rpcPool = newPool("rpc", 16, 200);
ExecutorService reportPool = newPool("report", 2, 50);
// ✅ 解法二:有父子依赖的任务,父任务和子任务必须用不同的池子,打破循环等待
这背后的原则是线程池隔离:把不同重要程度、不同耗时特征、有依赖关系的任务,放进各自独立的池子,就像船舱的水密隔板——某一个舱进水,不会沉掉整条船。核心链路(下单、支付)的池子和边缘链路(发通知、记日志)的池子分开,慢的边缘任务再怎么堆积,也淹不到核心业务。这也是 Hystrix 这类容错框架"线程池隔离"模式的核心思想。代价是池子多了、线程总数上去了,所以隔离的粒度要权衡:按"业务重要性 + 耗时特征"分几个大类,而不是每个方法都搞一个池。
第五件事:线程池要优雅关闭,别让任务半路被砍断
服务重启或下线时,如果直接把进程杀掉,线程池里那些正在执行、或还在队列里排队的任务就被硬生生砍断了——正在写一半的数据、还没提交的事务,全成了脏状态。线程池提供了两个关闭方法,但它们的语义截然不同,用错了照样丢任务:
// shutdown():温和关闭 —— 不再接收新任务,但会把已提交的(执行中+排队中)都跑完
// shutdownNow():强硬关闭 —— 尝试中断正在执行的线程,并返回队列里还没跑的任务列表
// ✅ 标准的优雅关闭姿势:先 shutdown,限时等待,超时了再 shutdownNow 兜底
void gracefulShutdown(ExecutorService pool) {
pool.shutdown(); // 第一步:停止接新任务,让存量任务继续跑
try {
// 第二步:给存量任务一个有限的执行窗口(比如 30 秒)
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
// 第三步:超时还没跑完,强制中断,并拿回没跑的任务做补偿/落库
List<Runnable> dropped = pool.shutdownNow();
log.warn("超时强制关闭,有 {} 个任务未执行", dropped.size());
}
} catch (InterruptedException e) {
pool.shutdownNow(); // 等待本身被中断,也强制关闭
Thread.currentThread().interrupt(); // 别吞掉中断标志,恢复它
}
}
关键在于这套"shutdown → awaitTermination 限时等 → 超时再 shutdownNow"的三段式:既给了正在跑的任务一个体面跑完的机会(避免硬砍),又用超时上限防止个别卡死的任务让关闭过程无限拖延(避免永远关不掉)。最后 shutdownNow 返回的那批"还没来得及执行"的任务别直接丢掉——把它们落库或转交补偿队列,下次启动时接着处理,才算真正没丢任务。在 Spring 里,可以把这套逻辑放进 @PreDestroy 方法,或者直接用 ThreadPoolTaskExecutor 的 setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(...),让容器在销毁 Bean 时自动帮你优雅关闭。
一张图把"我该怎么配这个池子"理顺
前面五件事拆得细,真到要给一个新池子下参数时,脑子里该有一条清晰的决策路径。下次面对"我要起个线程池处理 XX",照这棵树从上往下问一遍,每个参数的值自然就落定了:
这棵树的顺序本身就是一套优先级:先想隔离(要不要独立池),再定线程数(看任务性质),接着锁死队列必须有界,然后按"任务丢不丢得起"选拒绝策略,最后别忘了压测校准和优雅关闭。每一步都对应着前面踩过的一个坑。把这条路走顺了,你对任何一个池子的配置,都能一句句解释清楚"为什么是这个值",而不是抄一个 Executors.newFixedThreadPool 了事。
收口成几条评审时逐条对照的铁律
- 禁用
Executors工厂方法:一律手动new ThreadPoolExecutor,七个参数显式填写;newFixedThreadPool/newCachedThreadPool的无界队列和无界线程数是 OOM 的直接来源。 - 队列必须有界:用
ArrayBlockingQueue(N)这类有容量上限的队列;有界队列既防 OOM,又是让maximumPoolSize真正生效的前提。 - 显式选拒绝策略:按"任务丢不丢得起"选,关键链路用
CallerRunsPolicy或自定义落库;绝不放任默认、更不能用DiscardPolicy静默吞关键任务。 - 线程数按任务性质算:CPU 密集贴着核数(核数+1),IO 密集放大到核数×(1+等待/计算);公式只是起点,最终靠压测定值。
- 一池一用、按业务隔离:核心链路和边缘链路分池,有父子依赖的任务用不同池,避免互相拖累和线程池死锁。
- 线程必须有可读名字:用
ThreadFactory给线程起带业务前缀的名字,线上 dump、jstack 时能一眼定位是哪个池。 - 优雅关闭三段式:
shutdown→ 限时awaitTermination→ 超时shutdownNow,并把未执行的任务落库补偿,别让重启砍断任务。 - 给线程池上监控:定期采集
getActiveCount/getQueue().size/getCompletedTaskCount,队列水位和活跃线程数持续高位就该预警,别等 OOM 才发现。
几个特别容易踩的认知误区
这套规范推下去,有几个误区几乎人人都中过,值得专门拎出来说。
第一个、也是最致命的:"newFixedThreadPool 不是限制了线程数吗,怎么还会 OOM?" 这是把"线程数有界"误当成了"内存有界"。固定线程数确实限制住了线程,但它配的那个 LinkedBlockingQueue 是几乎无界的——线程忙不过来时,任务不是被拒绝,而是源源不断地塞进这个无底洞队列里,每个任务对象都占着堆内存,堆出来的不是线程而是任务。OOM 的来源是队列里堆积的任务对象,不是线程本身。认清这一点,你就明白为什么"有界队列"是不可妥协的底线。
第二个误区:"线程开得越多,处理得越快。" 线程不是免费的:每个线程要占一块栈内存(默认约 1MB),更要命的是线程多了之后,CPU 大量时间花在上下文切换上——保存一个线程的现场、加载另一个的现场,这些都是纯开销。对 CPU 密集任务,线程数超过核数后,多出来的线程只会互相抢核、拖慢整体;对 IO 密集任务虽然能多开,但也有上限,开到下游连接池、数据库连接数扛不住时,瓶颈就转移了。线程数有一个甜点区,过了那个点,加线程是负优化。
第三个误区:"shutdown() 会立刻停掉线程池。" 恰恰相反,shutdown() 是温和的——它只是不再接收新任务,已经提交的(包括还在队列里排队的)会全部执行完。真正"尝试立刻停"的是 shutdownNow(),它会中断正在跑的线程、并把没跑的任务退还给你。把这两个搞反,要么以为调了 shutdown 任务就没了(其实还在跑),要么用 shutdownNow 把本该跑完的任务硬砍断。正确姿势永远是两者配合的三段式关闭。
第四个误区:"全局一个线程池最省事,到处复用就行。" 省事是真省事,出事也是真出事。共享池子意味着所有业务的命运被绑在一起:一个慢任务能拖垮全部,一个父子依赖能引发死锁,一处抖动全线传染。线程池的隔离成本(多几个池、多点线程)远低于它换来的稳定性收益。"一池走天下"省下的是几行代码,赔进去的可能是整个服务的可用性。
写在最后
回到开头那次压测崩盘。后来我们把那个 Executors.newFixedThreadPool(50) 换成了手写的、队列有界、拒绝策略选 CallerRunsPolicy、线程带业务名、还挂了监控的池子。再压测,峰值时队列水位涨上去、CallerRunsPolicy 自然把上游压住,服务稳稳扛住了流量,没有再倒。改动其实不大,难的是把每一个参数为什么这么设想清楚这件事。
线程池给我最大的教训是:真正咬人的从来不是那些你看得见、想得到的复杂逻辑,而是 Executors.newXxx 这种"看起来已经帮你配好了"的便利封装。它把那几个最关键的决策(队列多大、满了怎么办、线程怎么伺候)悄悄替你做了,而且做的还是最危险的那个默认值。所以面对线程池,与其图省事一把梭,不如老老实实把七个参数一个个填过去——每填一个,就是逼自己回答一个"这个池子在高压下会怎么表现"的问题。这些问题,你不在写代码时回答,峰值流量就会替你回答,而它给的答案,往往是一次 OOM、一次雪崩、或者一个深夜的告警电话。
—— 别看了 · 2026