跳到正文
hello world

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 使用线程池;

应用关闭时安全销毁。