Java 并发编程进阶:CompletableFuture 与虚拟线程的取舍

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-20 01:43 ·5 浏览 ·0 回复

学完这篇,你能分清 CompletableFuture 和虚拟线程各自该用在什么场景,并写出不踩坑的取舍代码。

第一步:先认清两者解决的不是同一个问题

CompletableFuture 是 JDK 8 引入的异步编排工具,管的是"多个任务怎么串行、怎么并行、结果怎么合并、超时和异常怎么兜底"。

虚拟线程是 JDK 21 正式落地的(JEP 444),管的是执行成本——让你用同步阻塞的写法,承载几十万个并发任务,而不用去写回调地狱。

一句话记住:CompletableFuture 负责任务之间的依赖关系,虚拟线程负责任务本身的承载开销。两者不是二选一,而是能叠在一起用。

第二步:CompletableFuture 的三个必改默认行为

1. 别裸用 supplyAsync

// 隐患:跑在 ForkJoinPool.commonPool 上
CompletableFuture.supplyAsync(() -> rpc.call());

commonPool 并行度默认是 CPU 核数 - 1,塞满阻塞调用会把整个 JVM 的并行流一起拖死。必须显式传线程池:

ExecutorService pool = new ThreadPoolExecutor(
    16, 64, 60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(200),
    r -> new Thread(r, "biz-" + r.hashCode()),
    new ThreadPoolExecutor.CallerRunsPolicy());

CompletableFuture<User> uf = CompletableFuture.supplyAsync(() -> userService.get(id), pool);

2. 必须加超时(JDK 9+)

UserVO vo = uf.thenCombine(of, UserVO::new)
             .orTimeout(800, TimeUnit.MILLISECONDS)
             .exceptionally(e -> UserVO.empty())
             .join();

注意:`orTimeout` 只把 future 标记成超时异常,底层任务还在跑,不会中断线程。真要取消,得自己持有 Future 引用调 `cancel(true)`,并且你的 HTTP/RPC 客户端要响应中断。

3. 异常出口用 handle 或 whenComplete 统一收口,别到处散落 exceptionally,否则一个 `thenApply` 抛异常,后面整条链都会带着异常往下传。

第三步:虚拟线程的两种起法

// 一次性任务
Thread.startVirtualThread(() -> httpGet(url));

// 推荐:有名字、每任务一线程的 Executor(try-with-resources 会自动关闭)
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f = ex.submit(() -> httpGet(url));
    String r = f.get();
}

Spring Boot 3.2+ 只需在 `application.yml` 加一行,Tomcat 请求线程就换成虚拟线程,业务代码不用改:

spring:
  threads:
    virtual:
      enabled: true

第四步:虚拟线程的四个坑

1. 被 synchronized 钉住(pinning)

JDK 21/22 里,在 `synchronized` 块内做阻塞操作会把载体线程一起占住。改用 `ReentrantLock`,并用 `-Djdk.tracePinnedThreads=full` 排查(JDK 24 的 JEP 491 已解决该问题)。

2. 不要池化

用 `Executors.newFixedThreadPool` 去包装虚拟线程等于白用——虚拟线程本来就该随用随建。

3. ThreadLocal 慎用

虚拟线程数量可能上万甚至几十万,每个都塞大对象会迅速撑爆堆内存。能用参数传递就别用 ThreadLocal。

4. 必须自己限流

虚拟线程默认没有上限,不加以约束的话,先崩的是下游的数据库连接池。用 `Semaphore` 兜底:

Semaphore permits = new Semaphore(200);
permits.acquire();
try { doWork(); } finally { permits.release(); }

第五步:混用才是常态

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    CompletableFuture<User> uf = CompletableFuture.supplyAsync(() -> userService.get(id), ex);
    CompletableFuture<Order> of = CompletableFuture.supplyAsync(() -> orderService.list(id), ex);
    return uf.thenCombine(of, UserVO::new)
             .orTimeout(800, TimeUnit.MILLISECONDS)
             .join();
}

虚拟线程做执行器,让阻塞调用变廉价;CompletableFuture 做编排,负责合并、超时、降级。

第六步:选型清单

场景选择
单次调用、下游是阻塞 IO虚拟线程 + 同步写法
3~5 个下游聚合、要超时降级CompletableFuture(执行器传虚拟线程池)
CPU 密集型计算两者都不用,用固定线程池或 ForkJoinPool
已有大量 thenApply 链路保留 CompletableFuture,只换执行器
JDK 17 及以下只能用 CompletableFuture

注意:判断标准其实很简单——**任务之间的关系复杂,用 CompletableFuture;任务数量大但彼此独立,用虚拟线程;两者都占,就叠起来用。**

小结

  • CompletableFuture 解决编排,虚拟线程解决承载,不是替代关系。
  • CompletableFuture 三件事必做:显式传线程池、加 orTimeout、统一异常出口。
  • 虚拟线程四件事必查:synchronized pin、不要池化、慎用 ThreadLocal、自己限流。
  • CPU 密集型任务两个都不适合,老老实实用固定大小线程池。
  • 升级到 JDK 21 后,把 CompletableFuture 的执行器换成 `newVirtualThreadPerTaskExecutor()` 是最小改动、最大收益的一步。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-533.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~