2023 年我给一个订单服务加异步处理——下单成功后,要发通知、写审计流水、更新统计报表,这些事都不该卡住下单这条主流程,我决定把它们丢给一个线程池在后台异步做。第一版我做得很顺手:一行 Executors.newFixedThreadPool(10),拿到一个 10 线程的池子,业务里需要异步的地方,任务 submit 进去就不管了。本地我测了测,任务都正常执行完了,我心里很笃定:线程池嘛,就是设一个线程数,我把任务交给它,它就用那几个线程自动地、可靠地一个个执行;任务一时多了它会自动排队,我 submit 完就不用操心了——这线程池稳了。可等它一上线,一串问题冒了出来。第一种最先把我打懵:大促那天,服务的内存一路往上涨,涨到最后直接 OutOfMemoryError 崩了——明明只是一些轻量的异步任务,怎么会把内存吃光。第二种最难缠:平时偶尔会发现,有些异步任务像"消失了"一样——通知没发出去、流水没写,可日志里一条报错都没有,无声无息。第三种最头疼:有一次某个下游对账接口卡住了,结果整个线程池的线程全被这种慢任务占满,连"发通知"这种本该很快的任务也跟着一起卡死,异步处理整个瘫了。第四种最莫名其妙:一次正常的服务重启,重启完发现队列里还有几千个没执行的任务,全没了——它们就跟着旧进程一起凭空消失了。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为线程池就是一个"设定了线程数的任务执行器":我把任务 submit 进去,它就会用那固定几个线程,自动地、可靠地把任务一个一个执行完;任务一时来得多了,它会自动地、无限地帮我排队,我根本不用关心这个队列能排多长、排满了会发生什么,也不用关心某个任务执行失败了、卡死了会怎样——这些线程池都替我兜住了。可这个认知是错的。线程池根本不是一个"你不用操心的黑箱执行器",它是一台由几个互相牵制的参数驱动的"策略机":核心线程数、最大线程数、任务队列、拒绝策略、线程存活时间,这几个参数共同定义了一条精确的处理流程——一个任务进来,到底是立刻新建线程执行、还是进队列排队、还是新建更多线程、还是干脆被拒绝,完全由这几个参数算出来。而 Executors.newFixedThreadPool 这个看似省事的工厂方法,内部用的是一个容量近乎无限的队列。这个无界队列意味着:任务永远能排进队列、队列永远不会满,于是"新建更多线程"和"拒绝任务"这两条后路永远不会被触发,任务只会在这个看不见的队列里无止境地堆积,直到把内存堆爆。所以用线程池,根上不是"new 一个、submit 进去"这两个动作,而是一整套工程:要搞懂那几个参数如何联动、要用有界队列给堆积划一条线、要设计一个体面的拒绝策略、要兜住任务里的异常、要按业务隔离线程池、还要把它的状态监控起来、要让它能优雅地关闭。本文从头梳理:为什么"随手 new 一个线程池"会把服务拖垮,四个核心参数如何决定任务的去向,无界队列为什么是那个 OOM 真凶,队列满了该怎么体面地拒绝,以及一些把线程池用扎实要避开的工程坑。
问题背景
先把线程池这件事说清楚。线程是有成本的:创建、销毁都要开销,数量太多还会拖垮调度。线程池(thread pool)就是为了复用线程而存在的——它预先持有一组线程,你把任务(一个 Runnable)交给它,它从池里挑一个空闲线程来跑这个任务,任务跑完线程不销毁、回到池里等下一个任务。任务一时来得比线程处理得快,多出来的任务就先放进一个队列里排队。Java 里线程池的标准实现是 ThreadPoolExecutor,而 Executors 类提供了几个"一行就能创建"的工厂方法,newFixedThreadPool 就是其中之一——它省事,但它替你做的那些默认选择,恰恰是第一版灾难的根源。
错误认知是:线程池是个黑箱执行器,submit 进去就会被可靠执行,任务多了自动无限排队,我不用关心队列和失败。真相是:线程池是一台参数驱动的策略机,任务的去向由核心线程数、队列、最大线程数、拒绝策略精确决定;newFixedThreadPool 用的无界队列会让任务无限堆积。把这一点摊开,第一版的几类问题就都能解释了:
- 内存涨到 OOM:无界队列让任务无限排队,几十万个待执行任务全堆在队列里,吃光内存。
- 任务无声消失:submit 提交的任务抛了异常,被 Future 包住、无人 get,异常被静默吞掉。
- 慢任务拖垮全池:所有业务挤在一个池子里,一个卡死的慢任务占满全部线程,快任务一起饿死。
- 重启丢任务:没有优雅关闭,队列里没执行的任务随旧进程一起消失。
所以让线程池真正可靠,核心不是"new 一个 submit 进去",而是一整套工程:理解参数联动、用有界队列、设计拒绝策略、兜住异常、隔离业务、监控状态、优雅关闭。下面六节,就从第一版"线程池是个黑箱"的想当然讲起。
一、为什么"随手 new 一个线程池"会把服务拖垮
第一版我创建线程池的代码,只有一行。
// 反面教材:第一版 —— 随手 new 一个固定大小线程池就用
import java.util.concurrent.*;
public class OrderService {
// 看着很省事:一行就拿到一个 10 线程的池子
private final ExecutorService pool = Executors.newFixedThreadPool(10);
public void placeOrder(Order order) {
saveOrder(order); // 下单主流程,同步做
// 发通知、写流水、更新统计 —— 丢给线程池异步做
pool.submit(() -> sendNotify(order));
pool.submit(() -> writeAuditLog(order));
pool.submit(() -> updateStats(order));
}
}
// 本地测,任务都正常跑完了,我就上线了。
// 可大促那天,服务内存一路涨,最后 OutOfMemoryError 崩了 ——
// 几十万个还没执行的任务,全堆在了一个我看不见的队列里。
问题就藏在 Executors.newFixedThreadPool(10) 这一行省事的代码背后。它确实给了我 10 个线程,可它同时还替我做了一个我完全没意识到的选择:它给这个池子配的任务队列,是一个容量近乎无限的队列。平时任务来得不快,10 个线程跟得上,队列里没什么积压,一切看着正常。可大促时,任务的产生速度远远超过 10 个线程的处理速度,处理不完的任务就往队列里塞——而这个队列没有上限,它会一直涨、一直涨,每一个待执行的任务都是一个占着内存的对象,几十万个堆在一起,内存就这么被堆爆了。
这一节要建立的认知是:Executors 那几个"一行创建"的工厂方法,真正危险的地方,不是它们配错了某个参数,而是它们把那些本该由你根据业务来权衡的关键决策,用一个隐藏的默认值替你做掉了,让你误以为"这些决策不存在"。第一版最深的想当然,是把 newFixedThreadPool(10) 理解成"我只需要决定线程数,其余的线程池都安排好了"。可"其余的"里面,藏着一个生死攸关的决策:任务多到线程处理不过来时,多出来的任务该怎么办?这个问题有好几个完全不同的答案——可以排队(那队列排多长?),可以临时多开线程,可以干脆拒绝。这是一个必须结合"这个业务能容忍多少积压、过载时宁愿排队还是宁愿拒绝"来权衡的设计决策。而 newFixedThreadPool 把这个决策,用"配一个无界队列"这个默认值,悄悄替你定了——它替你选了"无限排队"。它没有问你、没有提醒你,你甚至不知道这里有一个决策被做了。于是你以为线程池只有"线程数"一个旋钮,实际上它还有"队列多大""满了怎么办"等好几个旋钮,只是被那个工厂方法藏了起来。所以用线程池的第一课,是丢掉 Executors 那几个省事的工厂方法,改用 ThreadPoolExecutor 的完整构造函数——它会逼着你把每一个参数都显式地写出来、想清楚。而要写清楚这些参数,你得先知道它们是怎么联动的,这就是下一节。
二、四个核心参数:任务进来后到底走哪条路
要掌控线程池,就得直接面对 ThreadPoolExecutor 的构造函数,把每个参数都显式地写出来。
// 直接用 ThreadPoolExecutor,把每个参数都摆到明面上
ExecutorService pool = new ThreadPoolExecutor(
10, // corePoolSize:核心线程数,常驻
50, // maximumPoolSize:最大线程数
60L, TimeUnit.SECONDS, // keepAliveTime:非核心线程空闲多久被回收
new LinkedBlockingQueue<Runnable>(1000), // workQueue:任务队列
new ThreadPoolExecutor.CallerRunsPolicy()); // 队列与线程都满后的拒绝策略
// Executors.newFixedThreadPool(10) 等价于:
// 核心 10、最大也 10、队列是不传容量的 LinkedBlockingQueue
// —— 那个队列的默认容量是 Integer.MAX_VALUE,近乎无限。
// 致命的就在这:队列永远填不满,maximumPoolSize 永远用不上。
这几个参数不是孤立的,它们定义了一条任务进来后必走的判断链。理解这条链,是用好线程池的核心。一个任务 submit 进来,线程池会按这个顺序决策:先看当前线程数有没有到 corePoolSize,没到,就新建一个核心线程来执行它;核心线程已经满了,就尝试把任务放进 workQueue 排队;队列也满了,才看线程数有没有到 maximumPoolSize,没到,就新建非核心线程来执行;线程数也到顶了,才触发拒绝策略。
这一节的认知是:线程池的几个参数,不是几个互相独立的"设置项",而是一条有严格先后顺序的决策链上的几道关卡——你必须把它们当成一个整体来理解,因为任何一个参数的取值,都会改变其它参数"还有没有机会起作用"。第一版的想当然,是把这几个参数看成可以各自孤立去想的旋钮。但它们其实环环相扣。最典型、也最反直觉的一处联动,就是队列和 maximumPoolSize 的关系:很多人以为"任务一多,线程池就会扩容到 maximumPoolSize",于是以为把最大线程数调大就能扛住更多压力。可决策链告诉你,真相是——线程池只有在队列已经放满了之后,才会去新建非核心线程、向 maximumPoolSize 扩容。这意味着:如果你的队列是无界的(像 newFixedThreadPool 那样),队列永远不会满,那么"扩容"这一步就永远不会被触发,你设的 maximumPoolSize 是 50 还是 5000,毫无区别,它形同虚设。同理,拒绝策略只有在"队列满了 且 线程数到顶了"这两个条件同时成立时才会触发——无界队列下,第一个条件永不成立,拒绝策略也就成了一段永远跑不到的死代码。第一版正是栽在这里:它的无界队列,让后面所有的关卡——扩容、拒绝——全部失效了,任务唯一的去处就是那个无限大的队列。所以理解这几个参数,关键不是单独记住每个参数是干什么的,而是记住那条决策链的顺序,以及"上一道关卡的设置,决定了下一道关卡有没有机会登场"。而这条链里最关键的那道关卡,就是队列——下一节专门讲它。
三、任务队列:无界队列是那个 OOM 真凶
上一节已经点出,无界队列让扩容和拒绝双双失效。所以修复第一版,最关键的一步,就是把那个无界队列换成有界队列——给它一个明确的容量上限。
// 正解:给线程池一个"有界"队列,让它在压力下能触底
ThreadPoolExecutor pool = new ThreadPoolExecutor(
10, // 核心线程数
50, // 最大线程数:压力大时能扩到 50
60L, TimeUnit.SECONDS, // 非核心线程空闲 60 秒就回收
new LinkedBlockingQueue<Runnable>(1000), // 有界队列:最多排 1000 个
new ThreadPoolExecutor.CallerRunsPolicy());
// 有界队列把"无限堆积"这条路彻底堵死了:
// 核心 10 个线程满 -> 任务进队列 -> 队列最多排 1000 个;
// 队列也满了 -> 才新建非核心线程,把池子扩到 50;
// 50 个线程还忙不过来、队列依然是满的 -> 才触发拒绝策略。
// 整个系统第一次有了一个明确的"我最多扛这么多"的上限。
有界队列带来的最大改变,不是"少占了点内存",而是让线程池在过载时有了一个底:它会触底、会扩容、最终会拒绝。系统不再是"无声地堆积到崩溃",而是"明确地告诉你它满了"。
这一节的认知是:有界和无界的区别,表面看是"队列能不能被填满",本质上是"这个系统在过载时,会不会给你一个信号"——无界队列是一个永远不会喊疼的系统,它把过载的代价,从'明确的拒绝'偷偷换成了'缓慢的、隐蔽的、最终致命的内存增长'。很多人选无界队列,心里的想法是"这样任务就永远不会丢了,多安全啊"。这个想法把一件危险的事,误当成了一件安全的事。你以为无界队列在"保护任务不丢失",其实它在做的是"无条件地接收一切,无论系统是否还扛得住"。当任务的产生速度持续超过处理速度——这在大促、在突发流量下一定会发生——一个有界队列会很快被填满,然后线程池会扩容、会拒绝,系统会用一种响亮的方式告诉你"我过载了":你会看到拒绝、看到日志、收到告警,你能在系统崩溃前就采取行动。而一个无界队列,在同样的过载下,什么信号都不会给你:它只是默默地、贪婪地接收每一个任务,队列长度从几千涨到几万、几十万,内存占用一点点爬升,监控曲线上看不出任何"出事了"的拐点——直到某一刻,内存彻底耗尽,整个 JVM 带着满载的队列一起 OutOfMemoryError 崩掉,而且崩的时候,队列里那几十万个任务一个都没保住,全丢了。所以"无界队列保护任务不丢"是一个彻头彻尾的幻觉:它非但保护不了任务,反而是用一种最坏的方式——连同整个进程一起——把任务全葬送掉。有界队列才是真正负责任的选择:它承认"系统的处理能力是有限的"这个事实,并在能力的边界上,给你一个清晰的、可以响应的信号。而这个信号一旦响起——队列满了、线程也满了——接下来该怎么办,就是拒绝策略的事了。
四、拒绝策略:队列满了,要体面地拒绝
有了有界队列,"拒绝"这条路终于会被走到了。ThreadPoolExecutor 自带四种拒绝策略,但它们各有各的坑:AbortPolicy(默认)直接抛异常打断调用方;DiscardPolicy 一声不响地把任务丢掉;DiscardOldestPolicy 把队列里最老的任务挤掉;CallerRunsPolicy 让提交任务的那个线程自己去执行它。前两个尤其危险——第一版那个"任务无声消失",一部分原因就是用错了策略。真正稳妥的做法,往往是自定义一个拒绝策略。
// 自定义拒绝策略:别默默丢弃,要记录、要兜底、要告警
RejectedExecutionHandler handler = (Runnable task, ThreadPoolExecutor executor) -> {
// 能走到这里,说明队列满了、线程也到顶了 —— 系统是真的过载了
log.warn("线程池过载,任务被拒绝,当前队列积压={}",
executor.getQueue().size());
metrics.increment("threadpool.rejected"); // 打点,接入告警
// 兜底:把被拒的任务落库,等系统空闲了由补偿任务捞起来重做
saveToRetryTable(task);
};
// 关键不在于"用四种内置策略里的哪一种",而在于:
// "拒绝"这件事本身,必须是看得见的 —— 有日志、有监控、有兜底。
// 默认的 AbortPolicy 会抛异常,DiscardPolicy 会静默吞掉,
// 都不该在你毫不知情的情况下,悄悄地发生。
这一节的认知是:拒绝策略要回答的,从来不是"任务被拒绝时,技术上怎么处理"这个小问题,而是"当系统已经过载、确实有任务做不完时,我希望它以什么样的姿态失败"这个大问题——拒绝是一定会发生的,你唯一能选的,是它发生时'吵不吵'。第一版对拒绝策略的态度,是根本没意识到它的存在(无界队列下它也确实不会触发)。可一旦换上有界队列,你就必须正面回答这个问题:任务被拒,意味着系统在说"这件事我做不了了",这是一个重要的、关于系统健康的信号。这个信号,你是希望它"安静"还是"响亮"?DiscardPolicy 选的是最安静的方式——任务被无声地丢弃,没有日志、没有异常、没有任何痕迹,系统假装什么都没发生。这是最坏的选择:用户的通知没发出去,而你永远不会知道,直到用户来投诉。AbortPolicy 会抛异常,比静默丢弃响亮一点,但它把异常直接甩给了提交任务的业务线程,可能会误伤主流程。CallerRunsPolicy 是一种有意思的"反压"策略:它让提交任务的线程自己去跑这个任务,这会拖慢任务的提交速度,反过来给上游一个"慢下来"的压力,适合那些"宁可慢、不可丢"的场景。而最稳妥的,通常是自定义:被拒绝的任务,要记一条日志、打一个监控点(让告警能响起来)、再尽可能地兜底(比如落库,等系统缓过来再重做)。这样,"拒绝"就从一个被隐藏的事故,变成了一个被看见、被记录、被补偿的、可控的事件。把拒绝策略想成"我希望系统过载时如何体面地说不",而不是"丢任务的几种方式",你才会认真对待它。而除了过载,任务本身也会出问题——那是下一节的事。
五、异常与隔离:别让一个坏任务拖垮整个池
前面解决的都是"任务多了怎么办"。但还有一个更隐蔽的问题:单个任务自己出了毛病——它抛了异常,或者它卡死了。第一版的"任务无声消失"和"慢任务拖垮全池",根子都在这里。先看异常:用 submit 提交的任务,如果在执行中抛了异常,你根本不会知道。
// 坑:submit 提交的任务抛了异常,你根本不会知道
// 反面:任务里抛了异常,被返回的 Future 包住了,
// 没人去 future.get() 取结果,异常就石沉大海
pool.submit(() -> sendNotify(order)); // sendNotify 抛异常?无声无息
// 正解:任务内部自己兜住所有异常,绝不让它逸出任务边界
pool.submit(() -> {
try {
sendNotify(order);
} catch (Exception e) {
log.error("发送通知失败 orderId={}", order.getId(), e);
// 这里还可以做兜底:落库重试、降级处理等
}
});
再看隔离。第一版把所有业务都丢进同一个池子,这埋下了"一损俱损"的隐患。正确的做法是按业务把线程池拆开。先准备一个能给线程命名的构造工具——线程有名字,排查问题时才认得出。
// 给线程池起个名字:排查问题时,你能一眼认出是哪个池
static ExecutorService buildPool(String name, int core, int max, int queueCap) {
java.util.concurrent.atomic.AtomicInteger seq =
new java.util.concurrent.atomic.AtomicInteger();
ThreadFactory factory = (Runnable r) -> {
Thread t = new Thread(r);
t.setName(name + "-pool-" + seq.incrementAndGet()); // 如 notify-pool-3
return t;
};
return new ThreadPoolExecutor(
core, max, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(queueCap),
factory,
new ThreadPoolExecutor.CallerRunsPolicy());
}
// 默认线程名是 pool-1-thread-5 这种,出了问题、看线程 dump
// 你根本分不清是哪个业务。起个名字,日志和 dump 里一目了然。
有了它,就能按"快慢、重要程度"把业务拆进互相隔离的多个线程池。
// 业务隔离:别让所有业务挤在同一个线程池里
// 反面:发通知(快)、调下游对账接口(慢、可能卡死)
// 全用同一个 pool —— 对账接口一卡,10 个线程很快被它占满,
// 连"发通知"这种本该毫秒级的快任务,也跟着一起饿死。
// 正解:按"快慢、重要程度"拆成互相隔离的多个池
ExecutorService notifyPool = buildPool("notify", 10, 50, 1000);
ExecutorService reconcilePool = buildPool("reconcile", 4, 8, 200);
// 现在,对账接口就算彻底卡死,被拖住的也只是 reconcilePool
// 那 8 个线程而已;notifyPool 毫发无伤,通知照发不误 ——
// 故障被牢牢地关在了一个池子里,没能蔓延出去。
这一节的认知是:线程池里的线程,是一种"会被坏任务长期霸占"的稀缺资源——一个抛异常的任务会让你失去对它的感知,而一个卡死的任务会真正地"扣押"一个线程;当多个不相干的业务共用一个池,你其实是让它们在共担彼此的风险。第一版把所有异步任务塞进一个池子,背后的想法是"线程池嘛,一个就够用了,任务都是异步执行,互不影响"。可这个"互不影响"是错觉。线程池的线程数是有限的(就那么 10 个、50 个),它是一种被所有任务争抢的共享资源。这就带来两种风险的传染。一种是异常的"静默":submit 一个任务,它的异常会被装进 Future,如果没有人调 future.get(),这个异常就永远不会浮现——任务失败了,系统却毫不知情,这就是"任务无声消失"。解法是让每个任务在自己内部就用 try-catch 把异常兜死,绝不让它逸出。另一种、也是更致命的,是慢任务的"扣押":一个调用了某个卡住的下游接口的任务,会让正在执行它的那个线程,长时间地无法返回、无法去接新任务——这个线程就被它"扣押"了。如果这种慢任务足够多,它们会把池子里所有线程逐个扣押干净,此时池子里一个空闲线程都没有了,哪怕你提交的是一个只需要 1 毫秒的发通知任务,它也只能排队、饿死。当快任务和慢任务、重要业务和次要业务全挤在一个池子里,慢的、次要的那个一旦出问题,就会通过"占满共享线程"这个途径,把快的、重要的那个一起拖下水。隔离,就是把这个共享资源池按业务拆开:对账接口卡死,卡住的是对账专用的那个池,发通知的池在另一边安然无恙。把线程理解成"会被坏任务霸占的稀缺共享资源",你就懂了为什么必须隔离。而要及时发现"某个池子是不是快被占满了",你得能看见它的状态——那是下一节的事。
把一个任务 submit 进线程池之后,它到底走哪条路、这条决策链画出来,就是下面这张图:
[mermaid]
flowchart TD
A[一个任务 submit 进来] --> B{核心线程数满了吗}
B -->|没满| C[新建一个核心线程执行它]
B -->|已满| D{任务队列满了吗}
D -->|没满| E[任务放进队列排队]
D -->|已满| F{线程数到 maximumPoolSize 了吗}
F -->|没到| G[新建非核心线程执行它]
F -->|已到| H[触发拒绝策略]
六、把线程池用扎实,要避开的工程坑
前面五节讲清了线程池的核心:参数联动、有界队列、拒绝策略、异常与隔离。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,也是最容易被忽略的:线程池的内部状态,必须监控起来,否则它对你就是一个真正的黑箱。
// 坑一:线程池的运行状态必须监控,否则你对它一无所知
ThreadPoolExecutor p = (ThreadPoolExecutor) pool;
log.info("活跃线程={} 池中线程={} 队列积压={} 累计完成={} 累计拒绝={}",
p.getActiveCount(), // 此刻正在执行任务的线程数
p.getPoolSize(), // 此刻池里的线程总数
p.getQueue().size(), // 队列里积压的任务数 —— 最该死盯的指标
p.getCompletedTaskCount(), // 启动至今累计完成的任务数
rejectedCounter.get()); // 启动至今累计被拒绝的任务数
// 队列积压数是最重要的预警信号:它要是持续往上涨,
// 说明任务进得比出得快,系统离过载已经不远了 ——
// 这个数,应该定时采集、画成监控曲线、配上告警阈值。
第二个坑,是关闭。服务重启或下线时,如果不优雅地关闭线程池,队列里没跑完的任务会凭空消失。
// 坑二:服务重启时,别让队列里没跑完的任务凭空消失
void shutdownGracefully(ThreadPoolExecutor pool) {
pool.shutdown(); // 不再接收新任务,但已提交的任务会继续执行
try {
// 给在途任务最多 30 秒把自己跑完
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
// 30 秒还没跑完,强制中断,并把没跑的任务捞出来
java.util.List<Runnable> dropped = pool.shutdownNow();
log.warn("强制关闭,仍有在途任务 {} 个", dropped.size());
persistForRetry(dropped); // 落库,等下次启动后补偿执行
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}
还有几个坑值得点一下。其一,线程数不是越大越好:任务如果是 CPU 密集型(纯计算),线程数设到比 CPU 核数多不了多少就够了,再多只是徒增上下文切换;任务如果是 IO 密集型(大量等待网络、磁盘),线程数才可以设得明显大于核数。两类任务最好别混在一个池里。其二,ThreadLocal 和线程池是一对危险组合:线程池的线程是复用的,上一个任务在 ThreadLocal 里留下的值,会被下一个任务读到,造成数据串掉——用完一定要 remove。其三,核心线程默认是"用到时才创建"的,可以调 prestartAllCoreThreads() 预热,避免服务刚启动、流量打进来时还在慢慢建线程。下面把线程池四个核心参数集中对照一下:
线程池四个核心参数对照
参数 管什么 设错的后果
----------------------------------------------------------------
corePoolSize 常驻的核心线程数 太小吞吐低 太大空耗资源
maximumPoolSize 压力下最多扩到几个线程 无界队列下它形同虚设
workQueue 任务排队的地方 用无界队列会一直堆到 OOM
拒绝策略 队列与线程都满后怎么办 用默认策略会静默丢任务
原则:线程池不是黑箱执行器,是一台参数驱动的策略机;
队列必须有界,拒绝必须可见,业务必须隔离
这一节这几个坑,串起来是同一个意思:线程池不是一个"创建完就可以遗忘"的工具对象,而是一个有自己的内部状态、有自己的生命周期、需要你持续观察和正确收尾的"运行中的系统组件"。第一版把线程池当成了一个一次性的工具:new 出来,塞进一个字段,然后就再也不看它一眼了。可线程池在它被创建之后的整个生命里,都是"活"的:它的队列在涨落、它的线程在忙闲、它在持续地完成任务、也可能在持续地拒绝任务。这些内部状态,如果你不主动去读(getQueue().size() 等方法),它就是一个不透明的黑箱——队列已经积压到一万了你也不知道,直到它崩。监控,就是给这个黑箱开一扇窗,让队列积压、活跃线程这些关键指标变成监控曲线上看得见的线,让"快过载了"成为一个能提前几分钟收到的告警,而不是一次猝不及防的 OOM。同样,线程池也有"生死":它有创建,就该有一个体面的死亡——服务要下线时,直接让进程退出,等于让线程池连同它队列里那些没跑完的任务一起暴毙;优雅关闭(shutdown 后 awaitTermination,再 shutdownNow 把残余任务捞出来落库)才是给它一个善终,保住那些在途的任务。把线程池当成一个"需要被监控、需要被正确关闭的运行中组件"来对待,而不是一个"new 完就忘"的工具,你才能真正用稳它。
关键概念速查
| 概念 | 说明 |
|---|---|
| 线程池 | 预先持有一组可复用线程,接收任务并调度线程执行,避免频繁创建销毁 |
| ThreadPoolExecutor | Java 线程池的标准实现,构造函数暴露全部关键参数 |
| Executors 工厂方法 | 一行创建线程池的便捷方法,但会隐藏关键决策,生产慎用 |
| corePoolSize | 核心线程数,池中常驻的线程数量 |
| maximumPoolSize | 最大线程数,只有队列满后才会向它扩容 |
| workQueue | 任务队列,核心线程满后任务在此排队,必须用有界队列 |
| 无界队列 | 容量近乎无限的队列,会让任务无限堆积直到内存耗尽 |
| 拒绝策略 | 队列与线程都满后对新任务的处理方式,应做到可见可兜底 |
| 线程池隔离 | 按业务把任务分到不同线程池,防止一个坏任务拖垮全部 |
| 优雅关闭 | shutdown 后等待在途任务跑完,再处理残余任务,避免重启丢任务 |
避坑清单
- 不要用 Executors 工厂方法建生产线程池:它会隐藏队列、拒绝等关键决策。
- 不要用无界队列:任务会无限堆积,最终把内存耗尽导致 OOM。
- 不要以为调大 maximumPoolSize 就能扛压:无界队列下它形同虚设。
- 不要用默认拒绝策略静默丢任务:拒绝要有日志、有监控、有兜底。
- 不要让 submit 的任务异常裸奔:任务内部要用 try-catch 自己兜住。
- 不要把所有业务挤在一个池:慢任务会占满线程拖垮快任务。
- 不要不给线程命名:出问题时线程 dump 里根本分不清是哪个池。
- 不要不监控队列积压:它持续上涨是过载最重要的预警信号。
- 不要直接退出进程:要优雅关闭,否则队列里的在途任务会全丢。
- 不要 CPU 密集和 IO 密集任务混用一个池:两者合适的线程数完全不同。
总结
回头看第一版那个 Executors.newFixedThreadPool(10) 的线程池,它的崩溃很典型。它不在某一行业务代码,而在一个对线程池的根本误解:以为它是个"设好线程数就能放心 submit"的黑箱执行器,任务多了会自动无限排队,我不用关心队列和失败。真相是,线程池是一台由核心线程数、队列、最大线程数、拒绝策略联动驱动的策略机,而 newFixedThreadPool 用的那个无界队列,让"扩容"和"拒绝"两条后路双双失效,任务只能在一个看不见的队列里无限堆积——大促的流量一来,内存就这么被堆爆了。
而把线程池用对,工程量并不小。它不是"new 一个、submit 进去"那么简单,而是要弃用 Executors 工厂方法、直接面对 ThreadPoolExecutor 的每个参数、要用有界队列给堆积划一条线、要设计一个有日志有兜底的拒绝策略、要在任务内部兜住异常、要按业务把线程池隔离开、要给线程命名、要把队列积压监控起来、还要让线程池能优雅地关闭。一套真正可靠的线程池,是这些环节一个不少地拼起来的。
这件事其实很像一家餐馆的后厨。线程,就是后厨的厨师;任务,就是一张张订单。第一版的想法是"雇 10 个厨师,订单来了他们自然会做,做不过来的就先堆着"。可"堆着"这件事,第一版从没想过堆在哪、能堆多少——它用的是一根无限长的订单夹,新订单永远能夹上去,夹子从不会满。平时客人不多,10 个厨师跟得上,夹子上没几张单,看着风平浪静;可一到饭点高峰,订单进来的速度远超厨师出菜的速度,那根无限长的夹子就越夹越长、越夹越长,直到把整个后厨堆得无处下脚——这就是 OOM。聪明的餐馆会怎么管后厨?第一,订单夹必须是有限长的:夹满了,就该考虑临时叫几个帮厨(扩容到 maximumPoolSize),帮厨也忙不过来、夹子还是满的,就得体面地跟门口的客人说"今天满了,实在接不了"(拒绝策略)——而不是假装还能接,把客人晾在那。第二,说"接不了"这句话,得让经理听见、得记下来,而不是服务员偷偷把订单扔进垃圾桶(别用静默丢弃)。第三,做牛排的灶台和煮面的灶台得分开:煮面的锅卡住了,不能连累做牛排的客人也吃不上饭(业务隔离)。第四,打烊的时候,得等手上正在做的菜做完、上桌,而不是厨师围裙一摘就走人,把半成品全倒掉(优雅关闭)。一家后厨能在高峰期不垮,靠的从来不是"多雇几个厨师",而是把订单夹、帮厨、怎么拒客、灶台怎么分,这一整套都安排明白了。
这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测,自己手动点几下、提交几十个任务,10 个线程绰绰有余,队列里压根排不了几个,无界还是有界毫无区别;你测的那几个任务都跑得飞快,既不会抛异常、也不会卡死,慢任务拖垮全池、异常无声消失,你一个都撞不见;你也不会在测试时去重启服务、去观察队列里的任务有没有丢。你会觉得"线程池嘛,new 一个 submit 进去就完事"。真正会把问题撑爆的,是上线后的真实流量:大促、秒杀那种洪峰般的并发,会瞬间把任务的产生速度顶到远超处理速度,你那个无界队列会在几分钟内堆到几十万、把内存吃光;真实的下游服务一定会有抖动、会有超时,会制造出卡死的慢任务,把你那个不隔离的池子占满;真实的发布、扩缩容会频繁地重启服务,把你队列里没跑完的任务一次次丢掉。这些场景,你本地一个都模拟不到。所以如果你正在用线程池处理异步任务,别等服务 OOM 崩了、别等用户投诉"通知怎么没收到",才回头怀疑你那行 Executors.newFixedThreadPool。在写下创建线程池的第一行代码时就想清楚:我的队列有没有界、排满了我怎么体面地拒绝、任务抛了异常我兜不兜得住、慢任务会不会拖垮别的业务、重启时在途任务保不保得住——把"创建一个线程池"和"让这个线程池在真实的洪峰、抖动和重启下依然可靠"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。
—— 别看了 · 2026