线程池这三个字,凡是写过点 Java 的都听说过,但说实话,能把原理讲清楚、在生产里用明白的人,真不多。我遇过很多同事,一提到线程池就是简单粗暴地Executors.newFixedThreadPool(10),结果线上任务一多,堆内存先扛不住。也有同学把拒绝策略当成摆设,等任务被默默丢掉才追悔莫及。今天这篇,我就把自己这几年折腾线程池的经验从头到尾捋一遍,从底层原理、核心参数,到常见线程池的选型对比,再到监控和动态调优,尽量讲得实操一点,别让面试题上的知识点变成线上事故的伏笔。
1. 从一次线上故障说起:线程池到底解决了什么问题
1.1 手动创建线程的三宗罪
很久之前参与过一个订单服务,平时跑得好好的,结果某天大促流量一上来,接口响应时间从 100ms 一路飙到 3 秒,CPU 直接打满。查了半天,问题不在 SQL 也不在 Redis,而是代码里每个请求都new Thread(() -> xxx)去处理业务逻辑。
高并发场景下手动创建线程,至少有三个致命问题。
第一,创建线程是有成本的。每创建一个线程,JVM 默认要给它分配大约 1MB 的栈内存,线程创建本身还需要操作系统从用户态切换到内核态,完成资源分配和调度。对核心链路来说,这就像每次做顿饭都要先去买个锅,锅还没热完,菜早凉了。
第二,线程数完全不可控。请求一多,线程数跟着请求数线性增长,几千个线程同时在跑的时候,CPU 大量时间花在线程上下文切换上,真正处理业务的时间所剩无几。操作系统有句老话叫“让 CPU 跑满容易,让 CPU 跑得有意义很难”,线程太多就是反例。
第三,线程生命周期不好管理。手动创建出来的线程没有统一管理者,想优雅地关闭没法做,出异常了也看不到日志,出了问题查起来全凭运气。
1.2 线程池到底“池化”了什么
线程池的核心思想其实和数据库连接池一样,就是一个字:复用。提前把线程创建好放在池子里,有任务进来就交给空闲线程执行,任务跑完线程不销毁,挂在池子里等下一个任务。这样说有点抽象,换个生活化的比喻:线程池就是一家快餐店,服务生(线程)固定那么几个人,客人(任务)来了直接接单,高峰期排队等着,客人太多时门口发个号(拒绝策略),让客人去别家吃。
除了复用,线程池还承担了三件事:限流、缓存、管理。限流,通过核心线程数和最大线程数限制同时运行的线程数量,防止资源被耗尽;缓存,多余的任务先放到队列里,相当于给了系统一个缓冲期,流量洪峰不会直接冲垮服务;管理,线程池可以统一设置线程名前缀、统一处理异常、统一调配资源、统一关闭。
所以线程池解决的绝不仅仅是性能问题,更是一套完整的并发资源治理方案。这也是为什么阿里规范里明确要求线程池不能用 Executors 随便创建,而要通过 ThreadPoolExecutor 手动配置——资源治理这事,马虎不得。
2. ThreadPoolExecutor 的运行内核:状态流转与 Worker 生命周期
2.1 七个参数,每个都在博弈
Java 里真正干活的是ThreadPoolExecutor,Executors 那些方法最后都是套壳它。先看最全的构造函数:
new ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲存活时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 任务队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 );这七个参数之间的关系,用一张表可以看得很清楚:
| 参数 | 作用 | 触发时机 |
|---|---|---|
| corePoolSize | 常驻线程数 | 提交任务时,核心线程未满则创建 |
| maximumPoolSize | 允许的最大线程数 | 队列满时,才创建非核心线程 |
| keepAliveTime | 非核心线程空闲存活时间 | 线程数超过 corePoolSize 时生效 |
| workQueue | 任务缓冲队列 | 核心线程全忙时,任务入队 |
| threadFactory | 生成线程的工厂 | 每次创建新线程时调用 |
| handler | 拒绝策略 | 队列满且线程数达到 maximumPoolSize 时触发 |
很多人以为只要线程数越多就越快,这是最大的误解。核心线程数决定的是“保底并发能力”,队列决定的是“缓冲能力”,最大线程数决定的是“临时扩容上限”。三者配合不好,要么资源浪费,要么任务堆积,要么直接满负荷拒绝。
这里还藏着一个容易忽略的细节:默认情况下核心线程是懒创建的。也就是刚 new 出来的线程池里一个线程都没有,等第一个任务提交进来才开始建线程。如果希望热点服务启动时就预热线程,可以调用prestartAllCoreThreads(),这在低延迟敏感的场景下很实用。
2.2 提交一个任务,线程池内部经历了什么
线程池处理一个任务的完整流程,网上很多文章画了各种图,我直接用文字把这条链路理清楚:
- 提交任务,先判断当前线程数是否小于 corePoolSize。如果小于,直接创建新线程执行任务,这一步不经过队列。
- 如果核心线程已经饱和,尝试把任务放入 workQueue 队列等待。
- 如果队列也满了,再判断当前线程数是否小于 maximumPoolSize。如果小于,创建非核心线程临时救场。
- 如果线程数已经到顶,队列也满,就只能交给拒绝策略处理。
这四步顺序非常关键,决定了为什么核心线程数和队列不能拍脑袋配。比如把队列配成无界队列,那第三步“创建非核心线程”永远不会执行,maximumPoolSize 就白设了。
线程数量的管理依赖一个内部变量ctl。它是一个 AtomicInteger,低 29 位存线程数,高 3 位存线程池状态。用这种打压缩包的方式,线程数量和状态可以用一次 CAS 操作原子更新,而不用加锁,性能开销低。
每个真正干活的线程被包装成 Worker,Worker 内部持有一个 Thread 和一个首次执行的任务。它执行完第一个任务后,会陷入一个 while 循环,不断从队列里取新任务来执行,直到超出生命周期条件才退出。这就是线程复用的本质:一个 Thread 不绑定单个任务,而是循环消费整个队列。
2.3 线程池的五种运行状态
线程池不是一直“开机”的,它有五种状态,名字都好认:
| 状态 | 含义 | 能否接收新任务 | 能否处理队列任务 |
|---|---|---|---|
| RUNNING | 运行中 | 能 | 能 |
| SHUTDOWN | 已调用 shutdown() | 否 | 能 |
| STOP | 已调用 shutdownNow() | 否 | 否 |
| TIDYING | 线程全部结束 | 否 | - |
| TERMINATED | 彻底终止 | 否 | - |
状态流转的方向基本是单向的:RUNNING 可以转向 SHUTDOWN 或 STOP,之后走到 TIDYING,最后变成 TERMINATED。shutdown()和shutdownNow()的区别非常实用:前者像下班打卡后把手头的工作干完再走,后者直接断电,正在跑的任务收不到中断信号也可能被强行打断。
这块原理性的东西看着枯燥,但排查问题的时候特别有用。比如服务明明不接收新请求了,线程池还有堆积任务在处理,这就说明状态处于 SHUTDOWN;如果怀疑线程池卡死,先用getActiveCount()和getQueue().size()定位,再决定是等人还是直接关闸。
3. Executors 工具类提供的常用线程池:四兄弟的适用边界与隐坑
3.1 newFixedThreadPool:看似安全,队列可能失控
ExecutorService executor = Executors.newFixedThreadPool(8);这个方法创建的是固定线程数线程池:corePoolSize 和 maximumPoolSize 相等,都是传入的数值,keepAliveTime 为 0。它内部用的队列是LinkedBlockingQueue,而且是没有指定容量的版本——换句话说,队列长度是Integer.MAX_VALUE。
所以这玩意儿有两个容易踩的坑。第一,最大线程数等于核心线程数,意味着线程池永远不会创建额外线程,哪怕任务堆积如山。第二,无界队列就像一个无限长的停车场,任务只会源源不断进队列,不会触发拒绝策略,看起来一切正常,直到排队任务把所有堆内存吃干净,触发OutOfMemoryError。
适用场景比如需要严格控制并发数的低频任务,或者任务量本身可控且提交高峰有限的服务。如果真是突发流量型任务,千万别用无参的 newFixedThreadPool。
3.2 newCachedThreadPool:弹性背后是失控风险
ExecutorService executor = Executors.newCachedThreadPool();Cached 线程池的核心线程数是 0,最大线程数是Integer.MAX_VALUE,队列用的是SynchronousQueue。这个队列本身不缓存任务,生产者提交任务时,必须有消费者线程正好在队尾等,否则任务直接失败并触发创建新线程的流程。
它的好处是弹性:任务多就创建线程,任务少线程空闲 60 秒自动回收,非常省资源。但问题也很明显,最大线程数等于无上限,一旦提交任务速度持续大于处理速度,线程数就会无限膨胀,最终把 CPU 和内存拖垮。
我见过一个统计服务用过它,平时数据量不大没事,某天突然导入历史全量数据,几分钟内线程数冲到几千,直接把机器打挂了。Cached 线程池只适合短生命周期任务且提交频率可控的场景,在不确定流量模型的线上服务里,用它是赌运气。
3.3 newSingleThreadExecutor:单线程也不是免死金牌
ExecutorService executor = Executors.newSingleThreadExecutor();单线程池保证任务按 FIFO 顺序串行执行,核心线程数 1,最大线程数 1,队列同样是无界 LinkedBlockingQueue。它适合需要保证任务严格有序执行的场景,比如顺序写日志、定时刷新缓存。
但它的坑和 Fixed 一样,队列无界。如果任务提交速度一直大于消费速度,任务会在队列里无限堆积。另外,如果任务内部抛异常,线程会终止,线程池会新建一个线程继续执行后续任务,但之前那个任务的执行结果你是拿不到的。想拿到任务异常,需要把任务包装成Callable并用submit()提交,通过Future.get()捕获。
3.4 newScheduledThreadPool:定时任务的正确打开方式
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4); scheduler.scheduleWithFixedDelay(task, 0, 10, TimeUnit.SECONDS);这一类线程池主要用于延迟执行和周期性任务,核心线程数手动指定,队列用的DelayedWorkQueue,本质上是一个延迟队列。正因为这个队列也是无界的,最大线程数参数其实发挥不了作用,任务都堆在延迟队列里等时间到。
用的时候要注意scheduleAtFixedRate和scheduleWithFixedDelay的区别。前者是固定频率,从任务开始执行计时,如果任务执行时间超过周期,下一个任务会延后,但不会并发执行;后者是固定延迟,从上一次执行结束开始计时。很多人把两者混用,结果定时任务的实际执行频率跟预期差很远。
四个兄弟的参数差异直接看这张表:
| 线程池 | 核心线程数 | 最大线程数 | 队列 | 最大队列容量 | 典型风险 |
|---|---|---|---|---|---|
| FixedThreadPool | n | n | LinkedBlockingQueue | Integer.MAX_VALUE | 任务堆积 OOM |
| CachedThreadPool | 0 | Integer.MAX_VALUE | SynchronousQueue | 0 | 线程无限膨胀 |
| SingleThreadExecutor | 1 | 1 | LinkedBlockingQueue | Integer.MAX_VALUE | 任务堆积 OOM |
| ScheduledThreadPool | n | Integer.MAX_VALUE | DelayedWorkQueue | Integer.MAX_VALUE | 周期任务串行堆积 |
4. 队列容器与饱和策略:任务无处安放时会发生什么
4.1 任务队列不是随便选一个容器
队列是线程池的“缓冲区”,但它的选型直接决定线程池在大流量下的表现。常用的几种:
LinkedBlockingQueue,基于链表的阻塞队列。可以指定容量,也可以不带容量。不带容量时是无界队列,任务只进不出,最大线程数和拒绝策略双双失效,这是很多线上事故的根源。带容量时是有界队列,搭配合理的最大线程数,就有完整的“缓冲 + 扩容 + 拒绝”三层防线。
ArrayBlockingQueue,基于数组的有界阻塞队列。创建时必须指定容量,还支持公平模式。公平模式下等待最久的线程优先获取任务,代价是性能略低。它适合对任务执行公平性有要求的场景。
SynchronousQueue,不缓存任何任务的队列,每个提交操作必须等待一个获取操作。它迫使任务直接转交给线程执行,没有中间缓冲,是 CachedThreadPool 的标准配置,适合直接透传任务、希望快速响应的场景。
PriorityBlockingQueue,支持按优先级出队的无界队列。可以给任务设置优先级,让紧急任务先执行,代价是队列本身需要排序,有额外开销。它同样是无界的,和它搭配时要格外小心堆积问题。
DelayedWorkQueue,每个任务有延迟时间,到期才能出队。ScheduledThreadPool 用的就是它,适合延迟任务、定时任务。
选队列的总体思路是:线上服务尽量用有界队列,容量根据任务峰值和平均执行时间来估算。宁可偶尔触发拒绝策略,也不要让队列无限长大。
4.2 四种拒绝策略,其实只有两种值得用
当队列满且线程数达到 maximumPoolSize,线程池会把任务交给拒绝策略。JDK 内置了四种:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy | 直接抛出 RejectedExecutionException | 可能中断调用方业务 |
| CallerRunsPolicy | 在调用线程里执行任务 | 调用方变慢,但提供天然背压 |
| DiscardPolicy | 直接丢弃,不通知 | 任务丢失无感知 |
| DiscardOldestPolicy | 丢弃队列里最老的任务,再尝试提交 | 旧任务丢失 |
AbortPolicy 是默认策略,也是“最安全”的策略——至少会让你知道系统过载了。很多线上系统没配拒绝策略,等到队列满了才发现抛异常,但此时往往已经丢了大量请求。
我个人的建议是,核心业务场景优先考虑 CallerRunsPolicy。它有一点很好:当线程池忙不过来时,任务会回退到调用方线程执行,调用方线程被占住后,自然就会减少继续提交任务,形成一种天然的背压机制。代价是接口耗时变长,但不会无声无息丢任务。
DiscardPolicy 和 DiscardOldestPolicy 在业务里要慎用。如果你丢了任务还没日志,事后排查会非常绝望,因为一切都看起来正常,但数据处理就是不完整。
4.3 自定义拒绝策略的通用做法
内置策略不够用的时候可以自己实现RejectedExecutionHandler接口。生产环境里我见过最好用的一种做法是:重试入队 + 告警。
public class AlertRejectedHandler implements RejectedExecutionHandler { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { // 记录告警日志,关键指标打点 System.err.println("task rejected, queueSize=" + executor.getQueue().size()); // 尝试等待一段时间后重新入队,最多重试3次 ThreadPoolExecutor temp = executor; for (int i = 0; i < 3; i++) { try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } try { temp.execute(r); return; } catch (RejectedExecutionException ignored) { } } // 仍然失败则走降级逻辑 fallback(r); } }这种策略等于把“拒绝”从直接失败改成“缓冲几秒再试”,给系统一点喘息空间。前提是调用方可以容忍短暂的排队延迟,同时一定要有告警机制提醒扩容或调参,否则问题被掩盖,迟早变成更大的隐患。
5. 参数调优与动态监控:让线程池真正可控的落地手段
5.1 核心线程数到底配多少
这是所有人最关心的问题,但没有银弹。线程池参数的估算逻辑是固定的,一切取决于任务是 CPU 密集型还是 IO 密集型。
CPU 密集型任务,比如图片处理、加解密、大量计算,线程数建议设为 CPU 核数 + 1,加 1 是为了在少量线程因为页缺失等短暂让出 CPU 时还能补位,保证 CPU 不闲着。
IO 密集型任务,比如网络请求、数据库读写、文件操作,任务大部分时间在等待 IO,线程可以适当多一点。业界常用公式是把 CPU 核数乘以 2,更精确的做法是考虑阻塞比例:线程数等于 CPU 核数乘以 1 加上阻塞系数,阻塞系数越高,线程数越多。
但公式只是起点,最终一定要配合压测。同一个线程池,垃圾回收器不同、机器规格不同、代码热点不同,结果可能差好几倍。我曾经在一个服务上做压测,固定线程数从 8 调到 32,QPS 只涨了 10%,但 CPU 上涨了 30%,怎么算都是亏的,最后调回 16 才是平衡点。调参这事,数据比直觉靠谱。
另外队列容量也不是越大越好。队列本质上是把压力往后移,任务堆积太多,等真正轮到执行时,数据可能已经过期了。对时效性敏感的任务,队列容量宁小勿大。
5.2 线程池运行状态怎么看
很多系统上线后,线程池就是个黑盒,出了问题才想起来看。但 JVM 其实给了不少内置指标,用好它们能提早发现隐患。
getPoolSize()返回当前线程数,getActiveCount()返回正在执行任务的线程数,getQueue().size()返回排队任务数,getTaskCount()返回线程池启动以来已调度的任务总数,getCompletedTaskCount()返回已完成任务数。这几个组合起来,基本能看出线程池的健康度:活跃线程数长期等于最大线程数、队列长期堆着任务,就是过载的信号。
如果嫌轮询麻烦,也可以直接继承 ThreadPoolExecutor,重写beforeExecute和afterExecute方法,在每个任务执行前后打点。这样每个任务的执行时长、异常信息都能落到日志里,排查问题效率翻倍。
还可以配合配置中心做动态调整。ThreadPoolExecutor提供了setCorePoolSize()、setMaximumPoolSize()等方法,运行时可以直接修改。所以线程池参数完全可以放到配置中心里,通过推送动态变更,不用重启服务。这套“可观测 + 动态调参”的组合,是我觉得生产环境最实用的一层保障。
5.3 优雅关闭线程池的姿势
线程池用完一定要关,不然 JVM 可能无法正常退出,尤其在你用线程池跑定时任务的场景里,进程停了半天还在等线程。
关闭有两个方法。shutdown()等待已经提交的任务执行完再关,是优雅关闭。shutdownNow()立刻中断所有正在执行的任务,返回尚未执行的任务列表,是暴力关闭。实践中比较稳的姿势是:先调shutdown(),再调用awaitTermination(超时时间)等它真正结束,如果超时还没关完,再补一个shutdownNow()强制兜底。
executor.shutdown(); if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { executor.shutdownNow(); // 此时可记录被强制中断的任务 }这里有个小细节:被shutdownNow()中断的任务,如果代码里不响应中断信号,就算调了也停不下来,所以在任务里对中断标志位要有基本的敏感度。
写在实际操作之后
最后再分享一个帮助很大的小习惯:自定义线程工厂给线程起名字。默认的线程池名称是pool-1-thread-1,一旦线上出问题,你根本不知道这个线程是谁创建的。我一般会在 ThreadFactory 里加上业务前缀,比如order-consumer-thread,这样线程 dump 出来一眼就能定位到哪个服务的哪个线程池放弃了治疗。
还有一点,线上线程池务必配上监控和告警,队列使用率、拒绝次数这些指标一旦超过阈值就报警。很多事故一开始只是队列悄悄变满,等所有线程都在排队、接口全面超时才被发现,那时候排查代价就大了。线程池这东西,配好只是开始,运维得当才是真正用明白。