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 的方便,坑在崩溃那一刻
队列优先于扩线程,生产环境只用有界队列
拒绝不可怕,从不拒绝(无界队列)才可怕
避坑清单
- 线程池绝不能用 Executors 创建——newFixedThreadPool/newSingleThreadExecutor 底层是无界的 LinkedBlockingQueue,任务堆积直到 OOM;newCachedThreadPool 最大线程数是 Integer.MAX_VALUE,线程无限创建
- 必须用 new ThreadPoolExecutor(...) 显式构造,它的七个参数逼着你把"队列多大""线程多少""满了怎么办"这些生死攸关的问题一个个亲手想清楚
- 新任务处理流程最反直觉的一点:核心线程满了之后,任务是【先去填队列、队列填满了才扩线程】,不是先扩线程到 max
- 用了无界队列,队列永远填不满,maximumPoolSize 这个参数就【永远不会生效】——这正是 newFixedThreadPool 把 core 和 max 设成相等的原因
- 生产环境必须用有界队列(ArrayBlockingQueue),无界队列的本质是把"线程池处理不过来"悄悄转化成"内存被无限占用",从不报错直到 OOM
- 有界队列容量宁可设小一点,让它尽早触发拒绝策略把过载暴露出来——队列小配合合理拒绝策略得到的是"会喊疼"的系统
- 拒绝策略不是"出了 bug",而是线程池在正确履职;CallerRunsPolicy 让提交者自己跑任务、形成天然背压,系统过载时自动降速,常是最优解
- DiscardPolicy 静默丢弃任务、没有任何人知道,对订单/支付这类任务等于数据凭空蒸发且无法追溯,几乎永远别用;生产环境应自定义 handler 做告警 + 落库重试
- 线程数不是靠公式算出来的,CPU 密集型核数+1、IO 密集型 2×核数 只是压测的起点;线程数还要和下游资源容量(数据库连接池)匹配
- 线程池里用 ThreadLocal,因为线程会被复用,任务结束必须在 finally 里 remove(),否则下一个任务会读到上一个任务残留的值——可能导致用户身份串味、越权
总结
这一趟把线程池彻底理清的过程,纠正了我一个特别根本、也特别危险的思维定式——我一直默认,一个被官方、被无数教程、被同事的代码反复使用的东西,它的【默认行为】就应该是【安全的】。Executors.newFixedThreadPool(20) 这行代码,JDK 提供它、教程用它、我接手的老代码里到处是它——这么多"权威"都在用,我凭什么怀疑它?于是我连它背后那个队列是有界还是无界,都从没去看过一眼。那次大促的 OOM,就是这个定式结结实实的代价:它教会我一件冷酷的事——【默认值,从来不等于安全值】。一个 API 设计得"方便",和它"安全",根本是两回事。Executors 的方便,方便的只是我敲下那行代码的【那一秒】;而它把"任务满了怎么办"这个本该当场回答的问题,偷偷地、不动声色地,推迟到了——线上服务因 OOM 崩溃的【那一刻】。我省下的那点思考,不是消失了,只是被【利滚利地】记在了账上,等着在最糟糕的时间点,连本带息地找我算账。想通这件事,我才真正读懂了阿里规约那条"强制不许用 Executors"背后的用意:它表面上是在"限制"你,不让你用那个方便的快捷方式;骨子里,它是在【逼你清醒】。它逼着你面对 ThreadPoolExecutor 那七个参数,逼着你为"队列设多大""线程开多少""满了之后这道关怎么把"——这每一个决定系统生死的问题,亲手填上一个【你自己想清楚了的答案】。这个过程是麻烦的,但这份麻烦,恰恰就是"工程"二字的重量。所谓工程素养,我现在的理解是:它不在于你写顺路的时候有多漂亮,而在于你有没有为"不顺路的时候"——队列满了、线程耗尽了、下游挂了——预先准备好一个【确定的、你亲手设计过的】应对方案。线程池那一整套机制,核心线程、队列、扩容、拒绝策略,本质上就是一套【面对过载的、逐级递进的预案】。而无界队列最致命的地方,恰恰是它【取消了这套预案】——它让系统在过载时不报错、不拒绝、不喊疼,只是默默地把问题往内存里咽,一直咽到整个进程猝死。一个"会喊疼"的系统,远比一个"不声不响硬扛到崩"的系统,要健康得多、也专业得多。这件事给我的最终启发,早已超出了线程池本身:此后我再看任何一个框架、任何一个库提供的"默认配置""快捷方法",都会多停三秒,问自己同一个问题——这个默认值,是为了"让我此刻少写两行代码"而设计的,还是为了"让我的系统在最坏情况下活下来"而设计的?这两者一旦冲突,我宁可多写那几行、多想那几个边界,也绝不再把系统的生死,赌在一个我从没看过一眼的默认值上。能跑,从来都只是及格线;想清楚"它什么时候会跑不动、跑不动了又该怎么办",才是这条及格线之上,真正属于工程的部分。
—— 别看了 · 2026