Java 线程池完全指南:从一次线上 OOM 看懂七个核心参数怎么配

2022 年我维护一个电商订单处理服务,某次大促流量上来十几分钟服务就 OOM 崩了,重启又崩。导出 heap dump 一看堆里躺着几十万个待执行任务对象全堆在同一个队列里,顺着追到一行没多想的代码 Executors.newFixedThreadPool(20)——它背后是一个没有容量上限的队列,20 个线程处理不过来潮水般的订单任务被这个无底洞照单全收直到把堆内存塞爆。那一刻才意识到我从来没真正搞懂过线程池,只会 new 一个出来用却从没问过它满了会怎样。梳理:别用 Executors,newFixedThreadPool/newSingleThreadExecutor 底层是无界 LinkedBlockingQueue 任务堆到 OOM、newCachedThreadPool 最大线程数 Integer.MAX_VALUE 线程无限创建,必须 new ThreadPoolExecutor 自己造。七个核心参数 corePoolSize/maximumPoolSize/keepAliveTime/unit/workQueue/threadFactory/handler;新任务处理流程①线程数小于 core 新建核心线程②核心满进队列排队③队列也满新建非核心线程到 max④都满触发拒绝策略——最反直觉的是队列优先于扩线程,用了无界队列队列永远填不满 maximumPoolSize 永不生效。队列怎么选是一条生死线,生产环境必须用有界队列 ArrayBlockingQueue,无界队列的本质是把"处理不过来"悄悄转化成"内存被无限占用"从不报错直到 OOM。四种拒绝策略 AbortPolicy 抛异常是默认/CallerRunsPolicy 让提交者自己跑形成天然背压常是最优解/DiscardPolicy 静默丢弃几乎永远别用/DiscardOldestPolicy 丢队头,生产常自定义 handler 做告警加落库重试。线程数 CPU 密集型核数+1、IO 密集型 2×核数起步最终靠压测定。工程坑:监控盯 getQueue().size 是过载最早信号、优雅关闭 shutdown 加 awaitTermination、自定义 ThreadFactory 给线程命名、ThreadLocal 因线程复用任务结束必须 finally 里 remove。默认值不等于安全值,Executors 的方便把"满了怎么办"推迟到了崩溃那一刻。

2022 年,我维护一个电商的订单处理服务。某次大促,流量冲上来没多久,服务就直接 OOM 崩了。我把它重启,十几分钟后,又崩。我导出崩溃前的 heap dump,用工具一看,愣住了:堆里躺着几十万个待执行的任务对象,密密麻麻,全都堆在【同一个队列】里。我顺着这个队列往上追,追到了一行我写下时根本没多想的代码——Executors.newFixedThreadPool(20)。在我当时的认知里,线程池这东西很"智能":线程忙不过来时,它要么让新任务排会儿队,要么干脆把任务"拒绝"掉,总之不会出什么大事。可真相把我这个想当然击得粉碎:我用的这个线程池,背后是一个【没有容量上限】的队列。大促那天,20 个线程处理不过来,潮水般涌入的订单任务,就全被这个"无底洞"队列照单全收——一个、一万个、十万个……它从不说"不",直到把整个堆内存塞爆。那一刻我才意识到,我从来就没真正搞懂过线程池。我只会"new 一个出来用",却从没问过:它的任务满了会怎样?它到底有几个参数?每个参数错配一档,会引发什么样的事故?这件事逼着我把线程池为什么不能用 Executors 创建、它那七个核心参数到底怎么联动、队列和拒绝策略该怎么选、线程数到底设多少,彻底理清了一遍。本文是这份梳理的完整复盘。

问题背景:一行"人畜无害"的代码引发的反复 OOM

背景:一个电商订单处理服务,大促时反复 OOM 崩溃
排查过程:
- ★ 大促流量上来十几分钟,服务 OOM;重启,十几分钟,又崩
- ★ 导出 heap dump:堆里几十万个待执行任务对象,
     全堆在【同一个队列】里
- ★★ 顺着队列追到根源:一行 Executors.newFixedThreadPool(20)
     —— 它背后是一个【没有容量上限】的队列

我当时的错误认知:
- ★ 以为线程池"很智能":忙不过来会让任务排会儿队,
     或者干脆"拒绝"掉,总之不会出大事
- ★★ 真相:这个线程池的队列【无界】,20 个线程处理
     不过来,潮水般的任务被队列照单全收 ——
     一个、一万个、十万个……直到把堆内存塞爆

★★ 我从没真正搞懂过线程池:只会"new 一个出来用",
   从没问过它满了会怎样、有几个参数、错配会出什么事故。

★ 本文要做的:把为什么别用 Executors、七个核心参数怎么
  联动、队列和拒绝策略怎么选、线程数设多少,彻底讲透。

为什么别用 Executors:那几个工厂方法藏着"暗雷"

# === ★ 几乎每个人学线程池,都是从 Executors 开始的 ===

# === ★ Executors 是个"工厂",但它的产品有暗雷 ===
# ★ ★ Executors 提供了几个静态方法,一行就能造出一个
#   线程池 —— newFixedThreadPool、newCachedThreadPool、
#   newSingleThreadExecutor、newScheduledThreadPool。
# ★ ★ 它们看着方便,但每一个背后,都藏着一个【会要命】
#   的默认配置。

# === ★★ 暗雷一:Fixed / Single —— 无界队列 ===
# ★ ★ newFixedThreadPool 和 newSingleThreadExecutor,用的
#   任务队列是 LinkedBlockingQueue,而且【不传容量】。
# ★ ★★ 不传容量的 LinkedBlockingQueue,容量是
#   Integer.MAX_VALUE —— 约 21 亿,等于【无界】。线程忙
#   不过来时,任务就往这个"无底洞"里无限堆积,堆到把
#   内存撑爆 —— 这就是我那次 OOM 的根因。

# === ★★ 暗雷二:Cached —— 无界线程数 ===
# ★ ★ newCachedThreadPool 的最大线程数,是
#   Integer.MAX_VALUE。任务一多,它会【无限地创建新
#   线程】。线程不是免费的,每个都吃内存、吃栈空间 ——
#   线程开太多,一样会把系统拖垮、OOM。

# === ★ 结论:必须用 new ThreadPoolExecutor(...) 自己造 ===
# ★ ★ 阿里巴巴 Java 开发规约里,有一条明确的强制规定:
#   线程池【不允许】用 Executors 创建,必须通过
#   new ThreadPoolExecutor(...) 的方式。
# ★ ★ 这条规定的用意,不是"麻烦你",而是【逼你清醒】——
#   它有七个参数,逼着你把"队列多大""线程多少""满了
#   怎么办"这些生死攸关的问题,一个一个亲手想一遍、填
#   一遍。Executors 的"方便",方便的是写代码的那一刻,
#   坑的是线上崩溃的那一刻。

# === 小结 ===
# ★ 几乎每个人学线程池都从 Executors 开始,它提供几个静态
#   方法一行造出线程池,但每个背后都藏着会要命的默认配置。
# ★★ 暗雷一:newFixedThreadPool 和 newSingleThreadExecutor
#   用的队列是不传容量的 LinkedBlockingQueue,容量是
#   Integer.MAX_VALUE 约 21 亿等于无界,线程忙不过来任务就
#   往无底洞无限堆积堆到内存撑爆。★★ 暗雷二:
#   newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,
#   任务一多无限创建新线程,线程吃内存吃栈空间开太多一样
#   把系统拖垮。★ 结论:必须用 new ThreadPoolExecutor(...)
#   自己造,阿里规约明确强制规定线程池不允许用 Executors
#   创建 —— 用意是逼你清醒,七个参数逼你把队列多大、线程
#   多少、满了怎么办这些生死攸关的问题一个个亲手想一遍;
#   Executors 方便的是写代码那一刻,坑的是线上崩溃那一刻。
// ★ 反例:这行代码"人畜无害",却是一颗定时炸弹
ExecutorService pool = Executors.newFixedThreadPool(20);

// ★★ 翻开它的 JDK 源码,真相是:
//   new ThreadPoolExecutor(20, 20, 0L, TimeUnit.MILLISECONDS,
//                          new LinkedBlockingQueue<Runnable>());
// ★★ LinkedBlockingQueue 不传容量 —— 它的容量就是
//    Integer.MAX_VALUE(约 21 亿),这等于一个【无界队列】。

// ★ 后果推演:20 个线程都在忙,新任务来了往哪去?
//   -> 全部往这个"无界"队列里堆。堆,堆,堆 ——
//   任务对象越积越多,直到把堆内存撑爆,OOM。

// === Executors 四个工厂方法,各自的"暗雷" ===
// newFixedThreadPool / newSingleThreadExecutor
//   -> 用【无界队列】LinkedBlockingQueue,任务无限堆积 -> OOM
// newCachedThreadPool
//   -> 最大线程数是 Integer.MAX_VALUE,线程无限创建 -> OOM
// newScheduledThreadPool
//   -> 同样使用无界的延迟队列

// ★★ 正解:任何一个,都该换成显式构造的 ThreadPoolExecutor
//    —— 下一节就来逐个看清那七个参数。

七个核心参数:看懂线程池到底是怎么转的

# === ★ ThreadPoolExecutor 的七个参数,不是七个孤立旋钮 ===

# === ★ 先把这七个参数列清楚 ===
#  - ★ corePoolSize:核心线程数 —— 池子"常驻"的线程数;
#  - ★ maximumPoolSize:最大线程数 —— 池子"最多"能有
#    多少个线程;
#  - ★ keepAliveTime:非核心线程"闲置多久"就被回收;
#  - ★ unit:keepAliveTime 的时间单位;
#  - ★ workQueue:任务排队用的阻塞队列;
#  - ★ threadFactory:创建线程的工厂(可定制线程名等);
#  - ★ handler:拒绝策略 —— 实在塞不下了,怎么办。

# === ★★ 关键:一个新任务进来,线程池怎么处理它 ===
# ★ ★ 这套流程,是线程池的"心脏",必须背下来:
#  - ① 当前线程数 < corePoolSize?-> 直接【新建一个核心
#    线程】来执行它(哪怕此刻有别的线程是空闲的);
#  - ② 核心线程满了 -> 把任务【放进 workQueue 排队】;
#  - ③ 队列也满了 -> 才【新建非核心线程】来执行(直到
#    线程总数达到 maximumPoolSize);
#  - ④ 线程到 max 了、队列也满了 -> 触发【拒绝策略】。

# === ★★ 最反直觉的一点:队列"优先于"扩线程 ===
# ★ ★ 很多人想当然:任务多了,线程池会先把线程加到 max,
#   实在不够再排队。★★ 错!真实顺序是反的:核心线程满了
#   之后,任务是【先去填队列】,队列填【满】了,才会去
#   加线程。
# ★ ★★ 这个顺序带来一个要命的推论:如果你用了【无界
#   队列】,那么队列【永远填不满】 —— maximumPoolSize
#   这个参数,就【永远不会生效】。这正是 newFixedThreadPool
#   把 core 和 max 设成相等的原因:反正 max 也用不上。

# === ★ keepAliveTime:针对的是"非核心线程" ===
# ★ ★ 当线程数超过 core(即创建了非核心线程),这些
#   "临时工"线程一旦空闲超过 keepAliveTime,就会被回收,
#   让线程数缩回 core。核心线程默认【不会】被回收。

# === 小结 ===
# ★ ThreadPoolExecutor 七个参数不是孤立旋钮:corePoolSize
#   核心(常驻)线程数、maximumPoolSize 最大线程数、
#   keepAliveTime 非核心线程闲置多久被回收、unit 时间单位、
#   workQueue 排队队列、threadFactory 线程工厂、handler
#   拒绝策略。★★ 一个新任务进来的处理流程是线程池的心脏
#   必须背下来:① 线程数小于 core 就直接新建核心线程执行
#   (哪怕有线程空闲);② 核心满了把任务放进 workQueue
#   排队;③ 队列也满了才新建非核心线程直到 max;④ 线程
#   到 max、队列也满就触发拒绝策略。★★ 最反直觉的一点:
#   队列优先于扩线程 —— 不是先加线程到 max 再排队,而是
#   核心满了先填队列、队列填满了才加线程;要命推论:用了
#   无界队列队列永远填不满,maximumPoolSize 就永远不生效,
#   这正是 newFixedThreadPool 把 core 和 max 设成相等的
#   原因。★ keepAliveTime 针对非核心线程:超过 core 创建
#   的临时工线程空闲超时就被回收缩回 core,核心线程默认
#   不回收。
// ★ 正确姿势:用 new ThreadPoolExecutor 显式配置七个参数
import java.util.concurrent.*;

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8,                              // ★ corePoolSize:常驻 8 个核心线程
        16,                             // ★ maximumPoolSize:最多 16 个线程
        60L, TimeUnit.SECONDS,          // ★ keepAliveTime:非核心线程闲 60s 回收
        new ArrayBlockingQueue<>(200),  // ★★ workQueue:有界队列,容量 200(关键!)
        new NamedThreadFactory("order-pool"),       // ★ threadFactory:给线程起名
        new ThreadPoolExecutor.CallerRunsPolicy()   // ★ handler:拒绝策略
);

// ★★ 一个新任务 submit 进来,线程池的处理顺序(务必记牢):
//   ① 线程数 < 8(core)     -> 直接新建核心线程执行
//   ② 线程数 = 8,核心已满   -> 进 ArrayBlockingQueue 排队
//   ③ 队列 200 个也满了      -> 新建非核心线程(直到 16 个)
//   ④ 线程到 16、队列也满    -> 触发拒绝策略 CallerRunsPolicy
pool.submit(() -> processOrder(orderId));

// ★★ 划重点:因为"队列优先于扩线程",只有当 ArrayBlockingQueue
//    被填满,第 9 个到第 16 个线程才会被创建。如果这里换成
//    无界队列,maximumPoolSize=16 就永远是个摆设。

队列怎么选:有界还是无界,这是一条生死线

# === ★ workQueue 这个参数,直接决定你的服务"死不死" ===

# === ★ 常见的几种 workQueue ===
#  - ★ ArrayBlockingQueue:基于数组的【有界】队列,
#    创建时【必须】指定容量。这是生产环境的首选;
#  - ★ LinkedBlockingQueue:基于链表的队列,【可有界
#    可无界】 —— 传容量就有界,不传就是无界(暗雷所在);
#  - ★ SynchronousQueue:一个【不存储元素】的队列 ——
#    每个 put 必须等一个 take。newCachedThreadPool 用的
#    就是它,所以它任务一来就只能创建新线程;
#  - ★ PriorityBlockingQueue:带优先级的【无界】队列。

# === ★★ 一条铁律:生产环境,必须用【有界】队列 ===
# ★ ★ 为什么?因为队列是用来"缓冲突发流量"的【蓄水池】。
#   蓄水池必须有【堤坝】 —— 有界,就是那道堤坝。
# ★ ★★ 无界队列的本质,是把"线程池处理不过来"这个
#   问题,悄悄地转化成了"内存被无限占用"。它从不报错、
#   从不拒绝,只是默默地把任务堆进内存,直到 OOM ——
#   等于把一个【本可以被优雅处理】的"过载",拖成了一场
#   【整个进程崩溃】的灾难。我那次事故,就是活例子。

# === ★ 有界队列的容量,怎么定 ===
# ★ ★ 容量不是拍脑袋。它取决于:单个任务占多大内存、你
#   能接受多少内存用于缓冲、任务的可接受等待时长。原则:
#   宁可设小一点,让它【尽早触发拒绝策略】,把过载这件事
#   【暴露出来】 —— 也好过设得过大,把内存悄悄吃光。
# ★ ★ 队列设小、配合一个合理的拒绝策略,你得到的是一个
#   "会喊疼"的系统;队列无界,你得到的是一个"不声不响
#   猝死"的系统。

# === 小结 ===
# ★ workQueue 直接决定服务死不死。常见几种:ArrayBlockingQueue
#   基于数组的有界队列创建时必须指定容量是生产首选;
#   LinkedBlockingQueue 基于链表可有界可无界,传容量就有界
#   不传就无界(暗雷所在);SynchronousQueue 不存储元素每个
#   put 必须等一个 take,newCachedThreadPool 用的就是它所以
#   任务一来只能创建新线程;PriorityBlockingQueue 带优先级
#   的无界队列。★★ 一条铁律:生产环境必须用有界队列 ——
#   队列是缓冲突发流量的蓄水池,蓄水池必须有堤坝,有界就是
#   那道堤坝;无界队列的本质是把"线程池处理不过来"悄悄
#   转化成"内存被无限占用",它从不报错从不拒绝只默默把
#   任务堆进内存直到 OOM,等于把一个本可被优雅处理的过载
#   拖成整个进程崩溃的灾难。★ 有界队列容量不是拍脑袋,取决
#   于单任务占多大内存、能接受多少内存缓冲、任务可接受等待
#   时长;原则是宁可设小让它尽早触发拒绝策略把过载暴露出来,
#   也好过设大把内存悄悄吃光 —— 队列设小配合合理拒绝策略
#   得到的是会喊疼的系统,队列无界得到的是不声不响猝死的
#   系统。
// ★ 队列选型:一个对比,看清"有界"和"无界"的生死之别

// === ★★ 无界队列 —— 那颗定时炸弹 ===
// 不传容量,容量 = Integer.MAX_VALUE,任务可以无限堆积
BlockingQueue<Runnable> unbounded = new LinkedBlockingQueue<>();
// 后果:线程处理不过来时,它从不拒绝,只默默吃内存 -> OOM

// === ★ 有界队列 —— 生产环境的正确选择 ===
// 必须显式指定容量,这个容量就是那道"堤坝"
BlockingQueue<Runnable> bounded = new ArrayBlockingQueue<>(200);
// 队列满 200 后,新任务不会再被默默吞下,而是触发拒绝策略
// —— 系统"会喊疼",而不是"不声不响地猝死"

// === ★ LinkedBlockingQueue 也能有界 —— 传容量即可 ===
BlockingQueue<Runnable> linkedBounded = new LinkedBlockingQueue<>(200);

// ★★ 一个判断小技巧:任何 new XxxQueue() 不带数字参数的写法,
//    在 review 线程池代码时都该亮红灯 —— 它极可能就是无界的。

拒绝策略:队列也满了之后,最后这道关怎么把

# === ★ 线程到 max、队列也满了 —— 拒绝策略,是最后一道关 ===

# === ★ 先想清楚:为什么必须有"拒绝"这个动作 ===
# ★ ★ 线程开到了 max,队列也塞满了 —— 这意味着系统【已经
#   过载】。这时再来一个新任务,线程池【必须】对它做点
#   什么,这个"做点什么",就是拒绝策略(handler)。
# ★ ★★ 拒绝,不是"出 bug 了",而是线程池在【正确地履职】
#   —— 它在用一个【你预先定义好】的方式,处理过载。可怕
#   的从来不是"拒绝",而是无界队列那种"从不拒绝"。

# === ★ JDK 内置的四种拒绝策略 ===
# ★ ★ AbortPolicy(默认):直接抛出
#   RejectedExecutionException 异常。任务被丢弃,且调用方
#   会【收到一个异常】,知道"这个任务没被接住"。
# ★ ★ CallerRunsPolicy:不丢弃,也不抛异常 —— 而是让
#   【提交任务的那个线程】,自己把这个任务【同步跑掉】。
# ★ ★ DiscardPolicy:【静默丢弃】这个新任务 —— 不抛异常、
#   不通知任何人。任务就这么【无声无息地没了】。
# ★ ★ DiscardOldestPolicy:丢弃【队列里最老的那个】任务,
#   然后把新任务塞进队列。

# === ★★ 这四种里,CallerRunsPolicy 往往是最优解 ===
# ★ ★ 它有一个极其精妙的作用:【天然的背压(back-pressure)】。
# ★ ★★ 想想看:提交任务的线程,通常就是那个"接收请求、
#   不停往池子里塞活"的线程。一旦它被迫"自己去跑一个
#   任务",它在这段时间里就【没法再接收新请求】了 ——
#   于是,上游的请求被迫【慢了下来】。系统过载时,它不
#   崩、不丢,而是【自动降速】,等线程池缓过劲来。这是
#   一种"过载时自我保护"的优雅姿态。

# === ★ DiscardPolicy:最危险,几乎永远别用 ===
# ★ ★ 它"静默丢弃"—— 任务没了,却没有任何人知道。对于
#   订单、支付这类任务,这等于【数据凭空蒸发】,且【无
#   法追溯】。除非你的任务【真的可丢】(如某些可降级的
#   监控上报),否则别碰它。

# === ★★ 真实生产里,常常要"自定义"拒绝策略 ===
# ★ ★ 四种内置策略,处理方式都比较"硬"。生产环境更常见
#   的做法,是实现 RejectedExecutionHandler 接口,【自己
#   写一个】:被拒的任务,先【打一条 error 日志 + 触发
#   告警】(让人知道"过载了"),再视任务性质 —— 重要的
#   【落库/转存到 MQ 后续重试】,可丢的才丢弃。
# ★ 关键认知:拒绝策略,是你对"系统过载"这件事【预先
#   写好的应对预案】。它不该是个被你忽略的默认值。

# === 小结 ===
# ★ 线程到 max、队列也满了,拒绝策略是最后一道关。必须有
#   "拒绝"这个动作:线程开到 max 队列也满意味着系统已过载,
#   再来新任务线程池必须做点什么 —— 拒绝不是出 bug 而是
#   线程池在正确履职、用你预先定义好的方式处理过载,可怕
#   的从来不是拒绝而是无界队列那种从不拒绝。★★ JDK 内置
#   四种:AbortPolicy 默认 —— 抛 RejectedExecutionException
#   异常调用方知道任务没被接住;CallerRunsPolicy —— 不丢
#   不抛、让提交任务的线程自己同步跑掉;DiscardPolicy ——
#   静默丢弃新任务不抛异常不通知任何人;DiscardOldestPolicy
#   —— 丢队列里最老的再塞新任务进去。★★ CallerRunsPolicy
#   往往是最优解,它有天然的背压作用:提交任务的线程被迫
#   自己跑一个任务期间就没法再接收新请求,上游被迫慢下来
#   —— 系统过载时不崩不丢而是自动降速等线程池缓过劲。
# ★ DiscardPolicy 最危险几乎永远别用,静默丢弃任务没了却
#   没人知道,订单支付这类任务等于数据凭空蒸发且无法追溯。
# ★★ 真实生产常要自定义拒绝策略:实现 RejectedExecutionHandler
#   接口,被拒任务先打 error 日志+触发告警让人知道过载了,
#   再视性质 —— 重要的落库/转存 MQ 后续重试、可丢的才丢;
#   拒绝策略是你对系统过载预先写好的应对预案,不该是个被
#   忽略的默认值。
// ★ 四种内置拒绝策略 —— 都是 ThreadPoolExecutor 的静态内部类
// new ThreadPoolExecutor.AbortPolicy()         // 默认:抛 RejectedExecutionException
// new ThreadPoolExecutor.CallerRunsPolicy()    // ★★ 让提交者自己跑 —— 天然背压
// new ThreadPoolExecutor.DiscardPolicy()       // ★ 静默丢弃 —— 几乎永远别用
// new ThreadPoolExecutor.DiscardOldestPolicy() // 丢队头最老的,塞进新的

import java.util.concurrent.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

// ★★ 生产级做法:自定义一个"会喊疼、还兜底"的拒绝策略
public class AlertingRejectedHandler implements RejectedExecutionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(AlertingRejectedHandler.class);

    @Override
    public void rejectedExecution(Runnable task, ThreadPoolExecutor pool) {
        // ★ 第一步:打 error 日志 + 触发告警 —— 让人【知道】系统过载了
        //   绝不能像 DiscardPolicy 那样"无声无息"
        log.error("线程池过载,任务被拒绝!pool={}, 活跃线程={}, 队列={}, 已完成={}",
                pool, pool.getActiveCount(),
                pool.getQueue().size(), pool.getCompletedTaskCount());
        AlertSender.send("订单线程池过载,已触发拒绝策略");

        // ★★ 第二步:别让任务凭空蒸发 —— 转存到 MQ,留待后续重试
        //   订单这类任务一旦丢了就是数据丢失,必须兜底
        if (task instanceof OrderTask) {
            mqProducer.send("order-retry-queue", ((OrderTask) task).payload());
        }
        // ★ 至此:既"喊了疼"(告警),又"接住了"(转存),没有静默丢弃
    }
}

// === 用自定义 handler 装配线程池 ===
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8, 16, 60L, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(200),
        new NamedThreadFactory("order-pool"),
        new AlertingRejectedHandler());   // ★★ 把"过载预案"显式装上

// ★★ 划重点:拒绝策略不是"异常处理",是"过载预案"。它该在
//    你设计线程池时就被想清楚,而不是用一个默认值糊弄过去。

线程数到底设多少:CPU 密集 vs IO 密集

# === ★ corePoolSize 设几?这是被问得最多、也最容易答错的 ===

# === ★ 先分清你的任务,是哪一种"密集" ===
# ★ ★ CPU 密集型:任务的时间,几乎【全花在 CPU 计算】上 ——
#   如加解密、压缩、复杂运算。它【不怎么等】。
# ★ ★ IO 密集型:任务的大部分时间,都【花在等待】上 ——
#   等数据库返回、等下游 HTTP 接口、等磁盘读写。这段时间,
#   CPU 其实是【闲着】的。绝大多数 Web 后端任务,都属于
#   IO 密集型。

# === ★ 两个经典的"参考公式" ===
# ★ ★ CPU 密集型:线程数 ≈ CPU 核数 + 1。理由:CPU 都在
#   满负荷算,开太多线程,只会带来无谓的【线程上下文切换】
#   开销,反而更慢。加的那个 1,是为了在某个线程偶发缺页
#   中断时,还有个备用的能顶上。
# ★ ★ IO 密集型:线程数可以【远大于】CPU 核数 —— 一个
#   常见的起点是 2 × CPU 核数。理由:线程大部分时间在
#   等 IO、并不占 CPU,那就该多开些线程,让 CPU 在"这个
#   线程等 IO"的间隙,去【伺候别的线程】。

# === ★★ 但是 —— 千万别迷信这两个公式 ===
# ★ ★ 这两个公式,只是给你一个【起点量级】,绝不是精确解。
#   真实的 IO 密集型任务,"等待"占比千差万别:一个任务
#   90% 时间在等,和一个 50% 时间在等,该开的线程数差得
#   远。有个更细的公式:线程数 = 核数 × (1 + 等待时间/计算
#   时间) —— 但"等待/计算"这个比值,你【根本拍不准】。
# ★ ★★ 真正靠谱的方法,只有一个:【压测】。用公式估一个
#   初始值,然后在【接近真实流量】的环境里加压,观察
#   QPS、响应时间、CPU 使用率,反复调,找到那个拐点。
#   线程数是【测出来】的,不是【算出来】的。

# === ★ 还有一个常被忽略的约束:下游的承受力 ===
# ★ ★ 你的线程数,不能只看自己。如果这些线程都在调同一个
#   数据库,而数据库连接池只有 20 个连接 —— 你线程开到
#   100,也只有 20 个能真正干活,其余 80 个在【抢连接】。
# ★ 线程数,要和【下游资源的容量】(连接池、下游限流)
#   匹配。否则你这边开再多,只是把压力堆在下游门口。

# === 认知 ===
# ★ corePoolSize 设几是被问得最多也最容易答错的。先分清
#   任务是哪种密集:CPU 密集型时间几乎全花在 CPU 计算上
#   (加解密/压缩/复杂运算)不怎么等;IO 密集型大部分时间
#   花在等待上(等数据库/等下游 HTTP/等磁盘),这段时间
#   CPU 闲着,绝大多数 Web 后端任务都是 IO 密集型。★ 两个
#   参考公式:CPU 密集型线程数约等于核数+1(CPU 满负荷算、
#   开太多只带来无谓上下文切换,+1 是某线程偶发缺页中断时
#   有备用顶上);IO 密集型可远大于核数、常见起点 2×核数
#   (线程大部分时间等 IO 不占 CPU,多开让 CPU 在间隙伺候
#   别的线程)。★★ 但千万别迷信公式:它只给你起点量级不是
#   精确解,IO 任务等待占比千差万别(90% 在等和 50% 在等
#   该开的线程数差得远);真正靠谱的方法只有压测 —— 用公式
#   估初始值再在接近真实流量的环境加压,观察 QPS/响应时间/
#   CPU 使用率反复调找拐点,线程数是测出来的不是算出来的。
# ★ 还有一个常被忽略的约束:下游的承受力 —— 线程都在调
#   同一个数据库而连接池只有 20 个连接,你线程开到 100 也
#   只有 20 个能干活其余在抢连接;线程数要和下游资源容量
#   (连接池、下游限流)匹配,否则只是把压力堆在下游门口。
// ★ 线程数估算:先按任务类型估个起点,再靠压测调到准
public class PoolSizeEstimator {

    // ★ CPU 核数 —— 一切估算的基准
    private static final int CORES = Runtime.getRuntime().availableProcessors();

    // === ★ CPU 密集型:核数 + 1 ===
    // 加解密、压缩、图像处理这类"埋头算"的任务
    public static int forCpuIntensive() {
        return CORES + 1;
    }

    // === ★ IO 密集型:一个常见起点是 2 × 核数 ===
    // 查库、调下游接口这类"大部分时间在等"的任务
    public static int forIoIntensive() {
        return CORES * 2;
    }

    // === ★★ 更细的公式:核数 × (1 + 等待时间/计算时间) ===
    //    但 waitRatio 你根本拍不准 —— 这个值只是"估个量级"
    public static int byWaitRatio(double waitTimeMs, double computeTimeMs) {
        double ratio = waitTimeMs / computeTimeMs;
        return (int) (CORES * (1 + ratio));
    }

    public static void main(String[] args) {
        System.out.println("CPU 核数        = " + CORES);
        System.out.println("CPU 密集型起点  = " + forCpuIntensive());
        System.out.println("IO  密集型起点  = " + forIoIntensive());
        // ★★ 假设一个任务:等 IO 180ms,真正计算 20ms
        System.out.println("按等待比估算    = " + byWaitRatio(180, 20));

        // ★★ 这些数字,全都只是"压测的起点"。真正的线程数,
        //    要在接近真实流量的环境里加压,看 QPS / RT / CPU
        //    使用率,反复调出来 —— 而且不能超过下游(如 DB
        //    连接池)能承受的并发量。
    }
}

工程坑:监控、优雅关闭、线程命名

# === ★ 参数配对了,还有几件事不做,线程池照样会坑你 ===

# === ★★ 坑 1:线程池"裸奔"—— 没有任何监控 ===
# ★ ★ 一个没被监控的线程池,就是个【黑盒】。它的队列是不是
#   快满了?活跃线程是不是长期顶在 max?你一无所知 —— 直到
#   它触发拒绝、甚至 OOM,你才后知后觉。
# ★ ★ ThreadPoolExecutor 自己就暴露了一组现成的监控方法:
#   getActiveCount()(正在干活的线程数)、getQueue().size()
#   (队列里积压多少)、getPoolSize()(当前线程总数)、
#   getCompletedTaskCount()(累计完成多少)。
# ★ ★★ 把这几个值,定时采集、打点到你的监控系统(如
#   Prometheus)。尤其盯紧【队列积压数】—— 它持续走高,
#   就是过载的【最早期信号】,比等到 OOM 早得多。

# === ★ 坑 2:不优雅关闭 —— 任务执行一半被"腰斩" ===
# ★ ★ 服务要重启/下线时,如果你直接让进程退出,线程池里
#   【正在执行】和【还在队列里排队】的任务,会被【直接
#   丢弃】—— 一笔订单可能就处理一半,数据处于中间态。
# ★ ★ 正确的关闭姿势,分两步:① 先调 shutdown() —— 它
#   【不接收新任务】了,但会【让已提交的任务执行完】;
#   ② 再调 awaitTermination(timeout) —— 给一个最长等待
#   时间,等存量任务跑完。
# ★ ★ shutdown() 和 shutdownNow() 的区别要记牢:前者"温和
#   地等存量跑完",后者"粗暴地中断所有、丢弃队列任务"。
#   优雅关闭,用 shutdown()。

# === ★★ 坑 3:线程不命名 —— 出事时无从排查 ===
# ★ ★ 默认的 threadFactory,造出来的线程名叫
#   pool-1-thread-3 这种 —— 毫无意义。线上 jstack 一打,
#   满屏 pool-N-thread-M,你根本【分不清哪个线程是哪个
#   业务的】。
# ★ ★ 解法:自己实现一个 ThreadFactory,给线程起【有业务
#   含义】的名字,如 order-pool-1、push-pool-2。出问题时
#   一看线程名,就知道是哪个池子、哪类任务。

# === ★ 坑 4:ThreadLocal + 线程池 —— 隐蔽的数据串味 ===
# ★ ★ 线程池的线程是【复用】的。如果你在任务里往
#   ThreadLocal 里塞了东西(如用户身份、traceId),任务
#   结束却【没 remove】—— 这个线程被下一个任务复用时,
#   就会读到【上一个任务残留】的值。
# ★ ★★ 后果可能极其严重:用户 A 的请求,读到了用户 B 残留
#   的身份信息 —— 数据串味、越权。铁律:在线程池里用
#   ThreadLocal,任务结束【必须】在 finally 里 remove()。

# === 认知 ===
# ★ 参数配对了还有几件事不做线程池照样坑你。★★ 坑 1 线程池
#   裸奔没有任何监控:没被监控的线程池就是个黑盒,队列是不是
#   快满了、活跃线程是不是长期顶在 max 你一无所知,直到触发
#   拒绝甚至 OOM 才后知后觉;ThreadPoolExecutor 自己暴露了
#   getActiveCount/getQueue().size/getPoolSize/getCompletedTaskCount,
#   定时采集打点到监控系统,尤其盯紧队列积压数 —— 它持续
#   走高就是过载最早期信号比等 OOM 早得多。★ 坑 2 不优雅
#   关闭任务执行一半被腰斩:服务重启/下线直接让进程退出,
#   正在执行和队列里排队的任务被直接丢弃、一笔订单处理一半
#   数据处于中间态;正确姿势两步 —— 先 shutdown() 不接收新
#   任务但让已提交的执行完,再 awaitTermination(timeout) 给
#   最长等待时间等存量跑完;shutdown 温和等存量跑完、
#   shutdownNow 粗暴中断所有丢弃队列任务,优雅关闭用 shutdown。
# ★★ 坑 3 线程不命名出事无从排查:默认 threadFactory 造的
#   线程名 pool-1-thread-3 毫无意义,jstack 一打满屏分不清
#   哪个线程是哪个业务;自己实现 ThreadFactory 给线程起有
#   业务含义的名字(order-pool-1),出问题一看就知道。★ 坑 4
#   ThreadLocal+线程池隐蔽的数据串味:线程是复用的,任务里
#   往 ThreadLocal 塞了东西(用户身份/traceId)结束没 remove,
#   线程被下个任务复用就读到上个任务残留的值,后果可能极
#   严重 —— 用户 A 读到用户 B 残留的身份信息、数据串味越权;
#   铁律:线程池里用 ThreadLocal 任务结束必须在 finally 里
#   remove()。
// ★ 三件必做的事:监控、命名、优雅关闭
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

// === ★★ 自定义 ThreadFactory:给线程起【有业务含义】的名字 ===
class NamedThreadFactory implements ThreadFactory {
    private final String prefix;
    private final AtomicInteger seq = new AtomicInteger(1);

    NamedThreadFactory(String prefix) { this.prefix = prefix; }

    @Override
    public Thread newThread(Runnable r) {
        // ★ 线程名形如 order-pool-1 —— jstack 一看就知道是谁
        Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement());
        t.setDaemon(false);
        return t;
    }
}

// === ★ 坑 4:任务里用了 ThreadLocal,结束必须 remove ===
void handleTask(Order order) {
    UserContext.set(order.getUserId());   // 往 ThreadLocal 塞了东西
    try {
        processOrder(order);
    } finally {
        // ★★ 线程会被复用 —— 不 remove,下个任务就读到本次残留值
        UserContext.clear();              // 内部调用 threadLocal.remove()
    }
}

// === ★★ 坑 1:定时采集线程池指标,打到监控系统 ===
void monitor(ThreadPoolExecutor pool) {
    int active = pool.getActiveCount();          // 正在干活的线程
    int queued = pool.getQueue().size();         // ★ 队列积压 —— 最该盯的指标
    int poolSize = pool.getPoolSize();           // 当前线程总数
    long done = pool.getCompletedTaskCount();    // 累计完成数
    Metrics.gauge("order_pool.active", active);
    Metrics.gauge("order_pool.queued", queued);  // ★ 它持续走高 = 过载早期信号
    Metrics.gauge("order_pool.size", poolSize);
}

// === ★ 坑 2:优雅关闭 —— shutdown + awaitTermination ===
void gracefulShutdown(ThreadPoolExecutor pool) {
    pool.shutdown();   // ★ 不再接收新任务,但让已提交的任务执行完
    try {
        // ★★ 给 60 秒,等存量任务跑完
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // ★ 超时还没跑完 —— 才动用 shutdownNow 中断剩余任务
            pool.shutdownNow();
        }
    } catch (InterruptedException e) {
        pool.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

命令速查

Java 线程池:七参数 + 一套生死线
=============================================================
为什么别用 Executors
-------------------------------------------------------------
Fixed / Single     底层 LinkedBlockingQueue 无界 -> 任务堆到 OOM
Cached             最大线程数 Integer.MAX_VALUE -> 线程无限创建
结论               必须 new ThreadPoolExecutor(...) 自己造

七个核心参数
-------------------------------------------------------------
corePoolSize       核心(常驻)线程数
maximumPoolSize    最大线程数
keepAliveTime+unit 非核心线程闲置多久被回收
workQueue          任务排队的阻塞队列
threadFactory      创建线程的工厂(给线程命名)
handler            拒绝策略

新任务处理流程(务必背下来)
-------------------------------------------------------------
① 线程数 < core      -> 直接新建核心线程
② 核心满             -> 进 workQueue 排队
③ 队列也满           -> 新建非核心线程(到 max 为止)
④ 线程到 max 队列满  -> 触发拒绝策略
★ 反直觉:队列【优先于】扩线程 —— 用无界队列 max 永不生效

队列 + 拒绝策略
-------------------------------------------------------------
队列铁律           生产环境【必须】用有界队列(ArrayBlockingQueue)
AbortPolicy        默认,抛 RejectedExecutionException
CallerRunsPolicy   ★ 让提交者自己跑,天然背压,常是最优解
DiscardPolicy      静默丢弃 —— 几乎永远别用
自定义 handler     告警 + 落库重试,别让任务凭空蒸发

线程数 + 工程坑
-------------------------------------------------------------
CPU 密集型          核数 + 1
IO  密集型          2 × 核数 起步,最终靠【压测】定
坑1 监控           盯 getQueue().size() —— 过载最早信号
坑2 优雅关闭        shutdown() + awaitTermination()
坑3 线程命名        自定义 ThreadFactory,jstack 才认得出
坑4 ThreadLocal     线程复用,任务结束 finally 里必须 remove()

口诀:默认值不等于安全值 —— Executors 的方便,坑在崩溃那一刻
      队列优先于扩线程,生产环境只用有界队列
      拒绝不可怕,从不拒绝(无界队列)才可怕

避坑清单

  1. 线程池绝不能用 Executors 创建——newFixedThreadPool/newSingleThreadExecutor 底层是无界的 LinkedBlockingQueue,任务堆积直到 OOM;newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,线程无限创建
  2. 必须用 new ThreadPoolExecutor(...) 显式构造,它的七个参数逼着你把"队列多大""线程多少""满了怎么办"这些生死攸关的问题一个个亲手想清楚
  3. 新任务处理流程最反直觉的一点:核心线程满了之后,任务是【先去填队列、队列填满了才扩线程】,不是先扩线程到 max
  4. 用了无界队列,队列永远填不满,maximumPoolSize 这个参数就【永远不会生效】——这正是 newFixedThreadPool 把 core 和 max 设成相等的原因
  5. 生产环境必须用有界队列(ArrayBlockingQueue),无界队列的本质是把"线程池处理不过来"悄悄转化成"内存被无限占用",从不报错直到 OOM
  6. 有界队列容量宁可设小一点,让它尽早触发拒绝策略把过载暴露出来——队列小配合合理拒绝策略得到的是"会喊疼"的系统
  7. 拒绝策略不是"出了 bug",而是线程池在正确履职;CallerRunsPolicy 让提交者自己跑任务、形成天然背压,系统过载时自动降速,常是最优解
  8. DiscardPolicy 静默丢弃任务、没有任何人知道,对订单/支付这类任务等于数据凭空蒸发且无法追溯,几乎永远别用;生产环境应自定义 handler 做告警 + 落库重试
  9. 线程数不是靠公式算出来的,CPU 密集型核数+1、IO 密集型 2×核数 只是压测的起点;线程数还要和下游资源容量(数据库连接池)匹配
  10. 线程池里用 ThreadLocal,因为线程会被复用,任务结束必须在 finally 里 remove(),否则下一个任务会读到上一个任务残留的值——可能导致用户身份串味、越权

总结

这一趟把线程池彻底理清的过程,纠正了我一个特别根本、也特别危险的思维定式——我一直默认,一个被官方、被无数教程、被同事的代码反复使用的东西,它的【默认行为】就应该是【安全的】。Executors.newFixedThreadPool(20) 这行代码,JDK 提供它、教程用它、我接手的老代码里到处是它——这么多"权威"都在用,我凭什么怀疑它?于是我连它背后那个队列是有界还是无界,都从没去看过一眼。那次大促的 OOM,就是这个定式结结实实的代价:它教会我一件冷酷的事——【默认值,从来不等于安全值】。一个 API 设计得"方便",和它"安全",根本是两回事。Executors 的方便,方便的只是我敲下那行代码的【那一秒】;而它把"任务满了怎么办"这个本该当场回答的问题,偷偷地、不动声色地,推迟到了——线上服务因 OOM 崩溃的【那一刻】。我省下的那点思考,不是消失了,只是被【利滚利地】记在了账上,等着在最糟糕的时间点,连本带息地找我算账。想通这件事,我才真正读懂了阿里规约那条"强制不许用 Executors"背后的用意:它表面上是在"限制"你,不让你用那个方便的快捷方式;骨子里,它是在【逼你清醒】。它逼着你面对 ThreadPoolExecutor 那七个参数,逼着你为"队列设多大""线程开多少""满了之后这道关怎么把"——这每一个决定系统生死的问题,亲手填上一个【你自己想清楚了的答案】。这个过程是麻烦的,但这份麻烦,恰恰就是"工程"二字的重量。所谓工程素养,我现在的理解是:它不在于你写顺路的时候有多漂亮,而在于你有没有为"不顺路的时候"——队列满了、线程耗尽了、下游挂了——预先准备好一个【确定的、你亲手设计过的】应对方案。线程池那一整套机制,核心线程、队列、扩容、拒绝策略,本质上就是一套【面对过载的、逐级递进的预案】。而无界队列最致命的地方,恰恰是它【取消了这套预案】——它让系统在过载时不报错、不拒绝、不喊疼,只是默默地把问题往内存里咽,一直咽到整个进程猝死。一个"会喊疼"的系统,远比一个"不声不响硬扛到崩"的系统,要健康得多、也专业得多。这件事给我的最终启发,早已超出了线程池本身:此后我再看任何一个框架、任何一个库提供的"默认配置""快捷方法",都会多停三秒,问自己同一个问题——这个默认值,是为了"让我此刻少写两行代码"而设计的,还是为了"让我的系统在最坏情况下活下来"而设计的?这两者一旦冲突,我宁可多写那几行、多想那几个边界,也绝不再把系统的生死,赌在一个我从没看过一眼的默认值上。能跑,从来都只是及格线;想清楚"它什么时候会跑不动、跑不动了又该怎么办",才是这条及格线之上,真正属于工程的部分。

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

AI Agent 完全指南:从一问一答到会规划、会用工具的智能体

2026-5-21 13:28:07

技术教程

RAG 实战完全指南:为什么你的检索增强问答总是一本正经胡说八道

2026-5-21 13:42:32

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