首页 / Java 入门教程 / 线程池 ExecutorService

Java 入门教程

线程池 ExecutorService

本教程共 100 篇 · 第 100 篇 · 更新于 2026-08-05 · 约 19 分钟阅读

JavaJava 入门教程线程池ExecutorServiceThreadPoolExecutor虚拟线程

100. 线程池 ExecutorService

本节目标:搞懂线程池为什么存在,学会显式创建 ThreadPoolExecutor、提交任务取回结果,并写出不丢任务的优雅关闭代码。

一个任务配一个线程,问题在哪

前面几章的例子都是 new Thread(task).start()。任务少的时候看不出毛病,量一上来立刻现原形。

创建和销毁的代价很高。Java 的平台线程一对一映射到操作系统线程。每建一个,操作系统要分配内核数据结构和栈空间,默认栈就是 1MB 上下。建好只跑一个任务就扔掉,这笔开销全打了水漂。

线程用完就废了。执行完 run() 方法,线程进入终止态,再也回不来。下一个任务只能从头再建一个。

数量彻底失控。来一个请求建一个线程,请求突然涌进来一万个,就是一万个线程。内存扛不住,CPU 光顾着上下文切换,真正干活的时间反而所剩无几。最惨的结局是抛出 OutOfMemoryError: unable to create native thread,整个进程崩掉。

线程池要解决的就是这三件事:线程建好反复用,任务多了排队等,数量画一条硬上限

池化:养一队人,而不是雇一次开一次

拿餐厅打比方。客人进门才去招服务员,这桌吃完就把人辞退,谁都知道荒唐。

正常做法是固定养 5 个服务员。客人少时他们待命,客人多了就发号排队,等位区也坐满了才考虑临时加两个兼职。人再多就只能挂出「今日客满」。

线程池的逻辑一模一样:线程常驻,任务排队,忙不过来再扩容,实在撑不住就拒绝。这套思路叫池化,数据库连接池、对象池用的都是同一招——把昂贵的资源提前造好,反复借还,不重复造。

四个名字先分清楚

刚接触线程池,很容易被几个长得像的名字绕晕。

名字类型定位
Executor接口只有一个 execute(Runnable),最顶层的抽象
ExecutorService接口继承 Executor,补上 submitshutdown 等能力
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 按固定顺序判断:

  1. 当前线程数小于 corePoolSize新建核心线程执行,哪怕有线程正闲着
  2. 核心线程已满 → 任务进入队列排队
  3. 队列也满了,且线程数小于 maximumPoolSize新建临时线程执行
  4. 线程数已到最大且队列已满 → 交给拒绝策略处理

这里有个反直觉的点:排队优先于扩容。不少人以为线程会先加满到 maximumPoolSize 再排队,实际恰好相反,队列填满了才肯扩容。

正因为这样,队列容量如果给成无限,第 3、4 步永远不会触发,maximumPoolSize 和拒绝策略形同虚设——这正是 newFixedThreadPool 的病根。

四种内置拒绝策略

任务被拒时的四种反应,都是 ThreadPoolExecutor 的静态内部类:

策略行为适用场景
AbortPolicyRejectedExecutionException默认任务不能丢,需要上层感知并处理
CallerRunsPolicy谁提交谁执行,在调用线程里跑想要天然的反压,拖慢提交速度
DiscardPolicy悄悄丢弃,不抛异常不打日志任务可丢,比如非关键的埋点上报
DiscardOldestPolicy丢掉队列里最老的,再重试提交只关心最新数据,如实时行情刷新

CallerRunsPolicy 是很多场景下的优选。主线程被迫去执行任务,自然就没空继续提交新任务,相当于给上游踩了一脚刹车,压力自动回传。

拒绝策略也可以自己实现 RejectedExecutionHandler 接口,比如把任务落库、写告警日志,之后再补偿执行。

submit 和 execute 怎么选

两个方法都能提交任务,差别不小。

对比项executesubmit
参数只收 RunnableRunnableCallable
返回值Future
任务抛异常直接打印到控制台被 Future 吞掉,不调 get() 就看不见

第三行是真正的坑。submit 提交的任务抛了异常,控制台安安静静什么都不显示,问题被彻底掩盖。只有调用 future.get() 时,异常才会包成 ExecutionException 抛出来。

不需要返回值就用 execute,异常至少能第一时间看到。

Callable 和 Future

Runnablerun() 没有返回值,也不能抛受检异常。要拿结果就用 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` 的分治并行、结构化并发。这一章讲的是并发的入门层,往下还有很大空间。

不管选哪条,方法都一样:**敲一遍,跑一遍,改坏它再修好**。看懂和写对之间那道坎,只能靠手敲过去。
上一篇
显式锁 Lock 与 ReentrantLock
下一篇
已经是最后一篇啦