Java 线程池配置完全指南:从一次"线程池把服务拖到 OOM"看懂为什么不能用 Executors

2023 年我给一个订单服务加异步处理下单成功后要发通知写审计流水更新统计报表这些事都不该卡住下单这条主流程我决定把它们丢给一个线程池在后台异步做第一版我做得很顺手一行 Executors.newFixedThreadPool 拿到一个 10 线程的池子业务里需要异步的地方任务 submit 进去就不管了本地我测了测任务都正常执行完了我心里很笃定线程池嘛就是设一个线程数我把任务交给它它就用那几个线程自动地可靠地一个个执行任务一时多了它会自动排队我 submit 完就不用操心了可等它一上线一串问题冒了出来第一种最先把我打懵大促那天服务的内存一路往上涨涨到最后直接 OutOfMemoryError 崩了第二种最难缠平时偶尔会发现有些异步任务像消失了一样通知没发出去流水没写可日志里一条报错都没有第三种最头疼有一次某个下游对账接口卡住了结果整个线程池的线程全被这种慢任务占满第四种最莫名其妙一次正常的服务重启重启完发现队列里还有几千个没执行的任务全没了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为线程池就是一个设定了线程数的任务执行器可这个认知是错的本文从头梳理为什么随手 new 一个线程池会把服务拖垮四个核心参数如何决定任务的去向无界队列为什么是那个 OOM 真凶队列满了该怎么体面地拒绝以及一些把线程池用扎实要避开的工程坑

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。同样,线程池也有"生死":它有创建,就该有一个体面的死亡——服务要下线时,直接让进程退出,等于让线程池连同它队列里那些没跑完的任务一起暴毙;优雅关闭(shutdownawaitTermination,再 shutdownNow 把残余任务捞出来落库)才是给它一个善终,保住那些在途的任务。把线程池当成一个"需要被监控、需要被正确关闭的运行中组件"来对待,而不是一个"new 完就忘"的工具,你才能真正用稳它。

关键概念速查

概念 说明
线程池 预先持有一组可复用线程,接收任务并调度线程执行,避免频繁创建销毁
ThreadPoolExecutor Java 线程池的标准实现,构造函数暴露全部关键参数
Executors 工厂方法 一行创建线程池的便捷方法,但会隐藏关键决策,生产慎用
corePoolSize 核心线程数,池中常驻的线程数量
maximumPoolSize 最大线程数,只有队列满后才会向它扩容
workQueue 任务队列,核心线程满后任务在此排队,必须用有界队列
无界队列 容量近乎无限的队列,会让任务无限堆积直到内存耗尽
拒绝策略 队列与线程都满后对新任务的处理方式,应做到可见可兜底
线程池隔离 按业务把任务分到不同线程池,防止一个坏任务拖垮全部
优雅关闭 shutdown 后等待在途任务跑完,再处理残余任务,避免重启丢任务

避坑清单

  1. 不要用 Executors 工厂方法建生产线程池:它会隐藏队列、拒绝等关键决策。
  2. 不要用无界队列:任务会无限堆积,最终把内存耗尽导致 OOM。
  3. 不要以为调大 maximumPoolSize 就能扛压:无界队列下它形同虚设。
  4. 不要用默认拒绝策略静默丢任务:拒绝要有日志、有监控、有兜底。
  5. 不要让 submit 的任务异常裸奔:任务内部要用 try-catch 自己兜住。
  6. 不要把所有业务挤在一个池:慢任务会占满线程拖垮快任务。
  7. 不要不给线程命名:出问题时线程 dump 里根本分不清是哪个池。
  8. 不要不监控队列积压:它持续上涨是过载最重要的预警信号。
  9. 不要直接退出进程:要优雅关闭,否则队列里的在途任务会全丢。
  10. 不要 CPU 密集和 IO 密集任务混用一个池:两者合适的线程数完全不同。

总结

回头看第一版那个 Executors.newFixedThreadPool(10) 的线程池,它的崩溃很典型。它不在某一行业务代码,而在一个对线程池的根本误解:以为它是个"设好线程数就能放心 submit"的黑箱执行器,任务多了会自动无限排队,我不用关心队列和失败。真相是,线程池是一台由核心线程数、队列、最大线程数、拒绝策略联动驱动的策略机,而 newFixedThreadPool 用的那个无界队列,让"扩容"和"拒绝"两条后路双双失效,任务只能在一个看不见的队列里无限堆积——大促的流量一来,内存就这么被堆爆了。

而把线程池用对,工程量并不小。它不是"new 一个、submit 进去"那么简单,而是要弃用 Executors 工厂方法、直接面对 ThreadPoolExecutor 的每个参数、要用有界队列给堆积划一条线、要设计一个有日志有兜底的拒绝策略、要在任务内部兜住异常、要按业务把线程池隔离开、要给线程命名、要把队列积压监控起来、还要让线程池能优雅地关闭。一套真正可靠的线程池,是这些环节一个不少地拼起来的。

这件事其实很像一家餐馆的后厨。线程,就是后厨的厨师;任务,就是一张张订单。第一版的想法是"雇 10 个厨师,订单来了他们自然会做,做不过来的就先堆着"。可"堆着"这件事,第一版从没想过堆在哪、能堆多少——它用的是一根无限长的订单夹,新订单永远能夹上去,夹子从不会满。平时客人不多,10 个厨师跟得上,夹子上没几张单,看着风平浪静;可一到饭点高峰,订单进来的速度远超厨师出菜的速度,那根无限长的夹子就越夹越长、越夹越长,直到把整个后厨堆得无处下脚——这就是 OOM。聪明的餐馆会怎么管后厨?第一,订单夹必须是有限长的:夹满了,就该考虑临时叫几个帮厨(扩容到 maximumPoolSize),帮厨也忙不过来、夹子还是满的,就得体面地跟门口的客人说"今天满了,实在接不了"(拒绝策略)——而不是假装还能接,把客人晾在那。第二,说"接不了"这句话,得让经理听见、得记下来,而不是服务员偷偷把订单扔进垃圾桶(别用静默丢弃)。第三,做牛排的灶台和煮面的灶台得分开:煮面的锅卡住了,不能连累做牛排的客人也吃不上饭(业务隔离)。第四,打烊的时候,得等手上正在做的菜做完、上桌,而不是厨师围裙一摘就走人,把半成品全倒掉(优雅关闭)。一家后厨能在高峰期不垮,靠的从来不是"多雇几个厨师",而是把订单夹、帮厨、怎么拒客、灶台怎么分,这一整套都安排明白了。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地测,自己手动点几下、提交几十个任务,10 个线程绰绰有余,队列里压根排不了几个,无界还是有界毫无区别;你测的那几个任务都跑得飞快,既不会抛异常、也不会卡死,慢任务拖垮全池、异常无声消失,你一个都撞不见;你也不会在测试时去重启服务、去观察队列里的任务有没有丢。你会觉得"线程池嘛,new 一个 submit 进去就完事"。真正会把问题撑爆的,是上线后的真实流量:大促、秒杀那种洪峰般的并发,会瞬间把任务的产生速度顶到远超处理速度,你那个无界队列会在几分钟内堆到几十万、把内存吃光;真实的下游服务一定会有抖动、会有超时,会制造出卡死的慢任务,把你那个不隔离的池子占满;真实的发布、扩缩容会频繁地重启服务,把你队列里没跑完的任务一次次丢掉。这些场景,你本地一个都模拟不到。所以如果你正在用线程池处理异步任务,别等服务 OOM 崩了、别等用户投诉"通知怎么没收到",才回头怀疑你那行 Executors.newFixedThreadPool。在写下创建线程池的第一行代码时就想清楚:我的队列有没有界、排满了我怎么体面地拒绝、任务抛了异常我兜不兜得住、慢任务会不会拖垮别的业务、重启时在途任务保不保得住——把"创建一个线程池"和"让这个线程池在真实的洪峰、抖动和重启下依然可靠"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

RAG 检索重排序完全指南:从一次"向量检索答案却总不对"看懂为什么 top-K 不能直接喂模型

2026-5-22 22:57:34

技术教程

LLM Agent 多步规划完全指南:从一次"Agent 绕圈子停不下来"看懂为什么不能让模型自己跑

2026-5-22 23:14:17

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索