42. 线程池应该定义在哪里,为什么不能每次都 new
发布于 • 阅读量 0
42. 线程池应该定义在哪里,为什么不能每次都 new
前面的示例为了方便运行,通常直接在 main 方法里创建线程池:
ThreadPoolExecutor pdfExecutor =
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("pdf-worker"),
new ThreadPoolExecutor.AbortPolicy()
);
对于一个运行完就退出的练习程序,这样写没有问题。
但放到长期运行的 Spring Boot 项目里,线程池不能在每次业务调用时重新创建。
比如下面这种写法就有问题:
public void processPdf(File file) {
ThreadPoolExecutor executor =
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("pdf-worker"),
new ThreadPoolExecutor.AbortPolicy()
);
executor.execute(() -> {
addWatermark(file);
});
}
每调用一次 processPdf(),就会创建一个新的线程池。
如果这个方法被调用 100 次,理论上就可能创建 100 个线程池。
真正失控的就不再是任务数量,而是线程池本身。
每次 new 线程池会发生什么
假设每个线程池配置了 3 个核心线程:
corePoolSize = 3
如果来了 100 次请求,每次请求都创建自己的线程池,那么系统最多可能出现:
100 个线程池;
每个线程池最多 3 个工作线程;
总共可能创建 300 个工作线程。
如果最大线程数设置得更大,情况还会更严重。
这已经失去了使用线程池的意义。
线程池原本是为了:
控制线程数量;
复用线程;
限制任务队列;
统一管理资源。
但如果每个请求都创建一个线程池,就相当于绕过了这些限制。
单个线程池确实只允许 3 个线程,但系统里有几十个甚至几百个线程池,总线程数依然不可控。
一个常见的错误写法
例如在 Controller 里:
@PostMapping("/pdf/watermark")
public String addWatermark(
@RequestParam MultipartFile file
) {
ThreadPoolExecutor executor =
createPdfExecutor();
executor.execute(() -> {
processPdf(file);
});
return "任务已经提交";
}
这个接口每被调用一次,就创建一个线程池。
而且代码里没有关闭线程池。
这样会出现几个问题:
线程不断增加;
线程池没有释放;
内存占用持续增加;
应用关闭时任务不好管理;
每个线程池都有自己的任务队列;
无法知道系统总共积压了多少任务。
即使在方法最后调用:
executor.shutdown();
也不算合理。
因为这样会变成:
创建线程池;
执行一个任务;
关闭线程池。
线程根本没有得到复用。
这种写法和每次创建新线程差别已经不大了,反而多了一层线程池管理代码。
线程池应该是长期存在的执行资源
我现在更倾向于把线程池理解成数据库连接池。
项目里不会每执行一次 SQL,就重新创建一个数据库连接池。
线程池也是一样。
它应该作为应用级资源长期存在:
应用启动时创建;
业务运行期间重复使用;
应用关闭时统一销毁。
每次业务调用时,我只负责提交任务:
pdfExecutor.execute(() -> {
processPdf(file);
});
而不是重新创建执行器。
这样所有 PDF 任务都会进入同一个线程池。
线程数量、队列长度和拒绝策略才真正有意义。
线程池和任务的生命周期不同
一个 PDF 任务可能只执行几秒。
线程池则可能从应用启动一直存在到应用关闭。
它们的生命周期不同:
PDF 任务:短生命周期;
线程池:长生命周期。
所以不能把线程池定义在每个任务内部。
错误结构是:
业务方法开始;
创建线程池;
提交一个任务;
业务方法结束。
更合理的结构是:
应用启动;
创建线程池;
不断接收并执行任务;
应用关闭;
关闭线程池。
普通 Java 程序里可以定义在哪里
如果不是 Spring Boot,而是一个普通 Java 程序,可以把线程池定义成应用级对象。
例如:
public class PdfTaskManager {
private final ThreadPoolExecutor pdfExecutor;
public PdfTaskManager() {
this.pdfExecutor =
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("pdf-worker"),
new ThreadPoolExecutor.AbortPolicy()
);
}
public void submit(File file) {
pdfExecutor.execute(() -> {
processPdf(file);
});
}
public void shutdown() {
pdfExecutor.shutdown();
}
private void processPdf(File file) {
System.out.println("处理 PDF:"
+ file.getName());
}
}
使用时:
public class PdfApplication {
public static void main(String[] args) {
PdfTaskManager taskManager =
new PdfTaskManager();
try {
taskManager.submit(
new File("input/a.pdf")
);
taskManager.submit(
new File("input/b.pdf")
);
taskManager.submit(
new File("input/c.pdf")
);
} finally {
taskManager.shutdown();
}
}
}
这里整个程序只创建一个 PdfTaskManager。
所有 PDF 任务都使用它内部的同一个线程池。
可以直接定义成 static 吗
普通 Java 项目里,也有人会这样写:
public class PdfExecutors {
public static final ThreadPoolExecutor PDF_EXECUTOR =
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("pdf-worker"),
new ThreadPoolExecutor.AbortPolicy()
);
private PdfExecutors() {
}
}
然后使用:
PdfExecutors.PDF_EXECUTOR.execute(() -> {
processPdf(file);
});
这种写法能保证整个 JVM 里共用一个线程池。
对于小型工具程序可以使用。
但它也有一些不足:
生命周期不容易管理;
测试时不方便替换;
线程池参数被写死;
依赖关系不够明确;
关闭线程池需要自己处理。
如果项目已经使用 Spring,就没有必要自己维护这种静态全局线程池。
交给 Spring 容器管理更合适。
Spring Boot 项目里应该交给 Spring 管理
Spring Boot 项目中,更合理的方式是:
把线程池定义成 Bean;
由 Spring 在应用启动时创建;
通过依赖注入交给业务类使用;
应用关闭时统一执行销毁逻辑。
业务类不负责创建线程池。
它只负责使用线程池。
结构大概是:
PdfThreadPoolConfig
↓
创建 pdfExecutor Bean
↓
PdfTaskService 注入 pdfExecutor
↓
提交 PDF 任务
例如业务类里只保留:
@Service
public class PdfTaskService {
private final ThreadPoolExecutor pdfExecutor;
public PdfTaskService(
ThreadPoolExecutor pdfExecutor
) {
this.pdfExecutor = pdfExecutor;
}
public void submit(File file) {
pdfExecutor.execute(() -> {
processPdf(file);
});
}
private void processPdf(File file) {
System.out.println("处理 PDF:"
+ file.getName());
}
}
这里的 PdfTaskService 不关心线程池怎么创建。
线程数、队列大小和拒绝策略都放到配置类里统一管理。
下一节会把这一套代码完整写出来。
为什么不建议直接在 Service 里 new
有时会写成:
@Service
public class PdfTaskService {
private final ThreadPoolExecutor pdfExecutor =
new ThreadPoolExecutor(
3,
3,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("pdf-worker"),
new ThreadPoolExecutor.AbortPolicy()
);
}
这比每个方法都 new 好一些。
因为一个 PdfTaskService 默认是单例,线程池通常也只会创建一次。
但我还是更倾向于把线程池单独定义成 Bean。
主要原因是职责更清楚。
PdfTaskService 应该负责:
PDF 任务怎么提交;
PDF 任务怎么处理;
处理结果怎么保存。
线程池配置类负责:
核心线程数;
最大线程数;
任务队列;
线程名称;
拒绝策略;
关闭方式。
如果把线程池直接写在 Service 里,业务逻辑和执行资源配置就混在一起了。
后面需要修改线程池参数时,也要修改业务类。
依赖注入更方便测试
如果线程池通过构造方法注入:
public PdfTaskService(
ThreadPoolExecutor pdfExecutor
) {
this.pdfExecutor = pdfExecutor;
}
测试时可以传入一个不同的执行器。
例如测试环境里使用单线程池:
ThreadPoolExecutor testExecutor =
new ThreadPoolExecutor(
1,
1,
0,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10)
);
PdfTaskService service =
new PdfTaskService(testExecutor);
这样测试结果更容易控制。
如果业务类内部直接写死:
new ThreadPoolExecutor(...)
测试时就很难替换。
这也是依赖注入的价值之一:
业务类依赖线程池;
但不负责创建线程池。
一个项目是不是只能有一个线程池
不是。
一个项目可以有多个线程池。
例如:
PDF 处理线程池;
文件上传线程池;
消息通知线程池;
报表导出线程池。
不同业务任务的特点可能完全不同。
PDF 处理比较耗 CPU、内存和磁盘 IO。
消息通知可能主要等待网络请求。
如果全部共用一个线程池,PDF 任务一旦占满线程,通知任务也会被堵住。
所以可以按业务类型进行适当隔离:
pdfExecutor;
uploadExecutor;
notifyExecutor。
但也不能走到另一个极端。
不要每个 Service、每个方法都创建自己的线程池。
否则线程池数量还是会失控。
我现在的判断是:
任务资源特点明显不同;
任务之间需要故障隔离;
任务优先级和队列策略不同;
才考虑拆成不同线程池。
线程池不是越多越安全
假设项目里有 10 个线程池,每个线程池核心线程数都是 10。
那应用理论上可能长期保留:
100 个核心线程。
如果每个线程池最大线程数又比较大,系统高峰期的线程数量会更多。
所以创建线程池时,不只要看单个线程池配置,还要看整个应用:
一共有多少线程池;
每个线程池有多少核心线程;
最大线程总数是多少;
数据库连接池有多少连接;
外部服务能承受多少并发。
不能只看某一个线程池觉得“3 个线程不多”,却忽略项目里有几十个类似线程池。
线程池应该由谁关闭
线程池由谁创建,通常就应该由谁负责关闭。
普通 Java 程序里:
ThreadPoolExecutor executor =
createExecutor();
try {
// 提交任务
} finally {
executor.shutdown();
}
Spring Boot 项目里,如果线程池由 Spring 创建,就应该让 Spring 在应用关闭时执行销毁逻辑。
业务方法里不要调用:
pdfExecutor.shutdown();
例如下面这种写法是错误的:
public void submitPdf(File file) {
pdfExecutor.execute(() -> {
processPdf(file);
});
pdfExecutor.shutdown();
}
第一次调用后,线程池就进入关闭状态。
第二次再提交任务时,可能直接抛出:
RejectedExecutionException
因为已经关闭的线程池不能重新接收任务。
所以长期共用线程池只能在应用真正关闭时销毁。
shutdown 后不能重新启动
线程池调用:
executor.shutdown();
以后会进入关闭流程。
它不会再接收新任务。
而且线程池没有:
restart()
这种方法。
关闭后不能重新启动,只能重新创建一个新的线程池。
所以业务代码不能随意调用 shutdown()。
应该把关闭权放在真正管理线程池生命周期的地方。
shutdown 和 shutdownNow 的区别
正常关闭一般使用:
executor.shutdown();
含义是:
不再接收新任务;
正在执行的任务继续执行;
队列里的任务继续执行;
全部完成后线程池终止。
强制关闭可以使用:
executor.shutdownNow();
它会:
尝试中断正在执行的任务;
返回队列里还没有开始执行的任务;
不保证正在执行的任务一定立即停止。
应用正常关闭时,我更倾向于先:
shutdown()
等待一段时间。
如果长时间仍未结束,再考虑:
shutdownNow()
线程池应该使用业务名称
线程池定义成公共资源后,命名就更重要了。
例如:
new NamedThreadFactory(
"pdf-watermark-worker"
)
日志会出现:
pdf-watermark-worker-1
pdf-watermark-worker-2
pdf-watermark-worker-3
如果任务跑到了错误的线程池,一看日志就能发现。
例如 PDF 任务日志中出现:
notify-worker-1
就说明线程池使用可能有问题。
业务名称也能帮助我区分不同线程池的状态和监控数据。
不要直接使用一个万能线程池
有些项目会创建一个全局线程池:
globalExecutor
然后所有异步任务都往里面提交:
PDF 处理;
邮件发送;
数据同步;
文件上传;
报表导出。
这样做看起来简单,但容易相互影响。
例如:
100 个 PDF 任务占满队列;
紧急通知任务无法提交;
通知被拒绝或者长时间排队。
所以线程池应该有一定业务边界。
但边界不能过细。
我现在更倾向于按照任务资源类型和重要程度拆分,而不是按照每个类拆分。
队列也是应用级边界
如果线程池是应用级单例,那么:
new ArrayBlockingQueue<>(100)
表示整个应用中的 PDF 任务最多排队 100 个。
这个限制才真正有意义。
如果每次请求都创建一个线程池,每个线程池都有容量为 100 的队列,那么 100 个请求就可能产生:
100 × 100 = 10000 个排队位置。
看起来每个线程池队列都不大,但整个应用仍然可以积压大量任务。
这也是为什么线程池必须统一管理。
只有统一以后,线程数和队列容量才能代表整个业务的承载上限。
CompletableFuture 也应该使用统一线程池
使用 CompletableFuture 时同样如此。
不要在每次业务方法里写:
ThreadPoolExecutor executor =
createPdfExecutor();
CompletableFuture.supplyAsync(
() -> processPdf(file),
executor
);
应该注入已经存在的线程池:
CompletableFuture.supplyAsync(
() -> processPdf(file),
pdfExecutor
);
这样:
普通 Runnable 任务;
Future 任务;
CompletableFuture 任务;
都可以使用同一个 PDF 执行器。
执行边界保持一致。
main 方法中的线程池为什么可以局部创建
前面的练习代码里经常这样写:
public static void main(String[] args) {
ThreadPoolExecutor executor =
createExecutor();
try {
// 提交和等待任务
} finally {
executor.shutdown();
}
}
这是合理的。
因为 main 方法代表整个练习程序的生命周期。
程序启动时创建线程池,程序结束前关闭线程池。
这里虽然线程池是局部变量,但它的生命周期仍然覆盖了整个应用运行过程。
真正不合理的是在一个会被频繁调用的业务方法里创建线程池。
所以关键不只是看线程池写在什么代码位置,还要看:
这个位置会执行多少次;
线程池能否被复用;
谁负责关闭;
它的生命周期是否覆盖整个业务运行期。
我现在的判断方式
判断线程池应该放在哪里时,我会问几个问题。
这个方法会不会被重复调用
如果会,就不能每次在方法内部创建线程池。
多个任务是否应该共享同一个并发上限
如果多个 PDF 任务都应该遵守:
最多 3 个同时处理;
那它们就应该共用同一个 PDF 线程池。
谁负责关闭线程池
如果找不到明确的关闭位置,说明线程池的生命周期设计可能有问题。
线程池配置是否需要统一修改
如果以后要调整:
核心线程数;
队列大小;
拒绝策略;
最好只修改一个配置类,而不是在多个 Service 中到处查找。
是否需要被 Spring 管理
在 Spring Boot 项目里,通常应该把长期使用的线程池交给 Spring 管理。
这样创建、注入和销毁会更统一。
一个错误和正确结构的对比
错误结构:
@Service
public class PdfTaskService {
public void submit(File file) {
ThreadPoolExecutor executor =
createExecutor();
executor.execute(() -> {
processPdf(file);
});
}
}
问题是每次调用都创建新线程池。
正确方向:
@Service
public class PdfTaskService {
private final ThreadPoolExecutor pdfExecutor;
public PdfTaskService(
ThreadPoolExecutor pdfExecutor
) {
this.pdfExecutor = pdfExecutor;
}
public void submit(File file) {
pdfExecutor.execute(() -> {
processPdf(file);
});
}
}
线程池由外部创建并注入。
PdfTaskService 只负责提交任务。
这一节小结
这一节我主要记住几点:
1. 线程池是长期存在的执行资源,不应该每个任务都重新创建;
2. 每次 new 线程池会导致线程数量、队列数量和资源占用失控;
3. 普通 Java 程序可以由应用级管理类持有线程池;
4. Spring Boot 项目更适合把线程池定义成 Bean;
5. 业务 Service 应该使用线程池,而不是负责创建线程池;
6. 线程池由谁创建,通常就应该由谁负责关闭;
7. 共用线程池不能在单次业务方法结束时 shutdown;
8. 一个项目可以有多个线程池,但不能无限拆分;
9. 统一线程池以后,线程数、队列容量和拒绝策略才真正代表业务边界。
用一句话总结:
线程池应该跟着应用一起创建和销毁,而不是跟着每一次任务一起创建和销毁。
下一节继续把 PDF 处理线程池定义成 Spring Boot Bean。
会完整整理:
@Configuration 配置类;
@Bean 创建线程池;
@Qualifier 区分不同线程池;
Service 中注入线程池;
CompletableFuture 使用线程池;
应用关闭时安全销毁。