在上一篇文章Day35 | Java多线程入门中,我们使用new Thread()来创建和启动线程。
通过这个过程了解了线程的一些基本概念和基础操作。
但是在实际的开发和真实的场景中,通过前文的方式使用线程有一些明显的弊端:
开销大:频繁地创建和销毁线程会消耗大量的系统资源。线程是一个重量级资源,创建过程涉及和操作系统的交互,成本很高。
不利于管理:没办法有效地控制并发线程的数量。如果请求量很大,无限制地创建线程很可能导致内存溢出,导致系统崩溃。
功能单一:new Thread()的方式功能很有限,很难实现任务的延迟执行、周期性执行,或者获取任务的执行结果等复杂需求。
所以在现实开发的过程中,我们通常都是使用Java5就引入的Executor框架。这个框架的核心就是线程池。
为了方便理解,后续的内容我们都围绕餐馆这个生活中的案例来阐述相关的概念。
如果你是一家餐馆的老板,每天都要处理大量的订单。
如果每来一个订单就雇一个新厨师(new Thread()),那你面临的问题就是:
雇人成本高(创建线程耗资源),厨房会被挤爆(内存溢出),而且那么多的厨师,你也管不过来。
最后,餐馆可能就倒闭了。
一、什么是线程池
为了解决上面那些问题,Java5就引入了Executor框架(智能厨房),这个厨房里一支固定的厨师团队(线程),
一个订单队列(任务队列),和一个经理(线程池管理器),有订单(任务)来的时候:
如果有空闲厨师,马上就处理。
如果厨师忙不过来了,订单就先排着队。
如果队列满了,还可以临时加派厨师(但有上限)。
如果实在忙不过来了,经理会按策略拒绝新订单(比如让客户等会再来)。
Java的Executor框架就是扮演的这个经理的角色,核心组件有这些:
Executor: 顶级接口,只定义了一个execute(Runnable command)方法。
ExecutorService: Executor的子接口,也是我们最常使用的接口。它增加了线程池的生命周期管理(如shutdown()),并提供了submit()方法来提交可以返回结果的任务。
ScheduledExecutorService: ExecutorService的子接口,增加了对任务进行定时或周期性执行的支持。

二、线程池创建
我们先用Executors工具类以简单的方式创建一些线程池,类似快速便捷的租一个现成的厨房团队。
2.1 FixedThreadPool
固定人数的团队,雇佣指定数量的厨师,订单多了就排队。
java
package com.lazy.snail.day36;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @ClassName Day36Demo
* @Description TODO
* @Author lazysnail
* @Date 2025/7/21 10:53
* @Version 1.0
*/
public class Day36Demo {
public static void main(String[] args) {
ExecutorService kitchen = Executors.newFixedThreadPool(3);
for (int i = 1; i <= 5; i++) {
final int order = i;
kitchen.execute(() -> System.out.println("订单 " + order + " 由 " + Thread.currentThread().getName() + " 处理"));
}
kitchen.shutdown();
}
}
3个线程轮流处理5个订单,多的订单排队等待。

订单队列没有上限(LinkedBlockingQueue),如果订单源源不断,可能撑爆内存。
2.2 CachedThreadPool
类似临时工,忙的时候就加点人,空闲的时候就减少点人。
java
ExecutorService kitchen = Executors.newCachedThreadPool();
这种比较适合处理短时订单高峰的情况。
高峰期可能雇佣无数厨师(线程数可达Integer.MAX_VALUE),然后耗尽资源。
2.3 SingleThreadExecutor
只有一个厨师,所有的订单都严格按照顺序处理。
java
ExecutorService kitchen = Executors.newSingleThreadExecutor();
这种适合需要顺序执行的任务,比如日志记录。
队列一样没有上限,可能导致内存溢出。
《阿里巴巴Java开发手册》建议避免直接使用Executors,因为默认配置可能导致内存溢出。实际使用过程中,我们应该用ThreadPoolExecutor自定义线程池,自己当经理。
三、自定义线程池
ThreadPoolExecutor可以让我们完全的掌控"厨房",能设置厨师数量、订单队列大小、处理策略等等。
3.1 核心参数
ThreadPoolExecutor的构造方法:

java
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
}
corePoolSize:核心线程数,线程池中长期保持的线程数量,即使它们处于空闲状态。
你平时雇佣的固定厨师人数,即使没订单也不会开掉这些人。
maximumPoolSize:最大线程数,线程池能够容纳的最大线程数量。
高峰期的时候,最多能够雇佣多少厨师。
keepAliveTime:线程存活时间,当线程数大于corePoolSize的时候,多余的空闲线程在被销毁前等待新任务的最长时间。
临时厨师闲着多久会被解雇。
unit:keepAliveTime的时间单位。
workQueue:任务队列,用来保存等待执行的任务的阻塞队列。
订单排队的桌子,大小有限或无限。
threadFactory:线程工厂,用来创建新线程。一般用来自定义线程名称,方便问题排查。
给每个厨师取个名字,方便追踪谁在干活。
handler:拒绝策略,当队列已满且线程数达到maximumPoolSize时,怎么处理新提交的任务。
订单太多、桌子满了怎么办?是拒绝、还是扔掉老订单、还是直接让客户自己做?
java
package com.lazy.snail.day36;
import java.util.concurrent.*;
/**
* @ClassName CustomThreadPool
* @Description TODO
* @Author lazysnail
* @Date 2025/7/21 11:45
* @Version 1.0
*/
public class CustomThreadPool {
public static void main(String[] args) {
ExecutorService kitchen = new ThreadPoolExecutor(
2,
5,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new ThreadFactory() {
private int count = 0;
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "厨师-" + count++);
}
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 1; i <= 10; i++) {
final int order = i;
kitchen.submit(() -> {
try {
Thread.sleep(1000);
System.out.println("订单 " + order + " 由 " + Thread.currentThread().getName() + " 处理");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
kitchen.shutdown();
}
}
上面的案例中,雇佣了2个固定厨师,最多能够容纳5个厨师,临时厨师如果空闲60秒就会被解雇。
订单的队列长度是10个,给每个厨师都起了名字,如果订单太多、桌子满了,就让客户自己做。
案例中模拟提交了10个订单由厨房处理。

"订单太多,客户自己做"解释下。当把订单数量调整到16(超过15)时,由于线程池已满,触发CallerRunsPolicy,提交任务的主线程(main)就会自己执行第16个订单的任务。

3.2 拒绝策略
RejectedExecutionHandler有四种,"订单太多,客户自己做"是其中的一种。
AbortPolicy(默认):直接抛出RejectedExecutionException异常。
餐馆老板直接把客户赶走了,明确的告诉客户,太忙了,处理不过来了。
CallerRunsPolicy:不使用线程池的线程,而是由提交任务的那个线程自己来执行。
这个策略就是上面案例中的订单太多,客户自己做。
DiscardPolicy:默默地丢弃新提交的任务,不抛异常也不执行。
老板看有新客户下单了,又忙不过来,直接就把订单丢垃圾桶了,客户根本不知道,他可能以为还在排队。
DiscardOldestPolicy:丢弃队列中最老的任务,然后尝试重新提交新任务。
老板发现桌子满了,又有新订单来了,转了一圈,把等最久的单子丢了,先安排了新订单。
3.3 任务队列
下面是一些常见的任务队列:
| 队列类型 | 特点 | 关联的Executors方法 |
|---|---|---|
| ArrayBlockingQueue | 有界队列,基于数组,FIFO。创建时需指定容量。 | (无,需自定义创建) |
| LinkedBlockingQueue | 无界队列(默认容量Integer.MAX_VALUE)。 | newFixedThreadPool, newSingleThreadExecutor |
| SynchronousQueue | 不存储元素的队列,每个插入操作必须等待一个移除操作。 | newCachedThreadPool |
| PriorityBlockingQueue | 带优先级的无界队列。 | (无,需自定义创建) |
实际开发优先使用ArrayBlockingQueue,避免任务无限堆积。
四、线程池执行流程
下面根据ThreadPoolExecutor的execute方法(openJdk17源码)大致梳理的一个线程池的执行流程。
4.1 检查核心线程
java
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) // true 表示"核心线程"
return;
c = ctl.get(); // 重新读取线程池状态
}
如果workerCount<corePoolSize,尝试通过addWorker(command, true)添加核心线程。
如果成功,退出;否则,更新线程池状态,继续下一步。
4.2 任务入队
java
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command)) // 如果线程池被关闭了,任务要移出队列并拒绝
reject(command);
else if (workerCountOf(recheck) == 0) // 如果线程池中没有线程了,要创建一个非核心线程
addWorker(null, false);
}
如果线程池是RUNNING且队列接受任务(workQueue.offer(command)),任务入队。
再一次检查状态:
如果不再是RUNNING且任务可移除,拒绝任务。
如果workerCount == 0,调用addWorker(null, false) 创建非核心线程处理队列。
4.3 检查非核心线程
java
else if (!addWorker(command, false)) // false 表示非核心线程
reject(command);
如果队列已满或线程池非RUNNING,尝试通过addWorker(command, false) 创建非核心线程。
如果成功,任务被分配给新线程。
4.4 拒绝任务
java
handler.rejectedExecution(command, this);
如果addWorker(command, false) 失败,调用handler.rejectedExecution(command, this)执行拒绝策略。
上面提到的核心线程和非核心线程本质上都是Worker实例,只是在创建时机和生命周期上有区别。
五、任务提交和结果获取
在线程池中,主要有两种方式来提交任务:execute(Runnable)和submit(Callable)。
其中execute(Runnable)它只管执行,不管结果。
java
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Runnable接口的run()方法签名是void run(),没办法返回任何计算结果。
run()方法签名没有声明throws Exception,在任务中发生了受检异常,必须在run()方法内部用try-catch块处理掉。
任务的执行结果(成功或失败)对于提交任务的主线程来说是完全未知的。
submit(Callable)是一个更加通用的任务提交方式。
Callable可以看成是Runnable的升级版,专门为需要返回结果的异步任务而设计。
java
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
跟Runnable相比,call()方法的返回值类型是泛型V,意味着你的任务可以返回任何类型的结果。
call()方法签名声明了throws Exception,这意味着你可以在任务中抛出受检异常,不需要在内部try-catch。
这个异常可以被任务的提交者捕获到。
当使用submit(Callable)提交任务时,方法不会阻塞,他会马上返回一个Future对象。
可以把这个返回的Future对象看成一个提货单,拿到它之后,就可以暂时去干别的事情了。
这个提货单有以下几个功能:

V get(): 这是提货的方法。你想来拿结果的时候,就调用这个方法。
如果任务已经完成,会返回Callable的call()方法计算出的结果。
如果任务还没完成,调用get()的线程会阻塞(暂停并等待),直到任务完成为止。
V get(long timeout, TimeUnit unit): get()带超时的版本。
如果在指定时间内任务还没完成,就会抛出TimeoutException,避免无限期等待。
boolean isDone(): 检查任务完成没有(无论是正常结束、异常终止还是被取消)。这是一个非阻塞方法。
boolean cancel(boolean mayInterruptIfRunning): 尝试取消任务。
全文提到的"尝试取消"、"尝试提交"、"尝试关闭"这类用词,其实是线程池设计的一个核心思想。线程池中大部分的操作都是"请求",而不是"命令"。调用者通过"请求"来表达想干什么事情,而整个线程池系统什么时候,怎么处理这个请求,取决于它自身的内部状态、规则和资源情况。而不是像"命令"一样,收到就马上、直接、强制的去执行并响应。
看一个submit(Callable)+Future的案例:
java
package com.lazy.snail.day36;
import java.util.concurrent.*;
/**
* @ClassName CallableFutureTest
* @Description TODO
* @Author lazysnail
* @Date 2025/7/21 16:09
* @Version 1.0
*/
public class CallableFutureTest {
public static void main(String[] args) {
ExecutorService pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10)
);
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};
Future<Integer> future = pool.submit(task);
System.out.println("任务已提交...");
try {
Integer result = future.get();
System.out.println("结果: " + result);
} catch (InterruptedException e) {
System.err.println("任务被中断: " + e.getMessage());
Thread.currentThread().interrupt();
} catch (ExecutionException e) {
System.err.println("任务执行失败: " + e.getCause());
} finally {
pool.shutdown();
}
}
}
pool.submit(task)被调用之后,没有阻塞主线程,马上返回了一个Future类型的对象(我们的提货单)。
"任务已提交..."这行马上被打印出来就证明了任务提交的异步性------主线程发起了任务,但不需要等待它完成。
主线程完成了其他事情之后,调用future.get()来提货。
这个调用是阻塞的,主线程会在这里暂停,直到子线程里的task执行完返回结果42。
拿到结果之后,程序才会继续执行并打印。
六、关闭线程池
前文中我们讲过守护线程和非守护线程的区别。默认情况下,线程池创建的线程都是非守护线程。
如果你创建了一个线程池并提交了任务,即使你的main方法执行完毕,只要线程池没有被关闭,它内部的线程(即使是空闲的)也会一直存在,从而阻止JVM正常退出。
这会导致应用程序挂起,看起来就像卡住了。
ExecutorService接口给我们提供了两个核心的关闭方法:shutdown和shutdownNow
看一下二者的区别:
| 特性 | shutdown() | shutdownNow() |
|---|---|---|
| 大喇叭宣布餐厅打烊 | 直接拉电闸,紧急疏散 | |
| 新任务 | 不再接受新提交的任务。 | 不再接受新提交的任务。 |
| 已提交任务 | 等待所有已提交的任务(包括队列中的)执行完毕。 | 尝试中断所有正在执行的任务,清空任务队列。 |
| 返回值 | void | List(返回队列中还没被执行的任务列表) |
| 调用时机 | 首选的、优雅的关闭方式。 | 作为shutdown()超时后的最后手段,或需要立即停止的场景。 |
| 状态变更 | 线程池进入SHUTDOWN状态。 | 线程池进入STOP状态。 |
字面上看,shutdownNow的方式也更加激进一些。
而在实际的开发中,我们肯定不能简单粗暴的关闭线程池。
下面是模拟的开发环境中关闭线程池的案例代码:
java
package com.lazy.snail.day36;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* @ClassName ShutdownTest
* @Description TODO
* @Author lazysnail
* @Date 2025/7/21 16:28
* @Version 1.0
*/
public class ShutdownTest {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(2);
pool.submit(() -> {
try {
System.out.println("任务1开始执行...");
Thread.sleep(2000);
System.out.println("任务1执行完毕。");
} catch (InterruptedException e) {
System.err.println("任务1被中断。");
Thread.currentThread().interrupt();
}
});
pool.submit(() -> {
try {
System.out.println("任务2开始执行...");
Thread.sleep(8000);
System.out.println("任务2执行完毕。");
} catch (InterruptedException e) {
System.err.println("任务2被中断。");
Thread.currentThread().interrupt();
}
});
System.out.println("主线程:发起关闭线程池请求...");
pool.shutdown();
try {
System.out.println("主线程:等待最多5秒让任务结束...");
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
System.err.println("主线程:超时!强制关闭线程池...");
pool.shutdownNow();
if (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
System.err.println("线程池未能终止。");
}
}
} catch (InterruptedException e) {
System.err.println("主线程等待时被中断,强制关闭线程池...");
pool.shutdownNow();
Thread.currentThread().interrupt();
}
System.out.println("主线程:关闭流程结束。");
}
}
shutdown通知线程池进入关闭流程,不再接受新任务。
调用awaitTermination(long timeout, TimeUnit unit)方法,让当前线程(main)阻塞等待。
要么线程池中的所有任务都执行完毕,线程池完全终止。此时,方法返回true。
要么超时时间到达。此时,方法返回false。
如果awaitTermination因为超时而返回false,说明还有任务还没结束。
这个时候就只能调用shutdownNow()来尝试强制中断这些相对顽固的任务。
调用awaitTermination的线程自身也可能被中断。
我们必须捕获这个InterruptedException,在捕获后也调用shutdownNow()并恢复当前线程的中断状态。
七、线程池监控及调优
线程池给我们提供了很多运行状态信息,如果我们想要我们经营的餐馆,厨房有序、合理、高效的处理订单。
就可以通过监控这些状态信息,并及时的调整配置信息,让厨房达到一个最佳的状态。
ThreadPoolExecutor提供了一系列方法,我们可以通过这些方法监控线程池的状态,接下来我用类比的方式讲一下常用的方法:
| 方法 | 类比 | 含义 |
|---|---|---|
| getActiveCount() | 有多少厨师在忙 | 返回当前正在执行任务的线程数 |
| getPoolSize() | 当前雇了多少厨师 | 返回线程池中实际存在的线程数(核心+临时) |
| getQueue().size() | 桌子上有多少订单 | 返回任务队列中的等待任务数 |
| getCompletedTaskCount() | 完成了多少订单 | 返回已完成的任务总数(近似值) |
| getTaskCount() | 总共收到多少订单 | 返回提交到线程池的总任务数(包括已完成、排队和正在执行的) |
| getLargestPoolSize() | 历史最多雇了多少厨师 | 返回线程池历史上同时存在的最大线程数 |
如果发现排队订单越来越多(getQueue().size()接近队列容量),说明桌子不够大或厨师太少,可能需要调整配置。
以下这些建议只是作为参考:
如果任务量稳定,corePoolSize可以设置成CPU核数的1-2倍。
maximumPoolSize一般设置为CPU核数的2-4倍,避免过多线程导致上下文切换开销。
推荐ArrayBlockingQueue这个有界队列,控制订单堆积。订单量波动大时,适当增大队列,但要注意内存占用。
一般设为几十秒到几分钟,高峰过后能够快速的释放资源。
低负载的时候采用CallerRunsPolicy,让客户自己做菜,减缓提交速度。
要求高可靠性的场景使用用AbortPolicy,明确失败,触发告警。
对结果不太敏感,非关键任务的时候用DiscardPolicy或DiscardOldestPolicy,丢弃任务以保护系统。
如果任务执行得太慢,就只能优化任务逻辑,或者把大人物拆成小任务(类似ForkJoinPool的分治思想)。
还可以使用PriorityBlockingQueue优先处理重要订单。
线程池的优化不是一成不变的模板化操作,而是需要根据实际业务场景和运行时状态动态调整。真正的优化依赖于持续监控线程池的运行状态,结合业务需求和系统资源,动态调整参数才能达到最佳性能。
结语
看完本文,我们已经从new Thread()跨越到了线程池。
线程池其实也并不高大上,他跟数据库连接池和HTTP连接池的核心思想一样。
都是一种池化技术,本质就是通过复用昂贵资源,来降低单次获取该资源的开销,并对资源进行统一管理,从而提高系统整体的性能和稳定性。
不管什么池,都可以把它看成一个容器,用来存放资源,都有获取、归还资源的方法。
只是因为管理的资源不同,对于核心数量、最大数量、空闲超时、拒绝策略等参数的管理策略不同而已。
下一篇预告
Day37 | 线程安全与synchronized
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!