Java 虚拟线程(Virtual Threads)实战——Project Loom 对并发编程的颠覆性改变
Java 虚拟线程(Virtual Threads)实战——Project Loom 对并发编程的颠覆性改变
适读人群:Java后端开发者,对新并发模型感兴趣的工程师 | 阅读时长:约19分钟 | 核心价值:彻底理解虚拟线程的工作原理,以及它对现有并发编程思维的颠覆
我用虚拟线程重写了一个接口,接口延迟降了70%
2024年初,我们把一个历史遗留的同步HTTP调用密集型服务从JDK 11升级到了JDK 21。升级过程中,我试着把线程池改成了虚拟线程。
改动只有3行代码:
// 改之前
ExecutorService pool = Executors.newFixedThreadPool(200);
// 改之后
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();上线后,接口的平均延迟从320ms降到了94ms,P99从1200ms降到了380ms,服务的吞吐量提升了约3.5倍。线程池里的200个线程,被无限数量的虚拟线程取代,内存使用反而下降了40%。
这不是魔法,是工程上的正确选择。今天把虚拟线程的原理和实战经验完整写出来。
传统线程的瓶颈
Java的传统线程(Platform Thread)是OS线程的包装,一个Java线程对应一个OS线程。
OS线程的成本:
- 创建成本:约1ms,需要系统调用
- 内存占用:默认栈大小512KB~1MB(可配置),1万个线程需要5-10GB内存
- 切换成本:上下文切换约1-10μs
典型Web服务的线程利用率:
请求处理 10ms:
├── CPU计算:0.5ms (5%)
├── DB查询:5ms (50%)
├── 远程调用:4ms (40%)
└── 其他IO:0.5ms (5%)
线程有效利用率:约5%
95%的时间在等待IO,线程被阻塞什么也不做,但OS线程依然占用内存和调度资源这就是为什么IO密集型服务的线程数要设得很大(通常是CPU核数的几十倍)——大部分时间线程都在等待,需要足够多的线程才能处理并发请求。
虚拟线程:轻量化的并发单元
核心概念
虚拟线程(Virtual Thread)是JVM层面的线程抽象,不直接对应OS线程。
JVM 调度架构:
虚拟线程 VT1 虚拟线程 VT2 虚拟线程 VT3 ... 虚拟线程 VT1000000
\ | /
┌─────────────────┐
│ ForkJoinPool │ (Carrier Thread载体线程)
│ CT1 CT2 CT3 │ 数量 = CPU核数
└─────────────────┘- 虚拟线程:JVM管理,数量可以是百万级,每个只占几KB内存(不是MB)
- 载体线程(Carrier Thread):真正的OS线程,数量等于CPU核数(通常)
- 挂载/卸载(Mount/Unmount):虚拟线程阻塞时,JVM自动把它从载体线程上卸载,载体线程继续执行其他虚拟线程
阻塞时发生了什么
传统线程阻塞(等待DB查询):
OS线程1:[执行中] → [阻塞等待] → [阻塞等待] → [阻塞等待] → [恢复执行]
OS线程完全被占用,无法做其他事
虚拟线程阻塞(等待DB查询):
虚拟线程VT1 挂载在 载体线程CT1 上
↓ VT1 调用阻塞IO
JVM:将VT1从CT1上卸载(状态保存到堆内存,不是栈!)
CT1 继续执行 VT2
IO完成后,VT1 重新挂载到某个可用的载体线程上这就是虚拟线程的魔力:IO等待的开销从"占用OS线程"变成了"在堆内存中存储状态",后者的成本要低得多。
完整实战代码
基础用法
import java.util.concurrent.*;
/**
* 虚拟线程基础用法演示
* JDK 21+
*/
public class VirtualThreadBasics {
public static void main(String[] args) throws Exception {
// ===== 创建方式1:Thread.ofVirtual() =====
Thread vt = Thread.ofVirtual()
.name("my-virtual-thread-", 0) // 线程名前缀
.start(() -> {
System.out.println("在虚拟线程中执行: " + Thread.currentThread());
System.out.println("是虚拟线程: " + Thread.currentThread().isVirtual());
});
vt.join();
// ===== 创建方式2:ExecutorService =====
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 每个任务一个虚拟线程(不再需要控制线程池大小)
Future<String> future = executor.submit(() -> {
Thread.sleep(100); // 阻塞,但不占用OS线程
return "完成";
});
System.out.println(future.get());
}
// ===== 创建方式3:Thread.Builder.ofVirtual() =====
Thread.Builder.OfVirtual builder = Thread.ofVirtual()
.name("worker-", 0)
.uncaughtExceptionHandler((t, e) ->
System.err.println("线程 " + t.getName() + " 异常: " + e.getMessage())
);
// 创建并立刻启动
Thread t = builder.start(() -> doWork());
t.join();
// 只创建,不启动
Thread unstarted = builder.unstarted(() -> doWork());
// unstarted.start();
}
private static void doWork() {
try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}高并发IO密集型场景
/**
* 用虚拟线程处理大量并发HTTP请求
* 场景:爬虫、批量API调用、数据同步
*/
public class VirtualThreadCrawler {
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor()) // HTTP客户端也用虚拟线程
.build();
/**
* 并发抓取100万个URL
* 传统方案:需要几百个平台线程,内存压力大
* 虚拟线程:轻松创建100万个虚拟线程,内存占用仅增加几GB
*/
public List<String> crawlUrls(List<String> urls) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> fetchUrl(url)))
.toList();
List<String> results = new ArrayList<>();
for (Future<String> future : futures) {
try {
results.add(future.get(10, TimeUnit.SECONDS));
} catch (TimeoutException e) {
results.add("TIMEOUT");
} catch (ExecutionException e) {
results.add("ERROR: " + e.getCause().getMessage());
}
}
return results;
}
}
private String fetchUrl(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.build();
HttpResponse<String> response = HTTP_CLIENT.send(
request, HttpResponse.BodyHandlers.ofString()
);
return response.body();
}
}Spring Boot 集成虚拟线程
/**
* Spring Boot 3.2+ 开启虚拟线程非常简单
*/
// 方式1:配置属性(Spring Boot 3.2+)
// application.yml:
// spring:
// threads:
// virtual:
// enabled: true
// 这一行配置会让Tomcat的请求处理线程全部改用虚拟线程
// 方式2:手动配置(Spring Boot 3.0/3.1)
@Configuration
public class VirtualThreadConfig {
// Tomcat 使用虚拟线程处理请求
@Bean
public TomcatProtocolHandlerCustomizer<?> tomcatVirtualThreads() {
return handler -> handler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
// 自定义业务线程池也用虚拟线程
@Bean("taskExecutor")
public AsyncTaskExecutor virtualThreadTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}虚拟线程的使用限制与注意事项
不适合的场景
CPU密集型任务:虚拟线程不能提升CPU利用率,CPU密集任务用与CPU核数相当的平台线程更高效。
synchronized 的 pinning 问题(JDK 21 已部分改善,JDK 24修复)
// 危险:在synchronized块内阻塞
public synchronized void doWork() {
Thread.sleep(1000); // 虚拟线程被"钉住"(pinned)到载体线程
// 即使虚拟线程阻塞,它也不会卸载载体线程
// 效果退化为传统线程,失去虚拟线程优势
}
// 正确:改用 ReentrantLock(不会 pinning)
private final ReentrantLock lock = new ReentrantLock();
public void doWork() {
lock.lock();
try {
Thread.sleep(1000); // 虚拟线程可以正常卸载
} finally {
lock.unlock();
}
}检查 pinning 的方法:
# JVM参数:出现pinning时打印日志
-Djdk.tracePinnedThreads=short # 输出简短信息
-Djdk.tracePinnedThreads=full # 输出完整栈ThreadLocal 的使用注意
虚拟线程数量可能是百万级,不适合在ThreadLocal里存大对象:
// 不推荐:百万个虚拟线程,每个都有一个大对象实例
static ThreadLocal<HeavyObject> heavyLocal = new ThreadLocal<>();
// 推荐:使用 Scoped Values(JDK 21 预览,JDK 25 正式)
// ScopedValue 是虚拟线程时代的ThreadLocal替代品三个踩坑实录
坑一:JDBC连接池限制了虚拟线程的吞吐量
现象: 改用虚拟线程后,HTTP层面的并发能力大幅提升,但DB查询的延迟反而升高了。
原因: 虚拟线程可以轻松创建10000个并发请求,但连接池只有20个连接(maximumPoolSize=20)。10000个虚拟线程都在等连接,形成了新的瓶颈。
解法: 适当增大连接池上限(但不能无限增大,DB有最大连接数限制):
# HikariCP 配置
spring:
datasource:
hikari:
maximum-pool-size: 100 # 根据DB服务器能力调整
minimum-idle: 20
connection-timeout: 3000 # 3秒超时,避免等太久同时考虑使用响应式数据库驱动(R2DBC),从根本上消除线程阻塞等待连接的问题。
坑二:synchronized 导致的 pinning,CPU 飙升
现象: 迁移到虚拟线程后,CPU使用率从40%飙升到了95%,性能反而下降。
原因: 代码中大量使用synchronized,虚拟线程在synchronized块内阻塞时无法卸载,载体线程被钉住,JVM为了保持吞吐量创建了更多载体线程(可能超过CPU核数),导致CPU竞争加剧。
排查命令:
# 在JVM参数里加上
-Djdk.tracePinnedThreads=short
# 日志里会出现类似:
# Thread[#7...]: PINNED - synchronized解法: 把IO等待密集的代码路径中的synchronized改成ReentrantLock,消除pinning。
坑三:虚拟线程 + CompletableFuture 组合使用,出现嵌套阻塞
现象: 在虚拟线程中调用了CompletableFuture.get()等待结果,虚拟线程被阻塞(但这本来是OK的),但内部的CompletableFuture任务被提交到了ForkJoinPool.commonPool()(平台线程池),导致两层线程池交叉。
原因: CompletableFuture.supplyAsync()不指定Executor时,默认使用ForkJoinPool.commonPool()(平台线程),这本来也没问题,但如果平台线程的CompletableFuture任务里又调用阻塞操作,就会浪费平台线程。
解法: 在虚拟线程友好的场景里,可以直接用传统的同步阻塞代码,不需要CompletableFuture:
// 虚拟线程时代:可以直接写同步代码,不需要异步编排
// 因为阻塞虚拟线程的代价很小(卸载到堆内存,不占OS线程)
String result1 = fetchFromDB(); // 阻塞,但虚拟线程可以卸载
String result2 = callExternalApi(); // 阻塞,但虚拟线程可以卸载
return combine(result1, result2);
// 如果确实需要并行:用结构化并发(JDK 21 预览,JDK 25 正式)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<String> r1 = scope.fork(() -> fetchFromDB());
Supplier<String> r2 = scope.fork(() -> callExternalApi());
scope.join().throwIfFailed();
return combine(r1.get(), r2.get());
}虚拟线程 vs 响应式编程
| 维度 | 虚拟线程 | 响应式(WebFlux/Reactor) |
|---|---|---|
| 编程模型 | 同步阻塞(简单直观) | 异步非阻塞(学习成本高) |
| 调试难度 | 普通线程栈,易调试 | 复杂的响应式调用链 |
| 性能 | 极高(IO密集) | 极高(IO密集) |
| CPU密集 | 不适合 | 不适合 |
| 迁移成本 | 极低(改配置即可) | 极高(代码要重写) |
| JDK版本 | JDK 21+ | JDK 8+ |
我的判断:如果是新项目,虚拟线程是首选——同样的吞吐量,代码简单10倍。响应式编程的时代窗口已经因为Project Loom而关闭了。
小结
虚拟线程是Java有史以来最重要的并发编程改进之一:
- 百万级并发单元:内存占用从MB级降到KB级
- 阻塞不再昂贵:IO等待时自动卸载,不占OS线程
- 代码无需改写:同步阻塞代码可以获得和异步代码相当的吞吐量
- Spring Boot 3.2+ 一行配置开启
从Java 1.0到今天,我们用线程池、CompletableFuture、响应式编程,本质上都是在用各种方式弥补"一个请求一个线程"模型的内存和上下文切换开销。虚拟线程从根本上解决了这个问题,让"一个请求一个虚拟线程"成为可行的架构选择。
这不只是一个新API,而是Java并发编程思维的一次颠覆。
