线程池 ExecutorService
本教程共 100 篇 · 第 100 篇 · 更新于 2026-08-05 · 约 19 分钟阅读
100. 线程池 ExecutorService
本节目标:搞懂线程池为什么存在,学会显式创建 ThreadPoolExecutor、提交任务取回结果,并写出不丢任务的优雅关闭代码。
一个任务配一个线程,问题在哪
前面几章的例子都是 new Thread(task).start()。任务少的时候看不出毛病,量一上来立刻现原形。
创建和销毁的代价很高。Java 的平台线程一对一映射到操作系统线程。每建一个,操作系统要分配内核数据结构和栈空间,默认栈就是 1MB 上下。建好只跑一个任务就扔掉,这笔开销全打了水漂。
线程用完就废了。执行完 run() 方法,线程进入终止态,再也回不来。下一个任务只能从头再建一个。
数量彻底失控。来一个请求建一个线程,请求突然涌进来一万个,就是一万个线程。内存扛不住,CPU 光顾着上下文切换,真正干活的时间反而所剩无几。最惨的结局是抛出 OutOfMemoryError: unable to create native thread,整个进程崩掉。
线程池要解决的就是这三件事:线程建好反复用,任务多了排队等,数量画一条硬上限。
池化:养一队人,而不是雇一次开一次
拿餐厅打比方。客人进门才去招服务员,这桌吃完就把人辞退,谁都知道荒唐。
正常做法是固定养 5 个服务员。客人少时他们待命,客人多了就发号排队,等位区也坐满了才考虑临时加两个兼职。人再多就只能挂出「今日客满」。
线程池的逻辑一模一样:线程常驻,任务排队,忙不过来再扩容,实在撑不住就拒绝。这套思路叫池化,数据库连接池、对象池用的都是同一招——把昂贵的资源提前造好,反复借还,不重复造。
四个名字先分清楚
刚接触线程池,很容易被几个长得像的名字绕晕。
| 名字 | 类型 | 定位 |
|---|---|---|
Executor | 接口 | 只有一个 execute(Runnable),最顶层的抽象 |
ExecutorService | 接口 | 继承 Executor,补上 submit、shutdown 等能力 |
ThreadPoolExecutor | 实现类 | 真正干活的线程池,所有参数都在这里 |
Executors | 工具类 | 一堆静态工厂方法,帮你快速造池子 |
Executors 结尾多个 s,它是工具类,和接口 ExecutorService 完全两码事。名字太像,初学者常在这里看串行。
日常写代码时,变量声明成 ExecutorService,实际对象是 ThreadPoolExecutor,这是典型的面向接口编程。
用 Executors 快速跑起来
先看最省事的写法,感受一下线程复用的效果:
import java.util.concurrent.*;
public class PoolQuickStart {
public static void main(String[] args) {
// 固定 3 个线程的池子
ExecutorService pool = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 6; i++) {
int id = i; // lambda 里要用的变量必须是有效 final
pool.execute(() -> {
String name = Thread.currentThread().getName();
System.out.println(name + " 接手任务 " + id);
try {
Thread.sleep(500); // 模拟耗时工作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
pool.shutdown(); // 不关就不退出
}
}
```text
输出:
```text
pool-1-thread-1 接手任务 1
pool-1-thread-2 接手任务 2
pool-1-thread-3 接手任务 3
pool-1-thread-1 接手任务 4
pool-1-thread-2 接手任务 5
pool-1-thread-3 接手任务 6
6 个任务,只出现了 3 个线程名,而且后 3 个任务复用了前面的线程。这就是池化的直接效果。
Executors 常用的几个工厂方法:
| 方法 | 特点 |
|---|---|
newFixedThreadPool(n) | 固定 n 个线程,多余任务排队 |
newCachedThreadPool() | 线程数按需增长,空闲 60 秒回收 |
newSingleThreadExecutor() | 只有一个线程,任务严格串行 |
newScheduledThreadPool(n) | 支持延迟执行和周期执行 |
newVirtualThreadPerTaskExecutor() | 每个任务一根虚拟线程(Java 21+) |
为什么生产代码不该用 Executors
方便归方便,前三个方法在真实项目里都有雷。
newFixedThreadPool 的底层是这样:
new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
```java
`LinkedBlockingQueue` 用无参构造,容量是 `Integer.MAX_VALUE`,等于**无界队列**。线程都在忙的时候任务只管往队列里堆,一直堆到堆内存被撑爆。
`newCachedThreadPool` 是另一个极端:
```java
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
SynchronousQueue 不存任何任务,来一个任务没有空闲线程就立刻新建。最大线程数是 Integer.MAX_VALUE,等于不限量。流量洪峰一来,线程数原地起飞。
Warning这两个坑在《阿里巴巴 Java 开发手册》里被点名,明确要求线程池不允许通过
Executors创建。生产代码请直接new ThreadPoolExecutor(...),逼自己把每个参数想清楚。
七个核心参数
ThreadPoolExecutor 完整构造方法有七个参数,记住它们就等于记住了线程池的全部行为。
| 参数 | 含义 | 怎么定 |
|---|---|---|
corePoolSize | 核心线程数,常驻不回收 | CPU 密集型取核数+1,IO 密集型可放大到核数的 2 倍以上 |
maximumPoolSize | 最大线程数,含临时线程 | 按下游系统承压能力定,别拍脑袋写很大 |
keepAliveTime | 临时线程空闲多久被回收 | 常用 30~60 秒 |
unit | 上一项的时间单位 | TimeUnit.SECONDS 等 |
workQueue | 任务等待队列 | 必须用有界队列,如 ArrayBlockingQueue |
threadFactory | 线程工厂,负责建线程 | 自定义线程名,排查问题时能救命 |
handler | 拒绝策略 | 队列满且线程满时怎么办 |
完整写法:
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class ExplicitPool {
public static void main(String[] args) {
AtomicInteger seq = new AtomicInteger(1);
// 给线程起有意义的名字,日志和线程栈里一眼能认出来
ThreadFactory factory =
r -> new Thread(r, "order-worker-" + seq.getAndIncrement());
ExecutorService pool = new ThreadPoolExecutor(
2, // 核心线程数
4, // 最大线程数
60L, TimeUnit.SECONDS, // 临时线程空闲存活时间
new ArrayBlockingQueue<>(10), // 有界队列,容量 10
factory, // 线程工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
pool.execute(() -> System.out.println(
Thread.currentThread().getName() + " 正在处理订单"));
pool.shutdown();
}
}
```text
输出:
```text
order-worker-1 正在处理订单
Tip线程工厂里改个名字是性价比极高的习惯。线上 dump 线程栈时,满屏
pool-1-thread-7谁也看不出是哪个池子的,order-worker-7一眼定位。
任务进来之后走哪条路
提交一个任务,ThreadPoolExecutor 按固定顺序判断:
- 当前线程数小于
corePoolSize→ 新建核心线程执行,哪怕有线程正闲着 - 核心线程已满 → 任务进入队列排队
- 队列也满了,且线程数小于
maximumPoolSize→ 新建临时线程执行 - 线程数已到最大且队列已满 → 交给拒绝策略处理
这里有个反直觉的点:排队优先于扩容。不少人以为线程会先加满到 maximumPoolSize 再排队,实际恰好相反,队列填满了才肯扩容。
正因为这样,队列容量如果给成无限,第 3、4 步永远不会触发,maximumPoolSize 和拒绝策略形同虚设——这正是 newFixedThreadPool 的病根。
四种内置拒绝策略
任务被拒时的四种反应,都是 ThreadPoolExecutor 的静态内部类:
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy | 抛 RejectedExecutionException,默认 | 任务不能丢,需要上层感知并处理 |
CallerRunsPolicy | 谁提交谁执行,在调用线程里跑 | 想要天然的反压,拖慢提交速度 |
DiscardPolicy | 悄悄丢弃,不抛异常不打日志 | 任务可丢,比如非关键的埋点上报 |
DiscardOldestPolicy | 丢掉队列里最老的,再重试提交 | 只关心最新数据,如实时行情刷新 |
CallerRunsPolicy 是很多场景下的优选。主线程被迫去执行任务,自然就没空继续提交新任务,相当于给上游踩了一脚刹车,压力自动回传。
拒绝策略也可以自己实现 RejectedExecutionHandler 接口,比如把任务落库、写告警日志,之后再补偿执行。
submit 和 execute 怎么选
两个方法都能提交任务,差别不小。
| 对比项 | execute | submit |
|---|---|---|
| 参数 | 只收 Runnable | Runnable 或 Callable |
| 返回值 | 无 | Future |
| 任务抛异常 | 直接打印到控制台 | 被 Future 吞掉,不调 get() 就看不见 |
第三行是真正的坑。submit 提交的任务抛了异常,控制台安安静静什么都不显示,问题被彻底掩盖。只有调用 future.get() 时,异常才会包成 ExecutionException 抛出来。
不需要返回值就用 execute,异常至少能第一时间看到。
Callable 和 Future
Runnable 的 run() 没有返回值,也不能抛受检异常。要拿结果就用 Callable:
import java.util.concurrent.*;
public class FutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
Callable<Integer> task = () -> {
Thread.sleep(1000); // Callable 可以抛受检异常
return 6 * 7;
};
Future<Integer> future = pool.submit(task);
System.out.println("任务已提交,主线程继续干别的");
// 最多等 2 秒,超时抛 TimeoutException
Integer result = future.get(2, TimeUnit.SECONDS);
System.out.println("结果:" + result);
pool.shutdown();
}
}
```text
输出:
```text
任务已提交,主线程继续干别的
结果:42
Future 的常用方法:
| 方法 | 说明 |
|---|---|
get() | 取结果,任务没完成就一直阻塞 |
get(timeout, unit) | 限时取结果,超时抛 TimeoutException |
isDone() | 任务是否已结束(正常、异常、取消都算) |
cancel(true) | 取消任务,参数决定是否中断正在跑的线程 |
Note无参
get()会一直等下去,任务卡死就跟着一起卡死。线上代码尽量用带超时的版本,给自己留条退路。
提交任务和调 get() 之间要留出干活的空间。提交完立刻 get(),等于把异步退化成同步,线程池白用了。
优雅关闭
线程池里的线程默认不是守护线程,只要池子不关,JVM 就不会退出。程序看着跑完了,进程却挂在那里不走,多半是忘了关线程池。
关闭有两个方法,行为完全不同:
| 方法 | 行为 | 返回值 |
|---|---|---|
shutdown() | 不再接收新任务,已提交的全部执行完 | 无 |
shutdownNow() | 不再接收新任务,中断正在执行的,清空队列 | 未执行的任务列表 |
两个方法都是立即返回的,不会等任务跑完。想确认池子真的停了,得配合 awaitTermination()。
标准的关闭套路是「先礼后兵」:
import java.util.concurrent.*;
public class GracefulShutdown {
public static void main(String[] args) {
ExecutorService pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10));
for (int i = 1; i <= 5; i++) {
int id = i;
pool.execute(() -> {
System.out.println("任务 " + id + " 开始");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; // 收到中断就尽快收尾
}
System.out.println("任务 " + id + " 完成");
});
}
shutdownGracefully(pool);
System.out.println("线程池已彻底关闭");
}
static void shutdownGracefully(ExecutorService pool) {
pool.shutdown(); // 第一步:礼貌请求
try {
// 第二步:给 5 秒时间收尾
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 第三步:超时就强制中断
// 第四步:再确认一次是否真停了
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("线程池未能正常关闭");
}
}
} catch (InterruptedException e) {
pool.shutdownNow(); // 等待期间自己被中断
Thread.currentThread().interrupt(); // 恢复中断标志
}
}
}
```bash
四步缺一不可。只调 `shutdown()` 不等待,任务可能被 JVM 退出打断;只调 `shutdownNow()` 又太粗暴,正在处理的数据可能写一半。
`shutdownNow()` 靠的是给线程发中断信号。任务代码如果不理会中断标志,比如里面是个不检查条件的死循环,照样停不下来。写任务时留意响应中断,这是能被强制关闭的前提。
## Java 19 起可以用 try-with-resources
`ExecutorService` 从 Java 19 开始实现了 `AutoCloseable`,可以交给 try-with-resources 托管:
```java
import java.util.concurrent.*;
public class AutoCloseDemo {
public static void main(String[] args) {
try (ExecutorService pool = Executors.newFixedThreadPool(3)) {
for (int i = 1; i <= 5; i++) {
int id = i;
pool.submit(() -> System.out.println("处理任务 " + id));
}
} // 离开代码块自动 shutdown 并等待全部任务结束
System.out.println("全部完成");
}
}
close() 内部做的事情就是 shutdown() 加上无限等待,中途被中断则改用 shutdownNow()。语义清晰,短生命周期的池子用它非常合适。
长期存活的全局线程池不适合这么写。它的生命周期跟着应用走,应该在容器关闭钩子里调用前面那套四步法。
虚拟线程带来的新选项
Java 21 正式落地的虚拟线程,直接改变了线程池的使用前提。
虚拟线程由 JVM 调度,不绑定操作系统线程,创建成本极低,几十万根都不成问题。既然线程本身不贵了,「复用线程」这个理由就不再成立。
import java.util.concurrent.*;
public class VirtualThreadDemo {
public static void main(String[] args) {
// 每个任务一根虚拟线程,不复用也不心疼
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 10_000; i++) {
int id = i;
pool.submit(() -> {
Thread.sleep(1000); // 模拟一次网络请求
return id;
});
}
} // 自动等待一万个任务全部结束
System.out.println("一万个任务全部完成");
}
}
```bash
一万个任务,每个睡 1 秒,总耗时大约 1 秒多一点。换成平台线程池,要么建一万个线程把内存吃光,要么限制并发数慢慢排队。
选型上有一条清晰的分界:
- **IO 密集型**(数据库查询、HTTP 调用、文件读写)→ 虚拟线程执行器,简单直接
- **CPU 密集型**(图像处理、加解密、大量计算)→ 固定大小的平台线程池,线程数贴近 CPU 核数
虚拟线程在阻塞时会主动让出底层载体线程,这是它省资源的关键。纯计算任务不会阻塞,用虚拟线程反而白白多一层调度开销。
> [!TIP]
> 虚拟线程执行器不需要也不应该设置池大小。它本来就不是池,每个任务一根新虚拟线程,用完即弃。
## 本节小结
- 手动建线程有三个硬伤:创建开销大、无法复用、数量失控会 OOM。
- `ExecutorService` 是接口,`ThreadPoolExecutor` 是实现类,`Executors` 是工具类。
- `Executors` 的工厂方法适合练手,生产代码直接 `new ThreadPoolExecutor(...)`。
- 七个参数里,**队列必须有界**,否则最大线程数和拒绝策略都失效。
- 任务分派顺序是「核心线程 → 队列 → 临时线程 → 拒绝策略」,排队优先于扩容。
- 四种拒绝策略中,`CallerRunsPolicy` 能形成天然反压。
- `submit` 会吞掉任务异常,不调 `get()` 看不见;不要返回值就用 `execute`。
- `Future.get()` 尽量带超时,避免无限期挂起。
- 优雅关闭四步:`shutdown()` → `awaitTermination()` → `shutdownNow()` → 再确认一次。
- Java 19+ 的 `ExecutorService` 支持 try-with-resources,Java 21 的虚拟线程执行器适合 IO 密集型任务。
## 走到这里,往哪继续
一百章从 `Hello World` 一路走到线程池,Java 的语言主线基本铺完了:语法、面向对象、集合、异常、IO、并发,这些是所有 Java 代码的地基。
再往前有三条路可以走。
**往下挖 JVM**。内存区域划分、垃圾回收算法、类加载机制。写得久了总会遇到内存溢出和 GC 停顿,那时候这些知识就从「知道」变成「必须懂」。
**往上接生态**。构建工具 Maven 或 Gradle、Spring 全家桶、数据库访问、消息中间件。企业项目里这些是日常,语言只是入场券。
**横向补并发深水区**。`CompletableFuture` 的异步编排、`ForkJoinPool` 的分治并行、结构化并发。这一章讲的是并发的入门层,往下还有很大空间。
不管选哪条,方法都一样:**敲一遍,跑一遍,改坏它再修好**。看懂和写对之间那道坎,只能靠手敲过去。