40. CompletableFuture 批量处理 PDF 最终版
发布于 • 阅读量 0
40. CompletableFuture 批量处理 PDF 最终版
前面已经分别学过:
自定义线程池;
supplyAsync;
allOf;
join;
结果对象;
异常处理;
超时控制;
线程命名;
成功失败统计。
这一节把这些内容放到一起,整理出一个相对完整的 PDF 批量处理版本。
这版代码的目标是:
读取 input 目录下的全部 PDF;
每个 PDF 创建一个 CompletableFuture;
所有任务使用自定义 PDF 线程池;
同一时间最多处理 3 个 PDF;
每个 PDF 最多处理 30 秒;
单个文件失败,不影响其他文件;
等待整批任务全部结束;
统计成功、失败和超时数量;
最后关闭线程池。
这不是一套完整的生产级任务系统,但作为这个并发练习项目的最终版本,已经把主要问题串起来了。
项目结构
这一版主要使用以下类:
src/main/java
└── com/succos
├── completablefuture
│ └── CompletableFuturePdfFinalDemo.java
├── dto
│ ├── FileItemContext.java
│ └── PdfTaskResult.java
├── service
│ └── PdfWatermarkService.java
└── threadpool
└── NamedThreadFactory.java
目录仍然是:
pdf-watermark
├── input
├── output
├── pom.xml
└── src
input 用来存放原始 PDF。
output 用来存放添加水印后的 PDF。
第一步:定义 PDF 任务结果对象
批量任务不适合只返回一个字符串。
因为我不仅想知道输出路径,还需要知道:
文件名;
是否成功;
是否超时;
输出路径;
失败原因;
任务耗时。
所以先定义一个结果对象。
新建:
com.succos.dto.PdfTaskResult
代码如下:
package com.succos.dto;
public class PdfTaskResult {
private boolean success;
private boolean timeout;
private String fileName;
private String sourcePath;
private String targetPath;
private String message;
private long costMillis;
public static PdfTaskResult success(
String fileName,
String sourcePath,
String targetPath,
long costMillis
) {
PdfTaskResult result = new PdfTaskResult();
result.success = true;
result.timeout = false;
result.fileName = fileName;
result.sourcePath = sourcePath;
result.targetPath = targetPath;
result.message = "处理成功";
result.costMillis = costMillis;
return result;
}
public static PdfTaskResult fail(
String fileName,
String sourcePath,
String message,
long costMillis
) {
PdfTaskResult result = new PdfTaskResult();
result.success = false;
result.timeout = false;
result.fileName = fileName;
result.sourcePath = sourcePath;
result.message = message;
result.costMillis = costMillis;
return result;
}
public static PdfTaskResult timeout(
String fileName,
String sourcePath,
String message,
long costMillis
) {
PdfTaskResult result = new PdfTaskResult();
result.success = false;
result.timeout = true;
result.fileName = fileName;
result.sourcePath = sourcePath;
result.message = message;
result.costMillis = costMillis;
return result;
}
public boolean isSuccess() {
return success;
}
public boolean isTimeout() {
return timeout;
}
public String getFileName() {
return fileName;
}
public String getSourcePath() {
return sourcePath;
}
public String getTargetPath() {
return targetPath;
}
public String getMessage() {
return message;
}
public long getCostMillis() {
return costMillis;
}
@Override
public String toString() {
return "PdfTaskResult{" +
"success=" + success +
", timeout=" + timeout +
", fileName='" + fileName + '\'' +
", sourcePath='" + sourcePath + '\'' +
", targetPath='" + targetPath + '\'' +
", message='" + message + '\'' +
", costMillis=" + costMillis +
'}';
}
}
这个类里没有直接抛异常,而是把任务状态明确表示出来。
后面汇总时就能直接判断:
result.isSuccess()
result.isTimeout()
第二步:定义通用线程工厂
为了让日志更清楚,我继续使用自定义线程名。
新建:
com.succos.threadpool.NamedThreadFactory
代码如下:
package com.succos.threadpool;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
public class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicInteger threadNumber =
new AtomicInteger(1);
public NamedThreadFactory(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable);
thread.setName(
prefix
+ "-"
+ threadNumber.getAndIncrement()
);
thread.setDaemon(false);
thread.setUncaughtExceptionHandler((t, e) -> {
System.err.println(
"线程发生未捕获异常,thread="
+ t.getName()
);
e.printStackTrace();
});
return thread;
}
}
线程名会变成:
pdf-watermark-worker-1
pdf-watermark-worker-2
pdf-watermark-worker-3
看到日志时,可以马上判断任务来自哪个线程池。
第三步:单个 PDF 处理逻辑
PdfWatermarkService 仍然只负责处理一个 PDF。
它不关心:
线程池;
CompletableFuture;
批量等待;
成功失败统计。
这些应该由外层调度代码负责。
单个文件处理方法保持类似结构:
package com.succos.service;
import com.succos.dto.FileItemContext;
public class PdfWatermarkService {
public void addWaterMakerOfPDF(
FileItemContext fileItem
) throws Exception {
/*
* 这里放真实的 PDFBox 水印处理代码:
*
* 1. 读取 sourcePath;
* 2. 遍历 PDF 页面;
* 3. 写入文字或图片水印;
* 4. 保存到 targetPath;
* 5. 关闭相关资源。
*/
System.out.println(
Thread.currentThread().getName()
+ " 正在处理:"
+ fileItem.getSourcePath()
);
Thread.sleep(3000);
System.out.println(
Thread.currentThread().getName()
+ " 已输出:"
+ fileItem.getTargetPath()
);
}
}
这里暂时仍然用 Thread.sleep(3000) 模拟处理。
如果已经有真实的 PDFBox 水印代码,直接替换方法内部即可。
第四步:完整批量处理代码
新建:
com.succos.completablefuture.CompletableFuturePdfFinalDemo
完整代码如下:
package com.succos.completablefuture;
import com.succos.dto.FileItemContext;
import com.succos.dto.PdfTaskResult;
import com.succos.service.PdfWatermarkService;
import com.succos.threadpool.NamedThreadFactory;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
public class CompletableFuturePdfFinalDemo {
private static final int CORE_POOL_SIZE = 3;
private static final int MAX_POOL_SIZE = 3;
private static final int QUEUE_CAPACITY = 100;
private static final long SINGLE_TASK_TIMEOUT_SECONDS = 30;
private static final long BATCH_TIMEOUT_MINUTES = 10;
private static final String WATERMARK_TEXT = "上下文网";
private static final File INPUT_DIR =
new File("input");
private static final File OUTPUT_DIR =
new File("output");
public static void main(String[] args) {
ThreadPoolExecutor pdfExecutor =
createPdfExecutor();
try {
List<File> pdfFileList =
loadPdfFiles(INPUT_DIR);
if (pdfFileList.isEmpty()) {
System.out.println(
"input 目录下没有 PDF 文件"
);
return;
}
prepareOutputDirectory(OUTPUT_DIR);
long batchStart =
System.currentTimeMillis();
List<CompletableFuture<PdfTaskResult>>
futureList = submitTasks(
pdfFileList,
pdfExecutor
);
waitAllTasks(
futureList,
BATCH_TIMEOUT_MINUTES,
TimeUnit.MINUTES
);
List<PdfTaskResult> resultList =
collectResults(futureList);
long batchCost =
System.currentTimeMillis()
- batchStart;
printTaskResults(resultList);
printSummary(
resultList,
batchCost
);
} catch (Exception e) {
System.err.println(
"批量 PDF 处理发生异常:"
+ getErrorMessage(e)
);
e.printStackTrace();
} finally {
shutdownExecutor(pdfExecutor);
}
}
private static ThreadPoolExecutor createPdfExecutor() {
return new ThreadPoolExecutor(
CORE_POOL_SIZE,
MAX_POOL_SIZE,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(
QUEUE_CAPACITY
),
new NamedThreadFactory(
"pdf-watermark-worker"
),
new ThreadPoolExecutor.AbortPolicy()
);
}
private static List<File> loadPdfFiles(
File inputDir
) {
if (!inputDir.exists()) {
throw new IllegalStateException(
"input 目录不存在:"
+ inputDir.getAbsolutePath()
);
}
if (!inputDir.isDirectory()) {
throw new IllegalStateException(
"input 不是目录:"
+ inputDir.getAbsolutePath()
);
}
File[] files = inputDir.listFiles(file ->
file.isFile()
&& file.getName()
.toLowerCase()
.endsWith(".pdf")
);
if (files == null) {
return new ArrayList<>();
}
return Arrays.stream(files)
.sorted(
Comparator.comparing(
File::getName
)
)
.collect(Collectors.toList());
}
private static void prepareOutputDirectory(
File outputDir
) {
if (outputDir.exists()) {
if (!outputDir.isDirectory()) {
throw new IllegalStateException(
"output 路径不是目录:"
+ outputDir.getAbsolutePath()
);
}
return;
}
boolean created = outputDir.mkdirs();
if (!created) {
throw new IllegalStateException(
"output 目录创建失败:"
+ outputDir.getAbsolutePath()
);
}
}
private static List<CompletableFuture<PdfTaskResult>>
submitTasks(
List<File> pdfFileList,
ThreadPoolExecutor pdfExecutor
) {
List<CompletableFuture<PdfTaskResult>>
futureList = new ArrayList<>();
for (File file : pdfFileList) {
try {
CompletableFuture<PdfTaskResult>
future = createPdfTask(
file,
pdfExecutor
);
futureList.add(future);
} catch (RejectedExecutionException e) {
PdfTaskResult result =
PdfTaskResult.fail(
file.getName(),
file.getAbsolutePath(),
"线程池任务已满,提交被拒绝",
0
);
futureList.add(
CompletableFuture
.completedFuture(result)
);
}
}
return futureList;
}
private static CompletableFuture<PdfTaskResult>
createPdfTask(
File file,
ThreadPoolExecutor pdfExecutor
) {
long taskStart =
System.currentTimeMillis();
return CompletableFuture
.supplyAsync(() -> {
return processSinglePdf(
file,
taskStart
);
}, pdfExecutor)
.orTimeout(
SINGLE_TASK_TIMEOUT_SECONDS,
TimeUnit.SECONDS
)
.whenComplete((result, ex) -> {
long cost =
System.currentTimeMillis()
- taskStart;
if (ex != null) {
System.err.println(
file.getName()
+ " 执行异常,耗时:"
+ cost
+ " ms,原因:"
+ getErrorMessage(ex)
);
} else {
System.out.println(
file.getName()
+ " 执行结束,耗时:"
+ cost
+ " ms"
);
}
})
.exceptionally(ex -> {
long cost =
System.currentTimeMillis()
- taskStart;
Throwable cause =
getRootCause(ex);
if (cause
instanceof TimeoutException) {
return PdfTaskResult.timeout(
file.getName(),
file.getAbsolutePath(),
"单个 PDF 处理超过 "
+ SINGLE_TASK_TIMEOUT_SECONDS
+ " 秒",
cost
);
}
return PdfTaskResult.fail(
file.getName(),
file.getAbsolutePath(),
cause.getMessage(),
cost
);
});
}
private static PdfTaskResult processSinglePdf(
File file,
long taskStart
) {
System.out.println(
Thread.currentThread().getName()
+ " 开始处理:"
+ file.getName()
);
validateSourceFile(file);
String targetPath =
buildTargetPath(
file,
OUTPUT_DIR
);
FileItemContext context =
new FileItemContext();
context.setSourcePath(
file.getAbsolutePath()
);
context.setTargetPath(
targetPath
);
context.setWaterMakeText(
WATERMARK_TEXT
);
PdfWatermarkService service =
new PdfWatermarkService();
try {
service.addWaterMakerOfPDF(context);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new CompletionException(
new RuntimeException(
"PDF 处理任务被中断",
e
)
);
} catch (Exception e) {
throw new CompletionException(e);
}
long cost =
System.currentTimeMillis()
- taskStart;
System.out.println(
Thread.currentThread().getName()
+ " 处理完成:"
+ file.getName()
);
return PdfTaskResult.success(
file.getName(),
file.getAbsolutePath(),
targetPath,
cost
);
}
private static void validateSourceFile(
File file
) {
if (!file.exists()) {
throw new IllegalArgumentException(
"源 PDF 文件不存在"
);
}
if (!file.isFile()) {
throw new IllegalArgumentException(
"源路径不是文件"
);
}
if (!file.canRead()) {
throw new IllegalArgumentException(
"源 PDF 文件不可读取"
);
}
if (file.length() == 0) {
throw new IllegalArgumentException(
"源 PDF 文件为空"
);
}
}
private static String buildTargetPath(
File sourceFile,
File outputDir
) {
String sourceName =
sourceFile.getName();
String lowerName =
sourceName.toLowerCase();
String targetName;
if (lowerName.endsWith(".pdf")) {
targetName =
sourceName.substring(
0,
sourceName.length() - 4
)
+ "-watermark.pdf";
} else {
targetName =
sourceName
+ "-watermark.pdf";
}
return new File(
outputDir,
targetName
).getAbsolutePath();
}
private static void waitAllTasks(
List<CompletableFuture<PdfTaskResult>>
futureList,
long timeout,
TimeUnit unit
) {
CompletableFuture<Void> allFuture =
CompletableFuture.allOf(
futureList.toArray(
new CompletableFuture[0]
)
);
try {
allFuture
.orTimeout(
timeout,
unit
)
.join();
} catch (CompletionException e) {
Throwable cause =
getRootCause(e);
if (cause
instanceof TimeoutException) {
System.err.println(
"整批任务等待超时,准备取消未完成任务"
);
cancelUnfinishedTasks(
futureList
);
return;
}
throw e;
}
}
private static void cancelUnfinishedTasks(
List<CompletableFuture<PdfTaskResult>>
futureList
) {
for (CompletableFuture<PdfTaskResult>
future : futureList) {
if (!future.isDone()) {
future.cancel(true);
}
}
}
private static List<PdfTaskResult>
collectResults(
List<CompletableFuture<PdfTaskResult>>
futureList
) {
List<PdfTaskResult> resultList =
new ArrayList<>();
for (CompletableFuture<PdfTaskResult>
future : futureList) {
try {
PdfTaskResult result =
future.join();
resultList.add(result);
} catch (Exception e) {
resultList.add(
PdfTaskResult.fail(
"未知文件",
null,
getErrorMessage(e),
0
)
);
}
}
return resultList;
}
private static void printTaskResults(
List<PdfTaskResult> resultList
) {
System.out.println();
System.out.println(
"========== 文件处理明细 =========="
);
for (PdfTaskResult result
: resultList) {
System.out.println(
"文件名:"
+ result.getFileName()
);
System.out.println(
"状态:"
+ buildStatusText(result)
);
System.out.println(
"源路径:"
+ result.getSourcePath()
);
System.out.println(
"输出路径:"
+ result.getTargetPath()
);
System.out.println(
"处理信息:"
+ result.getMessage()
);
System.out.println(
"任务耗时:"
+ result.getCostMillis()
+ " ms"
);
System.out.println(
"--------------------------------"
);
}
}
private static String buildStatusText(
PdfTaskResult result
) {
if (result.isSuccess()) {
return "成功";
}
if (result.isTimeout()) {
return "超时";
}
return "失败";
}
private static void printSummary(
List<PdfTaskResult> resultList,
long batchCost
) {
long successCount =
resultList.stream()
.filter(
PdfTaskResult::isSuccess
)
.count();
long timeoutCount =
resultList.stream()
.filter(
PdfTaskResult::isTimeout
)
.count();
long failCount =
resultList.size()
- successCount
- timeoutCount;
System.out.println();
System.out.println(
"========== 批量处理汇总 =========="
);
System.out.println(
"总任务数:"
+ resultList.size()
);
System.out.println(
"成功数量:"
+ successCount
);
System.out.println(
"失败数量:"
+ failCount
);
System.out.println(
"超时数量:"
+ timeoutCount
);
System.out.println(
"整批耗时:"
+ batchCost
+ " ms"
);
System.out.println(
"================================"
);
}
private static Throwable getRootCause(
Throwable throwable
) {
Throwable cause = throwable;
while (cause.getCause() != null) {
cause = cause.getCause();
}
return cause;
}
private static String getErrorMessage(
Throwable throwable
) {
Throwable cause =
getRootCause(throwable);
String message =
cause.getMessage();
if (message == null
|| message.trim().isEmpty()) {
return cause
.getClass()
.getSimpleName();
}
return message;
}
private static void shutdownExecutor(
ThreadPoolExecutor executor
) {
executor.shutdown();
try {
boolean terminated =
executor.awaitTermination(
30,
TimeUnit.SECONDS
);
if (!terminated) {
System.err.println(
"线程池未在规定时间内关闭,准备强制停止"
);
List<Runnable> droppedTasks =
executor.shutdownNow();
System.err.println(
"未开始执行的任务数量:"
+ droppedTasks.size()
);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
executor.shutdownNow();
}
}
}
这版代码的执行流程
整个流程可以拆成几步。
1. 创建线程池
ThreadPoolExecutor pdfExecutor =
createPdfExecutor();
线程池配置为:
核心线程数:3;
最大线程数:3;
任务队列容量:100;
线程名前缀:pdf-watermark-worker;
拒绝策略:AbortPolicy。
所以同一时间最多有 3 个 PDF 正在处理。
2. 读取 PDF 文件
List<File> pdfFileList =
loadPdfFiles(INPUT_DIR);
这里会:
检查 input 目录是否存在;
检查 input 是否真的是目录;
过滤非 PDF 文件;
按文件名排序。
排序不是必须的,但日志和结果列表会更稳定。
3. 创建 output 目录
prepareOutputDirectory(OUTPUT_DIR);
如果目录不存在,就创建。
如果路径已经存在,但它不是目录,则直接抛异常。
4. 提交全部任务
List<CompletableFuture<PdfTaskResult>>
futureList = submitTasks(
pdfFileList,
pdfExecutor
);
每个 PDF 都对应一个:
CompletableFuture<PdfTaskResult>
任务提交阶段不会马上等待结果。
这是批量并发的关键。
如果提交一个就 join() 一个,任务又会接近顺序执行。
5. 给单个任务增加超时
每个任务都加了:
.orTimeout(
SINGLE_TASK_TIMEOUT_SECONDS,
TimeUnit.SECONDS
)
如果某个 PDF 超过 30 秒还没有完成,这个 CompletableFuture 会异常完成。
后面的:
.exceptionally(...)
会把超时异常转换成:
PdfTaskResult.timeout(...)
这样单个文件超时不会直接打断整批任务。
6. 记录单个任务日志
任务后面加了:
.whenComplete((result, ex) -> {
// 记录成功或失败日志
})
它只负责观察任务状态和打印耗时。
不会改变任务最终结果。
如果任务异常,再由后面的 exceptionally() 转成失败结果对象。
7. 等待整批任务完成
waitAllTasks(
futureList,
BATCH_TIMEOUT_MINUTES,
TimeUnit.MINUTES
);
内部使用:
CompletableFuture.allOf(...)
等待所有任务完成。
整批任务还设置了 10 分钟超时。
如果超过 10 分钟,会尝试取消还没有完成的任务。
不过仍然要记住:
cancel(true) 只是尝试中断;
不能保证底层 PDF 处理立即停止。
8. 统一收集结果
List<PdfTaskResult> resultList =
collectResults(futureList);
因为每个任务已经通过 exceptionally() 转成了结果对象,所以大部分情况下 join() 都能正常返回:
成功结果;
失败结果;
超时结果。
不会因为一个 PDF 损坏,就拿不到其他 PDF 的结果。
9. 打印明细和汇总
每个文件会打印:
文件名;
处理状态;
源路径;
输出路径;
处理信息;
任务耗时。
最后汇总:
总任务数;
成功数量;
失败数量;
超时数量;
整批耗时。
这已经比较接近批量任务结果报告了。
为什么使用 AbortPolicy
这一版线程池用了:
new ThreadPoolExecutor.AbortPolicy()
队列满了以后会抛出:
RejectedExecutionException
然后在提交任务时捕获,并转换成一个失败结果:
PdfTaskResult.fail(
file.getName(),
file.getAbsolutePath(),
"线程池任务已满,提交被拒绝",
0
)
我在这一版里没有使用 CallerRunsPolicy。
原因是我希望:
PDF 任务只在线程池里执行;
提交线程不要突然自己处理一个耗时 PDF;
线程池满了以后,明确记录任务提交失败。
在普通本地脚本里,CallerRunsPolicy 也能用。
但如果以后把代码放到 Web 接口中,AbortPolicy 通常更容易控制接口行为。
为什么每个任务都返回 PdfTaskResult
如果任务直接抛异常:
CompletableFuture<String>
那么一个任务失败时:
CompletableFuture.allOf(...).join();
可能直接抛出 CompletionException。
虽然其他任务仍然会继续执行,但结果收集会变得麻烦。
现在每个任务最终都尽量返回:
PdfTaskResult
这样批量任务结果会更统一:
成功也是结果;
失败也是结果;
超时也是结果。
异常不再是批量处理的主要业务返回方式。
单任务超时仍然有一个现实问题
这版代码虽然用了:
orTimeout(...)
但它不能保证底层 PDF 处理马上停止。
可能出现:
CompletableFuture 已经返回超时结果;
PdfWatermarkService 还在后台继续处理;
输出文件仍然可能被写入。
所以真实项目还应该增加临时文件策略。
例如先输出到:
output/test-watermark.tmp.pdf
任务完全成功后再移动为:
output/test-watermark.pdf
如果失败或超时,就清理临时文件。
这部分属于文件任务的完整性设计,不是单靠 CompletableFuture 能解决的。
防止输出文件重名
当前代码使用:
原文件名-watermark.pdf
作为输出文件名。
如果不同目录中有同名 PDF,或者同一任务重复提交,就可能覆盖。
更稳的方式可以加入:
任务 ID;
时间戳;
UUID;
文件内容哈希。
例如:
String targetName =
UUID.randomUUID()
+ "-"
+ sourceName;
或者:
20260716-任务ID-test-watermark.pdf
是否允许覆盖,要根据业务决定。
真实项目还应该补什么
这版已经把 Java 并发主线串起来了,但距离生产系统还有一些内容。
比如:
任务记录保存到数据库;
接口提交后立即返回任务 ID;
前端查询任务进度;
上传文件大小限制;
PDF 页数限制;
任务重试机制;
临时文件清理;
应用重启后的任务恢复;
多实例环境下的任务分配;
任务幂等;
日志框架;
监控和告警。
如果只是本地批量处理 PDF,这版已经够用了。
如果要做成 Web 服务,就需要把它进一步改造成任务系统。
这版代码里各个并发工具的职责
最后把各个部分再放在一起看。
ThreadPoolExecutor
控制线程数量;
管理任务队列;
复用线程;
提供拒绝策略。
CompletableFuture.supplyAsync
把每个 PDF 包装成有返回值的异步任务。
orTimeout
限制单个 PDF 的结果等待时间。
whenComplete
记录单个任务的执行结果和耗时。
exceptionally
把异常转换成失败或超时结果对象。
allOf
等待整批 PDF 任务全部完成。
join
在最终需要结果的位置获取任务结果。
PdfTaskResult
统一表示成功、失败和超时状态。
这些工具组合起来以后,整个结构就比较清楚了。
这一节小结
这一节我主要完成了这些内容:
1. 使用自定义 ThreadPoolExecutor 控制 PDF 并发数量;
2. 使用 CompletableFuture 为每个 PDF 创建异步任务;
3. 使用 PdfTaskResult 统一表示成功、失败和超时;
4. 使用 orTimeout 限制单个任务时间;
5. 使用 exceptionally 把异常转换成业务结果;
6. 使用 whenComplete 记录任务日志和耗时;
7. 使用 allOf 等待整批任务完成;
8. 统一统计成功、失败、超时数量和整批耗时;
9. 任务处理完成后安全关闭线程池。
用一句话总结:
CompletableFuture 批量处理的重点,不只是把任务并发跑起来,而是把线程池、结果、异常、超时和等待都管理清楚。
下一节继续对比:
Future;
ThreadPoolExecutor;
CompletableFuture。
这三个概念经常一起出现,但它们并不是同一层面的工具。下一节会整理它们分别解决什么问题,以及实际项目中应该怎么选。