跳到正文
hello world

43. Spring Boot 中把线程池定义成 Bean

发布于阅读量 2

43. Spring Boot 中把线程池定义成 Bean

上一节已经明确了:

线程池应该跟着应用一起创建和销毁;

不能在每次业务调用时重新创建。

在 Spring Boot 项目中,比较合适的做法是把线程池交给 Spring 容器管理。

整体结构是:

配置类创建线程池 Bean;

Spring 容器保存线程池对象;

Service 通过构造方法注入线程池;

业务方法只负责提交任务;

应用关闭时由 Spring 销毁线程池。

这一节会完整实现:

@Configuration 配置类;

@Bean 创建线程池;

@Qualifier 区分线程池;

Service 注入线程池;

CompletableFuture 使用线程池;

应用关闭时安全销毁。

为什么要把线程池定义成 Bean

如果在线程池配置类中定义:

@Bean
public ThreadPoolExecutor pdfExecutor() {
    return new ThreadPoolExecutor(...);
}

Spring 启动时会调用这个方法,创建线程池对象。

之后其他类只要注入这个 Bean,就能使用同一个线程池。

也就是说:

线程池只创建一次;

所有 PDF 任务共用一个线程池;

线程数量和队列容量统一控制;

应用关闭时统一销毁。

这样线程池就真正成为了应用级资源。


第一步:创建线程工厂

先准备一个自定义线程工厂,用来设置线程名称。

新建:

com.succos.config.NamedThreadFactory

代码如下:

package com.succos.config;

import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

public class NamedThreadFactory implements ThreadFactory {

    private final String threadNamePrefix;

    private final AtomicInteger threadNumber =
            new AtomicInteger(1);

    public NamedThreadFactory(
            String threadNamePrefix
    ) {
        this.threadNamePrefix =
                threadNamePrefix;
    }

    @Override
    public Thread newThread(Runnable runnable) {

        Thread thread =
                new Thread(runnable);

        thread.setName(
                threadNamePrefix
                        + "-"
                        + threadNumber.getAndIncrement()
        );

        thread.setDaemon(false);

        thread.setUncaughtExceptionHandler(
                (currentThread, throwable) -> {

                    System.err.println(
                            "线程发生未捕获异常,thread="
                                    + currentThread.getName()
                    );

                    throwable.printStackTrace();
                }
        );

        return thread;
    }
}

最终线程名称类似:

pdf-worker-1
pdf-worker-2
pdf-worker-3

看到日志时,就知道任务来自 PDF 线程池。


第二步:创建线程池配置类

新建:

com.succos.config.ThreadPoolConfig

代码如下:

package com.succos.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Configuration
public class ThreadPoolConfig {

    @Bean(
            name = "pdfExecutor",
            destroyMethod = "shutdown"
    )
    public ThreadPoolExecutor pdfExecutor() {

        return new ThreadPoolExecutor(
                3,
                3,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                new NamedThreadFactory(
                        "pdf-worker"
                ),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }
}

这里最重要的是:

@Configuration

和:

@Bean(name = "pdfExecutor")

@Configuration 做了什么

@Configuration 表示:

这是一个 Spring 配置类;

类中可以定义需要交给 Spring 管理的 Bean。

Spring 启动时会扫描这个类。

发现:

@Bean
public ThreadPoolExecutor pdfExecutor()

以后,会调用这个方法创建线程池,并把返回的对象保存到 Spring 容器中。

因此,其他 Spring Bean 可以注入这个线程池。


@Bean 做了什么

@Bean 表示:

这个方法的返回值交给 Spring 管理。

例如:

@Bean(name = "pdfExecutor")
public ThreadPoolExecutor pdfExecutor() {
    return new ThreadPoolExecutor(...);
}

Spring 管理的是方法返回的:

ThreadPoolExecutor

而不是配置类方法本身。

Bean 的名称是:

pdfExecutor

默认情况下,如果不写:

name = "pdfExecutor"

Bean 名称也会使用方法名:

pdfExecutor

所以这两种写法效果相近:

@Bean
public ThreadPoolExecutor pdfExecutor() {
    return new ThreadPoolExecutor(...);
}

以及:

@Bean(name = "pdfExecutor")
public ThreadPoolExecutor createPdfExecutor() {
    return new ThreadPoolExecutor(...);
}

第二种通过 name 明确指定 Bean 名称。

我更倾向于让方法名和 Bean 名称保持一致:

@Bean(name = "pdfExecutor")
public ThreadPoolExecutor pdfExecutor()

看起来更直观。


destroyMethod = "shutdown" 做了什么

配置中写了:

@Bean(
        name = "pdfExecutor",
        destroyMethod = "shutdown"
)

表示 Spring 容器关闭时,会调用线程池的:

shutdown()

这样应用停止时,线程池会进入正常关闭流程:

不再接收新任务;

正在执行的任务继续执行;

队列中的任务继续执行;

所有任务完成后,线程池终止。

业务 Service 中不需要手动调用:

pdfExecutor.shutdown();

线程池的关闭由 Spring 统一负责。


为什么业务方法不能调用 shutdown

假设 Service 中写了:

public void submitPdf(File file) {

    pdfExecutor.execute(() -> {
        processPdf(file);
    });

    pdfExecutor.shutdown();
}

第一次请求提交完成后,线程池就开始关闭。

第二次请求再调用:

pdfExecutor.execute(...)

就可能抛出:

RejectedExecutionException

因为已经关闭的线程池不能再接收任务。

所以业务代码只能:

提交任务;

查询线程池状态;

不能随意关闭公共线程池。


第三步:在 Service 中注入线程池

新建:

com.succos.service.PdfTaskService

代码如下:

package com.succos.service;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.concurrent.ThreadPoolExecutor;

@Service
public class PdfTaskService {

    private final ThreadPoolExecutor pdfExecutor;

    public PdfTaskService(
            @Qualifier("pdfExecutor")
            ThreadPoolExecutor pdfExecutor
    ) {
        this.pdfExecutor = pdfExecutor;
    }

    public void submitPdf(File file) {

        pdfExecutor.execute(() -> {
            processPdf(file);
        });
    }

    private void processPdf(File file) {

        System.out.println(
                Thread.currentThread().getName()
                        + " 开始处理:"
                        + file.getName()
        );

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();

            throw new RuntimeException(
                    "PDF 任务被中断",
                    e
            );
        }

        System.out.println(
                Thread.currentThread().getName()
                        + " 处理完成:"
                        + file.getName()
        );
    }
}

这里使用的是构造方法注入。


为什么推荐构造方法注入

构造方法注入写法是:

private final ThreadPoolExecutor pdfExecutor;

public PdfTaskService(
        @Qualifier("pdfExecutor")
        ThreadPoolExecutor pdfExecutor
) {
    this.pdfExecutor = pdfExecutor;
}

它有几个好处:

依赖关系清楚;

字段可以声明为 final;

对象创建时就必须提供依赖;

更方便写单元测试;

不依赖字段反射注入。

我不太建议写成:

@Autowired
private ThreadPoolExecutor pdfExecutor;

这种字段注入虽然代码少,但依赖关系不够明显。

构造方法注入更适合长期维护。


@Qualifier 有什么作用

现在配置类里只有一个 ThreadPoolExecutor Bean:

@Bean(name = "pdfExecutor")

这种情况下,Spring 通常可以完成注入。

但以后很可能还会增加:

uploadExecutor;

notifyExecutor;

reportExecutor。

它们的类型都是:

ThreadPoolExecutor

这时仅根据类型注入就无法判断应该使用哪一个。

例如:

public PdfTaskService(
        ThreadPoolExecutor executor
) {
}

Spring 会发现容器里有多个 ThreadPoolExecutor

pdfExecutor;

uploadExecutor;

notifyExecutor。

于是可能报错:

NoUniqueBeanDefinitionException

所以应该使用:

@Qualifier("pdfExecutor")

明确告诉 Spring:

我要注入名称为 pdfExecutor 的线程池。

多个线程池的配置方式

假设系统中还需要文件上传线程池,可以继续在配置类中定义:

@Bean(
        name = "uploadExecutor",
        destroyMethod = "shutdown"
)
public ThreadPoolExecutor uploadExecutor() {

    return new ThreadPoolExecutor(
            5,
            10,
            60,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(200),
            new NamedThreadFactory(
                    "upload-worker"
            ),
            new ThreadPoolExecutor.AbortPolicy()
    );
}

完整配置类如下:

package com.succos.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

@Configuration
public class ThreadPoolConfig {

    @Bean(
            name = "pdfExecutor",
            destroyMethod = "shutdown"
    )
    public ThreadPoolExecutor pdfExecutor() {

        return new ThreadPoolExecutor(
                3,
                3,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(100),
                new NamedThreadFactory(
                        "pdf-worker"
                ),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }

    @Bean(
            name = "uploadExecutor",
            destroyMethod = "shutdown"
    )
    public ThreadPoolExecutor uploadExecutor() {

        return new ThreadPoolExecutor(
                5,
                10,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(200),
                new NamedThreadFactory(
                        "upload-worker"
                ),
                new ThreadPoolExecutor.AbortPolicy()
        );
    }
}

此时业务类必须明确指定线程池。

PDF Service:

public PdfTaskService(
        @Qualifier("pdfExecutor")
        ThreadPoolExecutor pdfExecutor
) {
    this.pdfExecutor = pdfExecutor;
}

上传 Service:

public UploadService(
        @Qualifier("uploadExecutor")
        ThreadPoolExecutor uploadExecutor
) {
    this.uploadExecutor = uploadExecutor;
}

这样不同业务使用不同线程池。


第四步:使用 CompletableFuture 提交 PDF 任务

前面的 submitPdf() 使用了:

pdfExecutor.execute(...)

如果任务需要返回结果,可以改成:

CompletableFuture.supplyAsync(...)

例如:

package com.succos.service;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;

@Service
public class PdfTaskService {

    private final ThreadPoolExecutor pdfExecutor;

    public PdfTaskService(
            @Qualifier("pdfExecutor")
            ThreadPoolExecutor pdfExecutor
    ) {
        this.pdfExecutor = pdfExecutor;
    }

    public CompletableFuture<String> processPdfAsync(
            File file
    ) {
        return CompletableFuture.supplyAsync(
                () -> processPdf(file),
                pdfExecutor
        );
    }

    private String processPdf(File file) {

        System.out.println(
                Thread.currentThread().getName()
                        + " 开始处理:"
                        + file.getName()
        );

        sleep(3000);

        String targetPath =
                "output/"
                        + file.getName()
                        .replace(
                                ".pdf",
                                "-watermark.pdf"
                        );

        System.out.println(
                Thread.currentThread().getName()
                        + " 处理完成:"
                        + targetPath
        );

        return targetPath;
    }

    private void sleep(long millis) {

        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();

            throw new RuntimeException(
                    "PDF 任务被中断",
                    e
            );
        }
    }
}

这里最重要的是:

CompletableFuture.supplyAsync(
        () -> processPdf(file),
        pdfExecutor
)

第二个参数明确传入了 Spring 管理的:

pdfExecutor

所以 PDF 任务不会跑到默认公共线程池。


为什么一定要传入 pdfExecutor

如果写成:

CompletableFuture.supplyAsync(() -> {
    return processPdf(file);
});

没有传执行器,就会默认使用:

ForkJoinPool.commonPool

线程名称可能是:

ForkJoinPool.commonPool-worker-1

这会失去自己配置的:

线程数量;

任务队列;

线程名称;

拒绝策略;

业务隔离。

正确写法是:

CompletableFuture.supplyAsync(
        () -> processPdf(file),
        pdfExecutor
);

日志中的线程名称会变成:

pdf-worker-1

这说明任务使用的是 PDF 专用线程池。


第五步:返回统一结果对象

真实项目里,不建议只返回文件路径。

可以定义:

PdfTaskResult

例如:

package com.succos.dto;

public class PdfTaskResult {

    private final boolean success;

    private final String fileName;

    private final String targetPath;

    private final String message;

    private PdfTaskResult(
            boolean success,
            String fileName,
            String targetPath,
            String message
    ) {
        this.success = success;
        this.fileName = fileName;
        this.targetPath = targetPath;
        this.message = message;
    }

    public static PdfTaskResult success(
            String fileName,
            String targetPath
    ) {
        return new PdfTaskResult(
                true,
                fileName,
                targetPath,
                "处理成功"
        );
    }

    public static PdfTaskResult fail(
            String fileName,
            String message
    ) {
        return new PdfTaskResult(
                false,
                fileName,
                null,
                message
        );
    }

    public boolean isSuccess() {
        return success;
    }

    public String getFileName() {
        return fileName;
    }

    public String getTargetPath() {
        return targetPath;
    }

    public String getMessage() {
        return message;
    }
}

Service 中改成:

public CompletableFuture<PdfTaskResult>
processPdfAsync(File file) {

    return CompletableFuture
            .supplyAsync(
                    () -> {

                        String targetPath =
                                processPdf(file);

                        return PdfTaskResult.success(
                                file.getName(),
                                targetPath
                        );
                    },
                    pdfExecutor
            )
            .exceptionally(ex -> {
                return PdfTaskResult.fail(
                        file.getName(),
                        getErrorMessage(ex)
                );
            });
}

这样任务失败后也会返回一个明确结果。


增加超时控制

可以继续增加:

orTimeout(...)

完整方法如下:

public CompletableFuture<PdfTaskResult>
processPdfAsync(File file) {

    return CompletableFuture
            .supplyAsync(
                    () -> {

                        String targetPath =
                                processPdf(file);

                        return PdfTaskResult.success(
                                file.getName(),
                                targetPath
                        );
                    },
                    pdfExecutor
            )
            .orTimeout(
                    30,
                    TimeUnit.SECONDS
            )
            .exceptionally(ex -> {

                Throwable cause =
                        getRootCause(ex);

                if (cause
                        instanceof TimeoutException) {

                    return PdfTaskResult.fail(
                            file.getName(),
                            "PDF 处理超过 30 秒"
                    );
                }

                return PdfTaskResult.fail(
                        file.getName(),
                        getErrorMessage(ex)
                );
            });
}

需要导入:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

这样:

任务正常完成,返回成功结果;

任务普通异常,返回失败结果;

任务超过 30 秒,返回超时失败结果。

完整的 PdfTaskService

把前面的内容放到一起:

package com.succos.service;

import com.succos.dto.PdfTaskResult;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;

import java.io.File;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

@Service
public class PdfTaskService {

    private final ThreadPoolExecutor pdfExecutor;

    public PdfTaskService(
            @Qualifier("pdfExecutor")
            ThreadPoolExecutor pdfExecutor
    ) {
        this.pdfExecutor = pdfExecutor;
    }

    public CompletableFuture<PdfTaskResult>
    processPdfAsync(File file) {

        long start =
                System.currentTimeMillis();

        return CompletableFuture
                .supplyAsync(
                        () -> {

                            String targetPath =
                                    processPdf(file);

                            return PdfTaskResult.success(
                                    file.getName(),
                                    targetPath
                            );
                        },
                        pdfExecutor
                )
                .orTimeout(
                        30,
                        TimeUnit.SECONDS
                )
                .whenComplete((result, ex) -> {

                    long cost =
                            System.currentTimeMillis()
                                    - start;

                    if (ex != null) {
                        System.err.println(
                                file.getName()
                                        + " 处理异常,耗时:"
                                        + cost
                                        + " ms,原因:"
                                        + getErrorMessage(ex)
                        );
                    } else {
                        System.out.println(
                                file.getName()
                                        + " 处理结束,耗时:"
                                        + cost
                                        + " ms"
                        );
                    }
                })
                .exceptionally(ex -> {

                    Throwable cause =
                            getRootCause(ex);

                    if (cause
                            instanceof TimeoutException) {

                        return PdfTaskResult.fail(
                                file.getName(),
                                "PDF 处理超过 30 秒"
                        );
                    }

                    return PdfTaskResult.fail(
                            file.getName(),
                            getErrorMessage(ex)
                    );
                });
    }

    private String processPdf(File file) {

        validateFile(file);

        System.out.println(
                Thread.currentThread().getName()
                        + " 开始处理:"
                        + file.getName()
        );

        sleep(3000);

        String targetPath =
                "output/"
                        + file.getName()
                        .replace(
                                ".pdf",
                                "-watermark.pdf"
                        );

        System.out.println(
                Thread.currentThread().getName()
                        + " 处理完成:"
                        + targetPath
        );

        return targetPath;
    }

    private void validateFile(File file) {

        if (file == null) {
            throw new IllegalArgumentException(
                    "PDF 文件不能为空"
            );
        }

        if (!file.exists()) {
            throw new IllegalArgumentException(
                    "PDF 文件不存在"
            );
        }

        if (!file.isFile()) {
            throw new IllegalArgumentException(
                    "PDF 路径不是文件"
            );
        }

        if (!file.canRead()) {
            throw new IllegalArgumentException(
                    "PDF 文件不可读取"
            );
        }
    }

    private void sleep(long millis) {

        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();

            throw new RuntimeException(
                    "PDF 任务被中断",
                    e
            );
        }
    }

    private Throwable getRootCause(
            Throwable throwable
    ) {
        Throwable cause = throwable;

        while (cause.getCause() != null) {
            cause = cause.getCause();
        }

        return cause;
    }

    private String getErrorMessage(
            Throwable throwable
    ) {
        Throwable cause =
                getRootCause(throwable);

        String message =
                cause.getMessage();

        if (message == null
                || message.isBlank()) {

            return cause
                    .getClass()
                    .getSimpleName();
        }

        return message;
    }
}

现在这个 Service 已经具备:

使用 Spring 线程池;

返回 CompletableFuture;

正常结果转换;

异常兜底;

超时控制;

任务耗时日志。

第六步:在 Controller 中调用 Service

新建:

com.succos.controller.PdfTaskController

为了先演示线程池调用,这里暂时使用固定文件路径:

package com.succos.controller;

import com.succos.dto.PdfTaskResult;
import com.succos.service.PdfTaskService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.io.File;
import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("/pdf")
public class PdfTaskController {

    private final PdfTaskService pdfTaskService;

    public PdfTaskController(
            PdfTaskService pdfTaskService
    ) {
        this.pdfTaskService =
                pdfTaskService;
    }

    @PostMapping("/watermark")
    public CompletableFuture<PdfTaskResult>
    addWatermark(
            @RequestParam String fileName
    ) {
        File file =
                new File(
                        "input",
                        fileName
                );

        return pdfTaskService
                .processPdfAsync(file);
    }
}

请求:

POST /pdf/watermark?fileName=test.pdf

Controller 会直接返回:

CompletableFuture<PdfTaskResult>

Spring MVC 会在异步任务完成后,把 PdfTaskResult 序列化为 JSON。


Controller 返回 CompletableFuture 会发生什么

当 Controller 返回:

CompletableFuture<PdfTaskResult>

请求线程不会在 Controller 中手动调用:

future.join();

而是把 CompletableFuture 交给 Spring MVC。

大致流程是:

HTTP 请求进入 Controller;

Controller 调用 Service;

Service 提交 PDF 异步任务;

Controller 返回 CompletableFuture;

PDF 任务在线程池中执行;

任务完成后,Spring 返回结果给客户端。

这样 Controller 不需要自己阻塞等待结果。


不要在 Controller 中立即 join

下面这种写法虽然能运行:

@PostMapping("/watermark")
public PdfTaskResult addWatermark(
        @RequestParam String fileName
) {
    File file =
            new File("input", fileName);

    return pdfTaskService
            .processPdfAsync(file)
            .join();
}

但这里调用了:

join()

请求线程仍然会一直等待 PDF 处理完成。

虽然 PDF 任务跑在线程池中,但接口线程被阻塞了。

如果希望使用 Spring MVC 对 CompletableFuture 的异步返回支持,可以直接返回:

CompletableFuture<PdfTaskResult>

而不是马上 join()


直接返回结果和返回任务 ID 的区别

上面的接口虽然使用了异步线程池,但客户端仍然要等 PDF 处理完成后才能收到结果。

只是等待过程由 Spring 管理,而不是 Controller 手动 join()

流程仍然是:

提交请求;

等待 PDF 处理完成;

返回最终结果。

如果 PDF 可能处理几十秒甚至几分钟,更合理的方式通常是:

接口接收任务;

立即返回任务 ID;

后台继续处理 PDF;

客户端根据任务 ID 查询进度。

这属于真正的后台任务系统。

后面的章节可以继续实现:

任务提交接口;

任务状态保存;

任务进度查询;

成功失败结果查询。

这一节先把线程池 Bean 和 CompletableFuture 注入方式搞清楚。


第七步:观察线程池是否被复用

连续请求三次:

POST /pdf/watermark?fileName=a.pdf

POST /pdf/watermark?fileName=b.pdf

POST /pdf/watermark?fileName=c.pdf

日志可能是:

pdf-worker-1 开始处理:a.pdf
pdf-worker-2 开始处理:b.pdf
pdf-worker-3 开始处理:c.pdf

再次请求:

POST /pdf/watermark?fileName=d.pdf

可能看到:

pdf-worker-1 开始处理:d.pdf

线程名称再次出现 pdf-worker-1,说明线程被复用了。

系统不是每次请求都创建一个新线程池或新线程。

这正是线程池存在的意义。


队列满了会发生什么

当前配置:

new ThreadPoolExecutor(
        3,
        3,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(100),
        new NamedThreadFactory("pdf-worker"),
        new ThreadPoolExecutor.AbortPolicy()
)

表示:

最多 3 个任务同时执行;

最多 100 个任务排队;

第 104 个未完成任务提交时,可能被拒绝。

任务被拒绝时会抛出:

RejectedExecutionException

如果不处理,这个异常可能直接从:

CompletableFuture.supplyAsync(...)

的调用位置抛出来。

因为任务甚至没有成功提交到线程池中。


处理任务提交被拒绝

可以在调用 supplyAsync() 时增加捕获:

public CompletableFuture<PdfTaskResult>
processPdfAsync(File file) {

    try {
        return CompletableFuture
                .supplyAsync(
                        () -> {

                            String targetPath =
                                    processPdf(file);

                            return PdfTaskResult.success(
                                    file.getName(),
                                    targetPath
                            );
                        },
                        pdfExecutor
                )
                .orTimeout(
                        30,
                        TimeUnit.SECONDS
                )
                .exceptionally(ex -> {
                    return PdfTaskResult.fail(
                            file.getName(),
                            getErrorMessage(ex)
                    );
                });

    } catch (RejectedExecutionException e) {

        PdfTaskResult result =
                PdfTaskResult.fail(
                        file.getName(),
                        "PDF 任务过多,请稍后重试"
                );

        return CompletableFuture
                .completedFuture(result);
    }
}

需要导入:

import java.util.concurrent.RejectedExecutionException;

这里要注意:

线程池拒绝发生在任务提交阶段;

不一定进入 CompletableFuture 的 exceptionally。

所以可以在 supplyAsync() 外层单独捕获。


completedFuture 是什么

这里使用了:

CompletableFuture.completedFuture(result)

它会创建一个已经完成的 CompletableFuture

例如:

PdfTaskResult result =
        PdfTaskResult.fail(
                file.getName(),
                "任务提交失败"
        );

return CompletableFuture
        .completedFuture(result);

调用方仍然能收到:

CompletableFuture<PdfTaskResult>

只是这个 Future 不需要等待,里面已经有结果了。

这样方法的返回类型保持一致。


加入拒绝处理后的完整方法

public CompletableFuture<PdfTaskResult>
processPdfAsync(File file) {

    long start =
            System.currentTimeMillis();

    try {
        return CompletableFuture
                .supplyAsync(
                        () -> {

                            String targetPath =
                                    processPdf(file);

                            return PdfTaskResult.success(
                                    file.getName(),
                                    targetPath
                            );
                        },
                        pdfExecutor
                )
                .orTimeout(
                        30,
                        TimeUnit.SECONDS
                )
                .whenComplete((result, ex) -> {

                    long cost =
                            System.currentTimeMillis()
                                    - start;

                    if (ex != null) {
                        System.err.println(
                                file.getName()
                                        + " 执行异常,耗时:"
                                        + cost
                                        + " ms"
                        );
                    } else {
                        System.out.println(
                                file.getName()
                                        + " 执行完成,耗时:"
                                        + cost
                                        + " ms"
                        );
                    }
                })
                .exceptionally(ex -> {

                    Throwable cause =
                            getRootCause(ex);

                    if (cause
                            instanceof TimeoutException) {

                        return PdfTaskResult.fail(
                                file.getName(),
                                "PDF 处理超过 30 秒"
                        );
                    }

                    return PdfTaskResult.fail(
                            file.getName(),
                            getErrorMessage(ex)
                    );
                });

    } catch (RejectedExecutionException e) {

        System.err.println(
                "PDF 任务提交被拒绝,file="
                        + file.getName()
        );

        return CompletableFuture
                .completedFuture(
                        PdfTaskResult.fail(
                                file.getName(),
                                "当前 PDF 任务过多,请稍后重试"
                        )
                );
    }
}

这样任务可能出现的主要状态都有了处理:

任务成功;

任务执行失败;

任务执行超时;

线程池拒绝任务。

能不能把 Bean 类型写成 Executor

可以。

例如配置类:

@Bean(name = "pdfExecutor")
public Executor pdfExecutor() {

    return new ThreadPoolExecutor(
            3,
            3,
            60,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100),
            new NamedThreadFactory("pdf-worker"),
            new ThreadPoolExecutor.AbortPolicy()
    );
}

Service 中:

private final Executor pdfExecutor;

public PdfTaskService(
        @Qualifier("pdfExecutor")
        Executor pdfExecutor
) {
    this.pdfExecutor = pdfExecutor;
}

CompletableFuture.supplyAsync() 接收的就是:

Executor

所以这样可以正常使用。

好处是业务类只依赖更抽象的接口:

Executor

不直接依赖具体的:

ThreadPoolExecutor

什么时候注入 ThreadPoolExecutor

如果业务类只负责提交任务,注入:

Executor

通常已经够了。

如果业务类还需要读取线程池状态:

pdfExecutor.getActiveCount();

pdfExecutor.getQueue().size();

pdfExecutor.getCompletedTaskCount();

那就需要注入:

ThreadPoolExecutor

不过线程池监控最好单独放到监控类中,不要让普通业务 Service 承担太多线程池管理职责。

这一阶段为了学习线程池的具体参数,可以继续使用:

ThreadPoolExecutor

Spring 容器中 Bean 的生命周期

这一版线程池的生命周期大致是:

1. Spring Boot 应用启动;

2. Spring 扫描 ThreadPoolConfig;

3. 调用 pdfExecutor();

4. 创建 ThreadPoolExecutor;

5. 保存为名称为 pdfExecutor 的 Bean;

6. 创建 PdfTaskService;

7. 把 pdfExecutor 注入 PdfTaskService;

8. 业务运行期间不断复用线程池;

9. Spring Boot 应用关闭;

10. Spring 调用 pdfExecutor.shutdown()。

整个过程由 Spring 管理。

业务代码只需要提交任务。


常见错误一:手动调用配置类方法

配置类是:

@Configuration
public class ThreadPoolConfig {

    @Bean
    public ThreadPoolExecutor pdfExecutor() {
        return new ThreadPoolExecutor(...);
    }
}

不要在业务类里自己写:

ThreadPoolConfig config =
        new ThreadPoolConfig();

ThreadPoolExecutor executor =
        config.pdfExecutor();

因为这里的:

new ThreadPoolConfig()

不是由 Spring 管理的配置类对象。

直接调用方法可能重新创建一个线程池,绕过 Spring 容器。

正确方式是注入 Bean:

public PdfTaskService(
        @Qualifier("pdfExecutor")
        ThreadPoolExecutor pdfExecutor
)

不要自己创建配置类。


常见错误二:直接 new Service

同样不要写:

PdfTaskService service =
        new PdfTaskService(...);

在 Controller 中应该通过构造方法注入:

public PdfTaskController(
        PdfTaskService pdfTaskService
) {
    this.pdfTaskService =
            pdfTaskService;
}

这样 PdfTaskService 和它依赖的线程池都由 Spring 管理。


常见错误三:Bean 名称写错

配置类中是:

@Bean(name = "pdfExecutor")

注入时却写成:

@Qualifier("pdfThreadPool")

Spring 就找不到对应 Bean。

名称必须一致:

@Qualifier("pdfExecutor")

为了减少字符串写错,也可以定义常量:

public final class ExecutorNames {

    public static final String PDF_EXECUTOR =
            "pdfExecutor";

    public static final String UPLOAD_EXECUTOR =
            "uploadExecutor";

    private ExecutorNames() {
    }
}

然后:

@Bean(name = ExecutorNames.PDF_EXECUTOR)

以及:

@Qualifier(ExecutorNames.PDF_EXECUTOR)

不过小项目中直接使用明确名称也可以。


常见错误四:把线程池配置得过大

比如:

new ThreadPoolExecutor(
        100,
        500,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(10000),
        ...
)

不代表系统性能一定更好。

PDF 任务本身可能消耗:

CPU;

内存;

磁盘 IO;

文件句柄。

线程太多可能导致:

CPU 上下文切换增加;

内存占用升高;

磁盘读写争用;

应用整体响应变慢;

大量任务同时失败。

线程池大小应该结合任务性质和服务器资源确定。

学习阶段使用:

核心线程数 3;

最大线程数 3;

队列容量 100。

便于观察执行过程。


常见错误五:在所有异步步骤中忘记指定线程池

例如:

CompletableFuture
        .supplyAsync(
                () -> processPdf(file),
                pdfExecutor
        )
        .thenApplyAsync(path -> {
            return upload(path);
        });

第一步使用:

pdfExecutor

第二步的 thenApplyAsync() 没有指定线程池,会使用默认公共线程池。

如果上传任务应该使用上传线程池,应该写成:

CompletableFuture
        .supplyAsync(
                () -> processPdf(file),
                pdfExecutor
        )
        .thenApplyAsync(
                path -> upload(path),
                uploadExecutor
        );

或者上传方法本身返回 CompletableFuture

.thenCompose(
        path -> uploadAsync(
                path,
                uploadExecutor
        )
)

不能认为整条链会自动一直使用第一个线程池。


最终项目结构

这一节完成后,项目大致是:

src/main/java/com/succos
├── PdfApplication.java
├── config
│   ├── NamedThreadFactory.java
│   └── ThreadPoolConfig.java
├── controller
│   └── PdfTaskController.java
├── dto
│   └── PdfTaskResult.java
└── service
    └── PdfTaskService.java

职责划分如下。

ThreadPoolConfig

创建线程池;

配置线程数;

配置任务队列;

配置线程名称;

配置拒绝策略;

配置销毁方法。

PdfTaskService

提交 PDF 异步任务;

执行 PDF 业务逻辑;

处理异常和超时;

返回任务结果。

PdfTaskController

接收 HTTP 请求;

调用 PdfTaskService;

把异步结果交给 Spring MVC 返回。

PdfTaskResult

统一表示任务成功或失败状态。

这一节小结

这一节我主要记住几点:

1. Spring Boot 中可以通过 @Configuration 和 @Bean 管理线程池;
2. 线程池 Bean 在应用启动时创建,在业务运行期间重复使用;
3. @Qualifier 用来从多个同类型 Bean 中选择指定线程池;
4. Service 应通过构造方法注入线程池;
5. CompletableFuture 应显式使用 Spring 管理的业务线程池;
6. 公共线程池不能在单次业务方法中调用 shutdown;
7. destroyMethod = "shutdown" 可以让 Spring 在应用关闭时销毁线程池;
8. 线程池拒绝任务时,要处理 RejectedExecutionException;
9. Controller 可以直接返回 CompletableFuture,让 Spring MVC 处理异步结果。

用一句话总结:

配置类负责创建线程池,Spring 负责管理生命周期,Service 只负责使用线程池执行任务。

下一节继续学习 Spring 提供的:

ThreadPoolTaskExecutor

它本质上还是对线程池的封装,但和 Spring 的结合更自然,并且经常配合:

@Async

实现异步方法调用。

43. Spring Boot 中把线程池定义成 Bean - Java并发之从Thread到CompletableFuture - 上下文网