前言
当我决定写这篇关于 CompletableFuture 的文章时,脑海中浮现出无数个曾经被异步编程折磨得死去活来的瞬间。
所以我希望能够用通俗且有趣的方式,帮列位看官逐步掌握这个Java异步编程的终极武器:CompletableFuture。
同时这篇文章,会尽可能的将知识点切成碎片化,不用看到长文就头痛。
坦白说,这篇文章确实有点长!一口气读完还是有点费劲。所以,我决定把它拆分为两篇:
第一篇,主打基础和进阶,深入浅出,这是传送门。
第二篇,重点在高级特性上,比如多任务编排等,还会围绕实战和性能优化展开讲讲。
无论你是Java新手还是资深开发,相信都能在这里找到值得学习的干货。
耐心看完,你一定有所收获。
正文
5. 多任务编排进阶
在实际开发中,我们经常需要处理多个异步任务。有时候需要等所有任务都完成,有时候只要其中一个完成就行。 CompletableFuture为我们提供了两个强大的工具:allOf()和anyOf()。
allOf:等待所有任务完成
先了解一下CompletableFuture.allOf()的基本用法。它接收多个CompletableFuture作为参数,返回一个新的CompletableFuture,这个新的Future会在所有任务都完成时才完成。
CompletableFuture<Void> allOf = CompletableFuture.allOf(
future1, future2, future3
);
听起来有点抽象?举个例子来理解。
比方说你在准备一顿丰盛的午饭。你需要煮饭、炒菜、煲汤,这些任务可以同时进行,但午饭必须等所有菜品都准备好才能开始。
用代码来表达就是:
CompletableFuture<String> cookRice = CompletableFuture
.supplyAsync(() -> {
// 模拟煮饭
sleep(1000);
return "饭已就位";
});
CompletableFuture<String> stirFry = CompletableFuture
.supplyAsync(() -> {
// 模拟炒菜
sleep(2000);
return "炒完了";
});
CompletableFuture<String> makeSoup = CompletableFuture
.supplyAsync(() -> {
// 模拟煲汤
sleep(3000);
return "汤煮好了";
});
// 等待所有任务完成
CompletableFuture.allOf(cookRice, stirFry, makeSoup)
.thenRun(() -> {
System.out.println("所有准备工作已完成,可以开始吃饭了!");
});
这段代码中:
- 每个任务都是独立的CompletableFuture
- 通过
allOf()将它们组合在一起 thenRun()是在所有任务完成后,执行回调- 三个任务是并行执行的,总耗时则取决于最慢的那个任务
anyOf:只要有一个任务完成
而CompletableFuture.anyOf()则是另一种场景:只要有一个任务完成就可以继续。
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(
future1, future2, future3
);
这就像什么呢?设想你在等网约车,同时打开了滴滴、高德和美团三个平台叫车。只要有一个平台接单了,你就可以出发。这就是CompletableFuture.anyOf()的典型场景。
CompletableFuture<Object> taxi = CompletableFuture.anyOf(
callDidi(), // 叫滴滴
callGaode(), // 叫高德
callMeituan() // 叫美团
);
taxi.thenAccept(platform -> System.out.println(platform + "接单了,出发!"));
通过anyOf()接收多个CompletableFuture后,返回最先完成的任务结果,虽然其他任务可能仍在继续执行,但结果会被忽略。
实战案例:并行请求多个微服务
理解了基本概念,让我们来看一个比较典型的业务场景:商品详情页。
假设你正在开发一个电商平台的商品详情页,需要同时获取:
- 商品基本信息
- 库存数据
- 价格信息
- 用户评价 这些数据分别来自不同的微服务。如果串行请求这些服务,页面加载会非常慢,如果使用CompletableFuture,就可以优雅地实现并行请求:
public ProductDetailVO getProductDetail(Long productId) {
// 1. 创建多个异步任务
CompletableFuture<ProductInfo> productFuture = CompletableFuture
.supplyAsync(() -> productService.getProductInfo(productId));
CompletableFuture<Stock> stockFuture = CompletableFuture
.supplyAsync(() -> stockService.getStock(productId));
CompletableFuture<Price> priceFuture = CompletableFuture
.supplyAsync(() -> priceService.getPrice(productId));
CompletableFuture<List<Review>> reviewsFuture = CompletableFuture
.supplyAsync(() -> reviewService.getReviews(productId));
// 2. 等待所有数据都准备好
return CompletableFuture.allOf(
productFuture, stockFuture, priceFuture, reviewsFuture)
.thenApply(v -> {
// 3. 组装最终结果
return new ProductDetailVO(
productFuture.join(), // 获取每个任务的结果
stockFuture.join(),
priceFuture.join(),
reviewsFuture.join()
);
}).join(); // 4. 等待最终结果
}
让我们解读一下上面这段代码:
- 首先,我们为每个微服务调用创建了一个异步任务
- 使用
allOf()等待所有任务完成 - 用
thenApply()组装最终结果 - 最后用
join()获取组装好的完整的商品数据
这样做的好处是显而易见的:
- 四个请求并行执行,减少了总耗时
- 代码结构清晰,易于维护
- 异常处理可以统一管理
- 扩展性好,随时可以添加新的数据源
小贴士
在实际项目中,记得加上超时控制和异常处理。毕竟在分布式环境下,网络请求随时可能失败。可以使用orTimeout()或completeOnTimeout()来优雅地处理超时情况。
// 为每个请求添加3秒超时
CompletableFuture<ProductInfo> productFuture = CompletableFuture
.supplyAsync(() -> productService.getProductInfo(productId))
.orTimeout(3, TimeUnit.SECONDS) // 超时控制
.exceptionally(ex -> {
log.error("获取商品信息失败", ex);
return new ProductInfo(); // 返回默认值
});
这样,即使某个服务出现问题,也不会影响整个页面的展示。
6. 性能优化与实战技巧
避免常见的性能陷阱
默认线程池的问题
// 不推荐:使用默认的ForkJoinPool
// 问题:共享的ForkJoinPool可能被其他任务占满,导致你的任务无法及时执行
CompletableFuture.supplyAsync(() -> doSomething());
// 推荐:指定自定义线程池
// 优势:可控的线程数量,避免资源争抢,便于监控和管理
ExecutorService executor = Executors.newFixedThreadPool(10);
CompletableFuture.supplyAsync(() -> doSomething(), executor);
默认的ForkJoinPool是JVM级别共享的,线程数通常等于CPU核心数-1。在高并发场景下,如果有大量CPU密集型任务,可能会导致线程饥饿。例如:
// 实际案例:模拟订单处理系统
public class OrderProcessor {
// 错误示范
public CompletableFuture<OrderResult> processOrder(Order order) {
return CompletableFuture.supplyAsync(() -> validateOrder(order))
.thenApply(this::calculatePrice)
.thenApply(this::applyDiscount);
}
// 优化版本
private final ExecutorService orderExecutor = Executors.newFixedThreadPool(
20,
new ThreadFactoryBuilder()
.setNameFormat("order-processor-%d")
.setDaemon(true)
.build()
);
public CompletableFuture<OrderResult> processOrder(Order order) {
return CompletableFuture.supplyAsync(() -> validateOrder(order), orderExecutor)
.thenApply(this::calculatePrice)
.thenApply(this::applyDiscount);
}
}
避免不必要的线程切换
// 反模式:过度使用异步转换
CompletableFuture.supplyAsync(() -> fetchUserData()) // 线程A
.thenApply(user -> enrichUserData(user)) // 可能切换到线程B
.thenApply(user -> formatUserData(user)) // 可能切换到线程C
.thenAccept(user -> saveToCache(user)); // 可能切换到线程D
// 最佳实践:合理规划异步边界
CompletableFuture.supplyAsync(() -> fetchUserData(), ioExecutor) // IO操作使用IO线程池
.thenApplyAsync(user -> {
// 计算密集型操作使用计算线程池
user = enrichUserData(user);
user = formatUserData(user);
return user;
}, computeExecutor)
.thenAccept(user -> saveToCache(user)); // 与上游使用相同线程
每次线程切换都会带来上下文切换开销,包括:
- 保存当前线程的执行状态
- 加载新线程的执行状态
- 可能的CPU缓存失效
- 线程调度开销
- 合理处理异常
// 不推荐:简单的异常处理
completableFuture.exceptionally(ex -> {
log.error("操作失败", ex);
return null; // 丢失了异常上下文
});
// 最佳实践:结构化的异常处理
public class Result<T> {
private final T data;
private final boolean success;
private final String errorCode;
private final String errorMessage;
// ... 构造方法和访问器
}
CompletableFuture<Result<UserData>> future = CompletableFuture
.supplyAsync(() -> fetchUserData())
.handle((data, ex) -> {
if (ex != null) {
log.error("获取用户数据失败", ex);
if (ex instanceof TimeoutException) {
return Result.error("TIMEOUT", "服务调用超时");
} else if (ex instanceof IllegalArgumentException) {
return Result.error("INVALID_PARAM", "参数错误");
}
return Result.error("SYSTEM_ERROR", "系统异常");
}
return Result.success(data);
});
CompletableFuture的性能优化
批量任务
在实际业务场景中,我们经常需要并行处理大量任务,比如批量发送消息、批量处理订单等。但简单地将所有任务转换为CompletableFuture并行执行可能会带来以下问题:
- 线程资源消耗过快,甚至耗尽
- 内存压力过大
- 系统CPU占用过高,负载陡增
这里提供一个BatchProcessor作为参考,先看代码:
// 不推荐:直接转换为CompletableFuture列表
List<CompletableFuture<Result>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(() -> task.execute()))
.collect(Collectors.toList());
// 推荐:控制并发度
public class BatchProcessor {
private final ExecutorService executor;
private final int maxConcurrency;
// 优化版本:带有限流和监控的批处理执行器
public <T> List<T> executeBatch(List<Supplier<T>> tasks) {
Semaphore semaphore = new Semaphore(maxConcurrency);
AtomicInteger activeTaskCount = new AtomicInteger(0);
AtomicInteger completedTaskCount = new AtomicInteger(0);
List<CompletableFuture<T>> futures = tasks.stream()
.map(task -> CompletableFuture.supplyAsync(() -> {
try {
semaphore.acquire();
activeTaskCount.incrementAndGet();
return task.get();
} catch (Exception e) {
throw new CompletionException(e);
} finally {
semaphore.release();
activeTaskCount.decrementAndGet();
completedTaskCount.incrementAndGet();
}
}, executor))
.collect(Collectors.toList());
// 添加批处理进度监控
CompletableFuture.runAsync(() -> {
while (completedTaskCount.get() < tasks.size()) {
log.info("批处理进度: {}/{}, 当前活动任务数: {}",
completedTaskCount.get(), tasks.size(), activeTaskCount.get());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
}
主要优化的点在于:
- 通过
Semaphore实现最大并发数限制,避免线程资源被用完 - 通过计数器实时跟踪活动的任务数量和完成进度
- 当系统负载较高时,通过信号量自动降低并发度
- 提供简单的处理进度日志,便于问题定位和后续的诊断
超时控制
在分布式系统中,超时控制是保证系统稳定性的关键。但简单的超时策略有时也会有些问题,比如下面这些:
- 无法应对网络抖动
- 缺乏重试的机制
- 固定的超时时间,不够灵活
- 缺乏降级机制
这里提供一个TimeoutHandler作为优化方向的参考:
// 原始的写法:基础的超时控制
CompletableFuture<Result> future = CompletableFuture
.supplyAsync(() -> doSomething())
.orTimeout(3, TimeUnit.SECONDS);
public class TimeoutHandler {
// 带有动态超时和重试的处理器
public <T> CompletableFuture<T> executeWithSmartTimeout(
Supplier<T> task,
long timeout,
TimeUnit unit,
int maxRetries) {
AtomicInteger retryCount = new AtomicInteger(0);
CompletableFuture<T> future = new CompletableFuture<>();
CompletableFuture.supplyAsync(() -> {
while (retryCount.get() < maxRetries) {
try {
CompletableFuture<T> attempt = CompletableFuture
.supplyAsync(task)
.orTimeout(timeout, unit);
return attempt.get();
} catch (TimeoutException e) {
log.warn("第{}次尝试超时", retryCount.incrementAndGet());
// 指数退避
Thread.sleep(Math.min(1000 * (1 << retryCount.get()), 10000));
} catch (Exception e) {
throw new CompletionException(e);
}
}
throw new TimeoutException("重试次数耗尽");
}).handle((result, ex) -> {
if (ex != null) {
future.completeExceptionally(ex);
} else {
future.complete(result);
}
return null;
});
return future;
}
}
这个TimeoutHandler还是比较有特点的,但也有不少优化空间,这里属于抛砖引玉。
来看下它的优点:
- 支持配置最大重试次数
- 采用指数退避算法,避免立即重试对系统造成冲击
- 每次重试都有独立的超时控制,实现了基础的隔离
- 支持自定义降级策略,可以对结果进行降级处理
也提供一个简单的示例:
TimeoutHandler handler = new TimeoutHandler();
CompletableFuture<UserProfile> future = handler.executeWithSmartTimeout(
() -> remoteService.getUserProfile(userId),
500, // 500ms超时
TimeUnit.MILLISECONDS,
3 // 最多重试3次
);
资源释放
这其实是个老生常谈的问题,与CompletableFuture关联不大,是整个异步编程领域都要面临的问题,这里也就扯一些闲篇。
在处理涉及数据库连接、网络连接等资源的异步任务时,常见问题包括:
- 资源泄露
- 释放时机不当
- 异常情况下资源未释放
- 缺乏统一的资源管理方案
针对上述问题,我们也提供一个ResourceAwareCompletableFuture,但也和上一节的参考类一致,这些优化都是抛砖引玉,不能一股脑用到生产上,也可能并一定适用于你的真实场景,必须要针对不同的业务去进行调整与修改。
public class ResourceAwareCompletableFuture<T> implements AutoCloseable {
private final CompletableFuture<T> future;
private final ExecutorService executor;
private final List<AutoCloseable> resources;
public ResourceAwareCompletableFuture(
Supplier<T> supplier,
ExecutorService executor,
List<AutoCloseable> resources) {
this.executor = executor;
this.resources = resources;
this.future = CompletableFuture.supplyAsync(() -> {
try {
return supplier.get();
} finally {
// 确保资源在任务完成后释放
closeResources();
}
}, executor);
}
private void closeResources() {
resources.forEach(resource -> {
try {
resource.close();
} catch (Exception e) {
log.error("关闭资源失败", e);
}
});
}
@Override
public void close() {
closeResources();
if (!executor.isShutdown()) {
executor.shutdown();
}
}
}
同样来看下这个优化的点:
- 实现
AutoCloseable接口,支持try-with-resources语法 - 在任务完成后自动释放资源
- 异常安全,即使发生异常也能保证资源释放
- 具备良好的可扩展性,支持管理多个相关资源
给个使用示例:
try (ResourceAwareCompletableFuture<Data> future = new ResourceAwareCompletableFuture<>(
() -> processWithConnection(connection),
executor,
Arrays.asList(connection, tempFile))) {
Data result = future.get(5, TimeUnit.SECONDS);
// 资源会在这里自动释放
}
7. 高级特性与实战案例
异步任务的取消与中断
让我们用一个生活中的例子:假设你正在点外卖。下单后,外卖小哥已经接单开始配送,这时你突然接到老板的紧急电话,说要马上去客户那里开会。没办法,只能忍痛取消订单了。这就很像我们在程序中需要取消一个正在执行的异步任务的情况。
CompletableFuture提供了 .cancel() 方法,用来方便地取消异步任务。
不过使用起来还是要注意一些细节,我们来看看具体代码:
// 不推荐的写法 - 没有合理的取消处理
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000); // 假装在做一些耗时的事情
return "任务完成";
} catch (InterruptedException e) {
return "任务被中断";
}
});
// 推荐的写法 - 优雅地处理取消操作
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
for (int i = 0; i < 5; i++) {
// 随时检查是否被要求停止
if (Thread.currentThread().isInterrupted()) {
throw new CompletionException(new InterruptedException("任务被取消"));
}
Thread.sleep(1000);
}
return "任务完成";
} catch (InterruptedException e) {
throw new CompletionException(e);
}
});
// 发出取消指令
boolean cancelled = future.cancel(true);
这里有几个要特别注意的点:
cancel(boolean mayInterruptIfRunning)方法返回boolean值,表示取消是否成功- 一旦任务被取消,就像泼出去的水,覆水难收,后面的所有操作都不会执行了
- 所以在写任务的时候,一定要好好处理
InterruptedException,不然可能会留下隐患
死锁预防
这里的死锁和数据库的死锁概念不太一样,举个简单的例子:两个I人在微信上聊天,但是互相都在等对方先发消息,结果两个人都一直在等,谁也不主动,这就陷入了死锁。
来看看代码中的情况:
// 容易产生死锁的写法
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
return future2.get(); // 等待future2的结果
} catch (Exception e) {
return "error";
}
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
try {
return future1.get(); // 等待future1的结果
} catch (Exception e) {
return "error";
}
});
两个future互相等待,不可避免的产生了死锁问题。
要解决这个问题,最简单的办法就是设置一个等待超时时间。
// 推荐的写法 - 使用超时机制
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
return future2.get(1, TimeUnit.SECONDS); // 设置超时时间
} catch (TimeoutException e) {
return "超时";
} catch (Exception e) {
return "error";
}
});
要预防死锁,记住这几点就够了:
- 别让任务互相等待对方,也就是避免循环依赖
- 设置合理的超时时间
- 用
thenCompose来代替直接get() - 合理规划任务执行顺序,提前想好任务该怎么排队执行
实战案例:构建异步请求框架
让我们来做一个实用的例子:构建一个处理HTTP请求的异步框架。这个框架要能处理大量并发请求,还得优雅地处理超时和错误情况。
public class AsyncHttpClient {
private final ExecutorService executor;
private final int timeout;
public AsyncHttpClient(int threadPoolSize, int timeoutSeconds) {
this.executor = Executors.newFixedThreadPool(threadPoolSize);
this.timeout = timeoutSeconds;
}
public CompletableFuture<String> asyncGet(String url) {
return CompletableFuture.supplyAsync(() -> {
try {
// 模拟HTTP请求
if (Math.random() < 0.1) { // 10%概率模拟超时
Thread.sleep(timeout * 1000 + 1000);
}
return "Response from " + url;
} catch (InterruptedException e) {
throw new CompletionException(e);
}
}, executor)
.orTimeout(timeout, TimeUnit.SECONDS)
.exceptionally(throwable -> {
if (throwable instanceof TimeoutException) {
return "请求超时了:" + url;
}
return "请求出错:" + throwable.getMessage();
});
}
// 批量处理请求
public List<String> batchGet(List<String> urls) {
List<CompletableFuture<String>> futures = urls.stream()
.map(this::asyncGet)
.collect(Collectors.toList());
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
public void shutdown() {
executor.shutdown();
}
}
这个小工具的特点是:
- 使用线程池管理并发请求
- 统一的超时处理机制
- 优雅的错误处理
- 支持批量请求处理
- 资源可控,不会造成内存泄露
看看使用示例:
AsyncHttpClient client = new AsyncHttpClient(10, 5); // 10个线程,5秒超时
List<String> urls = Arrays.asList(
"http://api1.example.com",
"http://api2.example.com",
"http://api3.example.com"
);
// 异步处理多个请求
List<String> results = client.batchGet(urls);
results.forEach(System.out::println);
client.shutdown();
8. 踩坑指南
常见误区与解决方案
除了前面讲到的性能陷阱,在实际开发中还有不少容易掉进去的坑。这里我挑几个最容易碰到的来聊一聊。(欢迎大家在评论区分享更多案例~)
异常处理的误区
说实话,异常处理可能是用 CompletableFuture 最容易翻车的地方了。很多开发者习惯了传统的 try-catch 模式,在使用 CompletableFuture 时容易忽略专门的异常处理。
最常见的问题是异常被静默吞掉:
// 错误示范
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("糟糕,出错了!");
return "成功";
});
future.thenAccept(System.out::println); // 这里异常被吞掉了,什么都看不到
这种情况下,如果发生异常,我们在日志中完全看不到任何错误信息,这对问题排查非常不利。正确的做法是要始终添加异常处理:
future
.thenAccept(System.out::println)
.exceptionally(throwable -> {
System.err.println("捕获到异常:" + throwable.getMessage());
return null;
});
线程池管理问题
另一个常见的误区是对线程池的管理不当。创建线程池容易,但很多同学用完就扔,忘记关闭了。这可能导致应用无法正常退出或资源泄露。
比较靠谱的做法是把线程池交给一个专门的管理类来处理:
public class AsyncExecutor implements AutoCloseable {
private final ExecutorService executor;
public AsyncExecutor(int threadCount) {
this.executor = Executors.newFixedThreadPool(threadCount);
}
public <T> CompletableFuture<T> submit(Supplier<T> task) {
return CompletableFuture.supplyAsync(task, executor);
}
@Override
public void close() {
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
混用 thenApply 和 thenCompose
最后要说的这个坑也挺有意思的。很多小伙伴分不清 thenApply 和 thenCompose 该在什么时候用,用错了就容易把代码写得特别复杂。
简单来说:
thenApply是做转换用的,比如把 A 转成 BthenCompose是用来组合另一个异步操作的,避免Future套Future
来看个例子:
// 错误示范:用 thenApply 导致嵌套的 Future
CompletableFuture<CompletableFuture<String>> nested =
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(s -> CompletableFuture.supplyAsync(() -> s + " World"));
// 正确做法:用 thenCompose 保持链式调用
CompletableFuture<String> better =
CompletableFuture.supplyAsync(() -> "Hello")
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + " World"));
小技巧
如果你的转换函数返回的是另一个 CompletableFuture,那就用 thenCompose,否则用 thenApply。这样代码就不会陷入"套娃"地狱了。
调试技巧
调试异步代码是比较麻烦的,因为执行流程不再是线性的,而是分散在多个线程中。这里介绍几个实用的调试技巧:
日志追踪
这是最简单粗暴的方法,在每个关键节点都添加日志,记得通过 MDC 加上统一的链路ID:
private static final Logger log = LoggerFactory.getLogger(AsyncTask.class);
public CompletableFuture<String> process(String taskId) {
return CompletableFuture.supplyAsync(() -> {
MDC.put("taskId", taskId);
log.info("开始处理任务");
try {
// 业务逻辑
return "处理结果";
} finally {
log.info("任务处理完成");
MDC.remove("taskId");
}
});
}
建议把这些日志模板封装一下,这样团队的其他同学也能统一用:
public class AsyncTracer {
public static <T> CompletableFuture<T> trace(String taskId, Supplier<T> task) {
return CompletableFuture.supplyAsync(() -> {
MDC.put("taskId", taskId);
log.info("[{}] 任务开始", taskId);
try {
T result = task.get();
log.info("[{}] 任务成功完成", taskId);
return result;
} catch (Exception e) {
log.error("[{}] 任务失败: {}", taskId, e.getMessage());
throw e;
} finally {
MDC.remove("taskId");
}
});
}
}
使用 join() 进行断点调试
在开发环境下,有时候我们需要一步步跟踪代码执行。可以在关键节点使用 join() 方法:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// 这里可以打断点
return "第一步";
}).thenApply(result -> {
// 这里也可以打断点
return result + "第二步";
});
// 在这里调用 join() 方法,方便断点调试
String finalResult = future.join();
使用 CompletableFuture.allOf() 调试并行任务
当有多个并行任务时,可以用 allOf() 来统一查看所有任务的执行情况:
List<CompletableFuture<String>> futures = Arrays.asList(
task1(), task2(), task3()
);
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.whenComplete((v, ex) -> {
futures.forEach(f -> {
try {
log.info("任务结果: {}", f.getNow(null));
} catch (Exception e) {
log.error("任务异常: {}", e.getMessage());
}
});
});
其实就记住一点:调试异步代码最重要的,就是得能够追踪到完整的执行链路,只要链路清晰,什么问题都好排查。
9. 拥抱虚拟线程
传统的线程就像餐厅里的服务员,每个服务员都需要专门的工作空间和配备(占用系统资源),所以餐厅不可能无限制地雇佣服务员。
而Java 21带来的虚拟线程,就像是餐厅引入了智能点餐系统,客人坐下来就点餐,不再受限于实体服务员的数量。
新的线程工厂:Thread.ofVirtual()
还是刚才的餐厅。
按传统的模式,餐厅需要事先规划好到底要雇佣多少服务员。雇太多,人工成本高;雇太少,顾客要排队等。
但是虚拟线程解决了这个问题。来看下虚拟线程怎么创建:
// 创建虚拟线程的线程工厂
// 就像采购了一套点餐系统
ThreadFactory virtualThreadFactory = Thread.ofVirtual().factory();
// 使用这个工厂创建执行器
// 相当于把系统部署到餐厅使用
ExecutorService executor = Executors.newThreadPerTaskExecutor(virtualThreadFactory);
Java还提供了更简便的方式,直接创建支持虚拟线程的执行器:
// 直接创建虚拟线程执行器
// 就像直接启用了整套智能点餐系统
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
针对餐厅这个例子,其实能预想到虚拟线程的几个特点:
- 创建成本极低(就像在客户坐下后自己点餐,系统自动派单)
- 占用资源少(不需要给每个订单都配备专门的服务员)
- 特别适合等待型任务(比如等待厨房出餐、等待支付完成等)
CompletableFuture与虚拟线程的整合
把 CompletableFuture 和虚拟线程结合起来非常自然:
// 创建基于虚拟线程的执行器
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 使用这个执行器来处理CompletableFuture任务
CompletableFuture<String> orderTask = CompletableFuture.supplyAsync(() -> {
// 模拟处理订单
return "订单已确认";
}, executor);
这种组合的优势在于:
- CompletableFuture提供了异步处理的框架
- 虚拟线程提供了高效的执行能力
- 两者结合,既能处理大量并发订单,又不会消耗过多系统资源
这就像餐厅里,每来一个订单,智能系统就会自动分配一个虚拟服务员(虚拟线程)来处理,而不用担心服务员数量不够的问题。
系统可以轻松应对午高峰数百个并发订单,因为虚拟线程的创建和切换成本极低。
性能对比:传统线程池 vs 虚拟线程
还是通过餐厅的例子,直观对比传统线程池和虚拟线程的性能差异。
想象一下两家餐厅在午餐高峰期的场景:
传统餐厅(传统线程池):
- 固定数量的服务员(比如10名)
- 每个服务员只能处理一个订单
- 当所有服务员都在忙时,新顾客需要排队等待
智能餐厅(虚拟线程):
- 智能点餐系统可以同时处理成百上千的订单
- 无需担心“服务员数量”的限制
- 新订单可以立即被接受处理
让我们用代码来模拟这两种场景:
public class RestaurantPerformanceTest {
// 模拟传统线程池餐厅
private static void traditionalRestaurant() {
// 创建固定大小的线程池,就像雇佣10个服务员
ExecutorService executor = Executors.newFixedThreadPool(10);
long startTime = System.currentTimeMillis();
// 模拟1000个订单
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
try {
// 模拟每个订单处理需要100ms
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
// ... 计算处理时间
}
// 模拟使用虚拟线程的餐厅
private static void virtualThreadRestaurant() {
// 创建虚拟线程执行器,相当于启用智能点餐系统
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
long startTime = System.currentTimeMillis();
// 同样模拟1000个订单
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
try {
// 同样的处理时间
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
// ... 计算处理时间
}
}
在实际性能测试中,两种方案的差异非常明显。
- 传统线程池(10个线程):
- 总耗时约10秒
- 内存占用较大
- CPU频繁切换线程上下文
- 虚拟线程:
- 总耗时约0.2秒
- 内存占用小
- 切换成本低
可以看下差在哪里:
- 传统线程池受限于固定的线程数量,但是虚拟线程没有这个困扰,可以轻松处理成千上万的并发任务。
- 传统线程每个都需要约1MB栈内存,但是虚拟线程只需要几KB
- 传统线程池一旦进入高负载,必然需要排队等待,虚拟线程几乎可以立即处理新请求
比较官方的说法是,虚拟线程通过其轻量级的特性和高效的调度机制,完美解决了传统线程池在高并发场景下的瓶颈问题。它不仅能够支持更多的并发任务,还能更好地利用系统资源。
隐患
虽然虚拟线程带来了很多好处,但在使用时也需要注意一些潜在的问题。
- 阻塞操作
// 不推荐的做法
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// 长时间的同步阻塞操作
synchronized (lockObject) {
Thread.sleep(1000);
// 处理逻辑
}
});
}
虽然虚拟线程在I/O操作时能够高效切换,但在进行同步阻塞操作时,仍然会占用平台线程,影响整体性能。
- 线程局部变量的内存泄漏
// 潜在的内存问题
public class ThreadLocalExample {
private static final ThreadLocal<byte[]> threadLocal =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]); // 1MB数据
public void processWithVirtualThread() {
// 大量创建虚拟线程,每个都使用ThreadLocal
Thread.startVirtualThread(() -> {
threadLocal.get(); // 获取线程局部变量
// 处理逻辑
// 忘记调用 threadLocal.remove()
});
}
}
虽然虚拟线程本身很轻量,但如果每个虚拟线程都持有大量的ThreadLocal数据,并且没有及时清理,仍然会导致内存问题。
最佳实践
在使用虚拟线程时,有几点建议:
- IO密集型任务优先考虑虚拟线程
// 适合虚拟线程的场景
CompletableFuture.supplyAsync(() -> {
// 数据库查询、HTTP请求等IO操作
}, virtualExecutor);
- CPU密集型任务还是用传统线程池更好
// 计算密集型任务保持使用传统线程池
ExecutorService computeExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
- 注意资源的及时释放
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 使用虚拟线程执行任务
} // 自动关闭执行器
虚拟线程给异步编程带来了新的活力,特别是在处理大量IO操作时。它让我们可以用更简单的方式实现高并发,而不用过多担心线程资源的问题。
不过诸君谨记,这不是一个万能药,要根据具体场景选择合适的方案。
寄语
写到这里,不知不觉已经写了这么多。回想起最初决定写这篇文章的初衷,就是希望能帮助更多的开发者们攻克异步编程这座"大山"。
CompletableFuture 确实是个强大的工具,但就像所有的编程工具一样,它不是万能的。关键在于我们要理解它的本质,知道在什么场景下使用它最合适。
记得我刚接触异步编程时,也是一头雾水,经常被各种回调和线程问题折腾得够呛。但随着不断实践和深入理解,渐渐发现异步编程的优雅之处。
这也是我写这篇文章的动力 —— 希望能让后来者少走一些弯路。
如果这篇文章能帮你理清哪怕一个概念,或解决一个困扰你的问题,那就达到了我写作的目的。
道阻且长,但有趣的是,我们每解决一个问题,就会遇到新的挑战。这正是技术进步的动力所在。
希望这篇文章能成为你掌握异步编程的一个起点,而不是终点。
最后,感谢你能耐心读到这里。如果你有任何问题或建议,欢迎留言交流。让我们在技术的道路上共同进步!