引言
Java 21 正式引入了虚拟线程(Virtual Thread,JEP 444),这是自 Java 5 引入 java.util.concurrent 以来,Java 并发模型最重大的一次变革(Java 5到现在已经过了20多年了!烂大街的东西终于在Java这里成了新特性!不过有总比没有好)。虚拟线程的目标并不是让程序"跑得更快",而是让高并发 IO 密集型应用的开发回归简单。
本文将从基本原理出发,深入探讨虚拟线程的工作机制、常见陷阱、与现有框架的集成策略以及调试监控方法,帮助你在生产环境中正确且高效地使用这一特性。
一、基本原理
1.1 主要作用:让每请求每线程再次成为可能
在传统 Java 服务端编程中,我们习惯用一个线程处理一个请求(Thread-per-Request)。这种模型代码直观、易于调试,异常处理也符合直觉:
// 传统的阻塞式写法:简单、直观
Response handle(Request req) {
var user = userService.findById(req.getUserId()); // 阻塞等待数据库
var orders = orderService.listByUser(user.getId()); // 阻塞等待数据库
var result = buildResponse(user, orders);
return result;
}
问题在于,原生线程(Platform Thread)是昂贵的。每个原生线程默认占用约 1MB 栈内存,且与操作系统内核线程 1:1 映射。当并发连接数达到数万甚至数十万时,操作系统无法承载如此多的内核线程,线程池成为瓶颈。
为了突破这一限制,社区发展出了异步非阻塞的编程范式:
// Reactor 风格:高性能,但代码复杂度剧增
Mono<Response> handle(Request req) {
return userService.findById(req.getUserId())
.flatMap(user -> orderService.listByUser(user.getId())
.map(orders -> buildResponse(user, orders)));
}
Reactor、RxJava、Vert.x 等框架通过事件循环(Event Loop)和回调链将阻塞操作从线程中解耦,让少量线程服务大量并发请求。代价是:代码变得晦涩,调用栈丢失,调试困难,异常处理反直觉,学习曲线陡峭。
虚拟线程的核心价值,正是消除这个两难困境。 它让你用回最朴素的阻塞式顺序代码,同时获得接近异步框架的并发能力:
// 虚拟线程:写法和传统完全一致,但底层不再绑定原生线程
Response handle(Request req) {
var user = userService.findById(req.getUserId()); // 虚拟线程在此挂起,不占用原生线程
var orders = orderService.listByUser(user.getId()); // 同上
var result = buildResponse(user, orders);
return result;
}
// 启动方式
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> handle(req));
}
}
这就是虚拟线程的核心主张:用同步的写法,享受异步的吞吐。
1.2 常见误区:虚拟线程不是银弹
必须首先澄清一个广泛存在的误解:
虚拟线程不会让你的程序运行得更快。
虚拟线程提升的是并发度(Concurrency) ,而非计算性能(Performance) 。
具体而言:
- CPU 密集型场景:虚拟线程毫无优势。最终所有代码都要在原生线程上执行,虚拟线程数量再多,可用的 CPU 核心数不会增加。如果你的瓶颈是 CPU 计算,虚拟线程只会增加调度开销。
- IO 密集型场景:虚拟线程的优势体现在"等待"上。当一个虚拟线程阻塞在 IO 操作上时,底层的原生线程被释放去执行其他虚拟线程,从而让有限的原生线程服务远超其数量的并发请求。
- 受限于总吞吐:即使你创建了 100 万个虚拟线程,如果下游数据库每秒只能处理 1000 个查询,整体吞吐也不会超过 1000 QPS。虚拟线程不会凭空增加上下游系统的处理能力。
一句话总结:虚拟线程解决的是"线程不够用"的问题,而不是"CPU 不够快"的问题。
1.3 与原生线程的映射关系
虚拟线程采用 M:N 调度模型:M 个虚拟线程映射到 N 个原生线程上(也称为载体线程,Carrier Thread)。
JVM 默认使用一个专用的 ForkJoinPool 作为虚拟线程的调度器,其载体线程数默认等于 可用 CPU 核心数(Runtime.getRuntime().availableProcessors())。这意味着在一台 8 核机器上,默认只有 8 个载体线程,却可以同时运行数十万甚至上百万个虚拟线程。
1.4 轻量级切换
切换的时机
虚拟线程的切换发生在 JVM 内部,而非操作系统层面。当虚拟线程执行到以下操作时,会主动让出(yield) 载体线程:
- 阻塞 IO 操作:
Socket.read()、Socket.write()、FileChannel操作等 Thread.sleep()BlockingQueue.take()/put()Lock.lock()(ReentrantLock等java.util.concurrent锁)CompletableFuture.get()/join()
与操作系统级别的上下文切换相比,虚拟线程的切换成本极低:
| 维度 | 原生线程切换 | 虚拟线程切换 |
|---|---|---|
| 切换层面 | 内核态 | 用户态(JVM 内部) |
| 栈处理 | 保存/恢复完整寄存器 | 将栈帧拷贝到堆 / 从堆恢复 |
| 典型耗时 | 1-10 微秒 | 约 200 纳秒 |
| TLB 刷新 | 可能需要 | 不需要 |
如何处理阻塞 IO
这是虚拟线程最关键的机制。JDK 对标准库中的阻塞 IO 操作进行了改造:当虚拟线程调用阻塞 IO 时,JVM 并不会让底层的载体线程真正阻塞在系统调用上,而是:
- 将当前虚拟线程的栈帧(continuation)保存到堆内存
- 将虚拟线程标记为"等待 IO"状态
- 载体线程被释放,去执行调度队列中的其他虚拟线程
- 当 IO 就绪时(通过底层的 NIO/epoll/io_uring 机制通知),虚拟线程被重新调度到某个载体线程上继续执行
本质上,虚拟线程在 JVM 层面实现了操作系统的 IO 多路复用,但对应用开发者完全透明。
要注意饥饿问题
由于载体线程数量有限(默认等于 CPU 核心数),如果某些虚拟线程长时间占用载体线程不释放,会导致其他虚拟线程无法获得执行机会,产生饥饿(Starvation) 。
典型的饥饿场景:
- 虚拟线程中执行了长时间的 CPU 密集计算
- 虚拟线程持有
synchronized锁并在锁内执行阻塞 IO(即 Pinning,后文详述) - 调用了未经适配的 native 方法导致载体线程阻塞
饥饿的表现通常是:系统吞吐量骤降,大量虚拟线程处于"可运行但未运行"的状态,而 CPU 利用率可能并不高。
二、注意隔离
2.1 限制虚拟线程底层的原生线程数目
虚拟线程的调度器(ForkJoinPool)默认使用与 CPU 核心数相同的载体线程。在某些场景下,你可能需要调整这个数量:
# 通过 JVM 参数设置载体线程数
-Djdk.virtualThreadScheduler.parallelism=4
-Djdk.virtualThreadScheduler.maxPoolSize=4
什么时候需要调整?
- 当你的应用中混合使用虚拟线程和原生线程,且原生线程也有较大的 CPU 需求时,应适当减少虚拟线程调度器的并行度,为原生线程预留 CPU 资源。
- 当你需要控制对下游系统的并发压力时(虽然信号量是更推荐的方式)。
2.2 CPU 密集型任务使用原生线程
这是一条重要的设计原则:虚拟线程专用于 IO 密集型任务,CPU 密集型任务应继续使用原生线程池。
// IO 密集型:使用虚拟线程
ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();
// CPU 密集型:使用固定大小的原生线程池
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// 混合场景示例
void processRequest(Request req) {
// IO 操作在虚拟线程中执行
var data = fetchFromDatabase(req); // 当前已在虚拟线程中
// CPU 密集计算提交到原生线程池
var future = cpuExecutor.submit(() -> heavyComputation(data));
var result = future.get(); // 虚拟线程在此挂起等待,不浪费载体线程
sendResponse(result);
}
这样做的原因是:
- CPU 密集任务会长时间占用载体线程,导致其他虚拟线程饥饿
- 虚拟线程的调度开销虽小,但对于纯计算任务来说仍是不必要的额外开销
- 原生线程池的大小可以精确控制,更适合 CPU 资源管理
三、最大的问题:Pinning
3.1 问题的形式
Pinning(固定) 是虚拟线程目前面临的最严重的实际问题。当虚拟线程在以下两种情况下执行阻塞操作时,它会被"钉"在载体线程上,导致载体线程无法被释放:
- 在
synchronized代码块或方法中执行阻塞操作 - 在 native 方法帧内执行阻塞操作
// 典型的 Pinning 场景
synchronized (lock) {
// 虚拟线程在此被固定到载体线程上
var result = httpClient.send(request, bodyHandler); // 阻塞IO!载体线程也被阻塞
process(result);
}
在上述代码中,虚拟线程持有 synchronized 锁并执行了网络 IO。正常情况下,虚拟线程会在 IO 阻塞时让出载体线程。但由于 synchronized 的存在,JVM 无法安全地将虚拟线程从载体线程上卸载,因为 synchronized 的监视器(monitor)与特定的操作系统线程绑定。结果就是:载体线程被白白阻塞,和传统原生线程无异。
标准库中隐藏的 synchronized
更棘手的是,Pinning 问题往往隐藏在你看不到的地方。Java 标准库和第三方库中大量使用了 synchronized。
一些常见的"陷阱"来源:
| 来源 | 典型类/方法 |
|---|---|
| 标准 IO 流 | PrintStream、BufferedOutputStream、BufferedInputStream |
| JDBC 驱动 | 多数驱动内部使用 synchronized 保护连接状态 |
| 日志框架 | Logback/Log4j2 的某些 Appender |
| HTTP 客户端 | Apache HttpClient 4.x 的连接池 |
| 序列化 | ObjectOutputStream / ObjectInputStream |
你可以通过以下 JVM 参数检测 Pinning 事件:
# JDK 21+:当发生 Pinning 时打印警告
-Djdk.tracePinnedThreads=short
# 输出示例:
# Thread[#37,ForkJoinPool-1-worker-3,5,CarrierThreads]
# java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
# java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
# java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)
# java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
# java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
# java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269)
# ...
# com.example.MyService.fetchData(MyService.java:42) <== blocked here
# while holding monitor for java.lang.Object@6b71769e <== pinning cause
3.2 问题的根源
要理解 Pinning 为何发生,需要了解虚拟线程挂起(unmount)的底层机制。
虚拟线程的挂起本质上是 Continuation 的让出(yield) 。当虚拟线程需要挂起时,JVM 会:
- 将当前虚拟线程的调用栈(即 Continuation)从载体线程的栈上拷贝到堆内存
- 恢复载体线程的状态,让调度器将其分配给其他虚拟线程
问题出在 synchronized 的实现上。Java 的 synchronized 使用的是 对象监视器(Object Monitor) ,而对象监视器在 HotSpot JVM 中与操作系统线程(即载体线程)深度绑定:
synchronized 的内部机制(简化):
1. monitorenter 指令
└── 将当前 OS 线程的 ID 记录在对象头的 Mark Word 中
└── 如果需要膨胀为重量级锁,则关联一个 OS 级的 mutex
2. 在 synchronized 块内部
└── monitor 持有者 = 特定的 OS 线程(载体线程)
└── 如果此时要 unmount 虚拟线程并将载体线程交给另一个虚拟线程...
└── 新的虚拟线程运行在同一个载体线程上
└── 它"看起来"像是 monitor 的持有者(因为是同一个 OS 线程)
└── 监视器的语义被破坏!
3. monitorexit 指令
└── 验证当前 OS 线程是否是 monitor 持有者
└── 如果虚拟线程被调度到了不同的载体线程上恢复执行
└── OS 线程 ID 不匹配 → 解锁失败 → 崩溃
因此,JVM 不得不在虚拟线程持有 synchronized 锁时禁止卸载,将其固定在当前载体线程上。这不是设计缺陷,而是在当前 synchronized 实现下保证正确性的必要措施。
相比之下,java.util.concurrent.locks.ReentrantLock 完全在 Java 层面实现,基于 AbstractQueuedSynchronizer(AQS),其锁的持有者以 Thread 对象而非 OS 线程 ID 来标识。因此 ReentrantLock 可以正确地与虚拟线程协作——虚拟线程在持有 ReentrantLock 时可以正常挂起和恢复。
// ✗ 会导致 Pinning
synchronized (lock) {
blockingIO();
}
// ✓ 不会导致 Pinning
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
blockingIO();
} finally {
lock.unlock();
}
3.3 JDK 24 的解决方案
Oracle 在 JDK 24(2025 年 3 月发布)中通过 JEP 491 从根本上解决了 synchronized 的 Pinning 问题。
JEP 491 重新实现了对象监视器,使其不再与操作系统线程绑定:
- 当虚拟线程持有
synchronized锁并需要阻塞时,JVM 现在可以安全地将其从载体线程上卸载 - 监视器的持有者信息改为跟踪虚拟线程而非载体线程
- 这一改变对应用代码完全透明
这意味着从 JDK 24 开始,你不再需要将所有 synchronized 替换为 ReentrantLock,也不必过度担忧第三方库中隐藏的 synchronized。
注意:JDK 24 解决的是
synchronized的 Pinning 问题。Native 方法帧导致的 Pinning 仍然存在,因为 JVM 无法操控 native 代码的栈帧。但在实际开发中,native 方法帧内的阻塞远不如synchronized常见。
四、在虚拟线程中不要再用 ThreadLocal
4.1 问题描述
ThreadLocal 是 Java 中实现线程局部存储的经典机制,广泛用于传递请求上下文、数据库连接、事务信息等。
在原生线程模型下,这种用法完全合理——线程池中的线程数量有限(通常几十到几百),每个 ThreadLocal 变量只会有与线程数相同的实例。
但在虚拟线程模型下,情况完全不同:
问题一:内存浪费
虚拟线程可以轻松创建数十万甚至上百万个。如果每个虚拟线程都持有 ThreadLocal 副本,内存消耗将是巨大的:
// 假设每个 ThreadLocal 值占 1KB
// 100 万个虚拟线程 × 1KB = ~1GB 仅用于 ThreadLocal 数据
问题二:生命周期不匹配
原生线程通常通过线程池复用,ThreadLocal 的值在线程生命周期内持续存在(配合 remove() 清理)。虚拟线程是一次性的,不应该被池化。每次创建新的虚拟线程都会重新初始化 ThreadLocal,失去了"复用"的意义。
问题三:可继承性混乱
InheritableThreadLocal 在创建子线程时会拷贝父线程的值。如果在虚拟线程中大量创建子虚拟线程,每次拷贝都有内存和时间开销。
4.2 替代方案:Scoped Values
JDK 21 引入了 Scoped Values(JEP 429,Preview),在 JDK 24 中进入第四轮预览(JEP 487),预计在未来版本中正式发布。Scoped Values 专为虚拟线程时代设计:
// 声明一个 ScopedValue
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
void handleRequest(Request req) {
ScopedValue.where(CONTEXT, new UserContext(req.getUserId()))
.run(() -> {
businessLogic(); // 内部通过 CONTEXT.get() 获取用户信息
});
// 离开 run() 作用域后,值自动不可见,无需手动 remove
}
ScopedValue 相比 ThreadLocal 的优势:
| 维度 | ThreadLocal | ScopedValue |
|---|---|---|
| 可变性 | 可变(get/set) | 不可变(绑定后不可修改) |
| 作用域 | 线程生命周期 | 显式的代码块作用域 |
| 清理 | 需要手动 remove()(容易遗忘) | 自动随作用域结束清理 |
| 继承 | InheritableThreadLocal 深拷贝 | 自动对子线程可见,零拷贝 |
| 内存效率 | 每个线程一份副本 | 基于栈帧,天然更轻量 |
| 适合虚拟线程 | ✗ | ✓ |
实际建议:
- 如果你的项目仍在 JDK 21 上且
ScopedValue尚未正式发布,短期内继续使用ThreadLocal是可以接受的,但要注意控制虚拟线程的数量,并确保及时remove()。 - 新项目应优先评估
ScopedValue,即使它仍处于预览状态。
五、要注意上下游
虚拟线程让你可以轻松创建数十万个并发任务,但这种"无限并发"的能力如果不加约束,反而会引发严重问题。
5.1 上游吞吐有限:创建太多虚拟线程没有意义
如果你的应用接收请求的速率是有限的(例如前端网关限制为 5000 QPS),那么无论你创建多少虚拟线程,在某一时刻活跃的虚拟线程数也就在数千的量级。创建虚拟线程的策略应当匹配实际的请求到达速率。
// 正确做法:按请求创建虚拟线程,不预分配
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
while (server.isRunning()) {
var socket = serverSocket.accept(); // 一个请求一个虚拟线程
executor.submit(() -> handle(socket));
}
}
// 虚拟线程数自然与并发请求数匹配
5.2 下游吞吐有限:太多虚拟线程可能将下游打垮
这是更危险的场景。假设你的应用接收了 10 万个并发请求,每个请求都需要查询数据库。如果每个请求都在虚拟线程中直接发起数据库查询,下游数据库将瞬间收到 10 万个并发连接请求——这几乎必然导致数据库崩溃。
解决方案:使用信号量(Semaphore)限流
// 用信号量限制对下游系统的并发访问
private static final Semaphore DB_LIMITER = new Semaphore(200); // 匹配数据库连接池大小
void queryDatabase(String sql) throws Exception {
DB_LIMITER.acquire(); // 虚拟线程在此挂起等待,不消耗载体线程
try {
// 最多 200 个虚拟线程同时执行数据库查询
return jdbcTemplate.query(sql);
} finally {
DB_LIMITER.release();
}
}
Semaphore.acquire() 已经被 JDK 适配为虚拟线程友好的操作——当信号量不可用时,虚拟线程会正常挂起(unmount),不会占用载体线程。这使得信号量成为虚拟线程场景下最自然的限流工具。
对其他下游系统同样适用:
// 限制对外部 HTTP API 的并发调用
private static final Semaphore API_LIMITER = new Semaphore(50);
// 限制对 Redis 的并发访问
private static final Semaphore REDIS_LIMITER = new Semaphore(500);
// 限制对消息队列的并发写入
private static final Semaphore MQ_LIMITER = new Semaphore(100);
核心原则:虚拟线程让你的应用本身不再是瓶颈,但下游系统仍然有容量上限。你有责任用信号量、连接池或其他限流手段保护下游。
六、快速释放
6.1 虚拟线程的栈是惰性增长的
原生线程在创建时就需要预分配固定大小的栈空间(默认约 1MB),无论是否用到。虚拟线程则不同——它的栈存储在堆内存中,并且是惰性增长(lazily growing) 的:这意味着虚拟线程的内存占用与其实际调用深度成正比,而非固定开销。一个刚创建的虚拟线程可能只占用几百字节到几 KB。
6.2 可以创建很多快速结束的虚拟线程
虚拟线程的设计哲学是短暂的、一次性的、按需创建的。它们不应该被池化,也不应该被复用。每个任务一个虚拟线程,任务完成后虚拟线程随即消亡,GC 负责回收。
// ✓ 推荐:大量短生命周期虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Result>> futures = new ArrayList<>();
for (var task : tasks) {
futures.add(executor.submit(() -> {
// 快速完成的 IO 操作
var data = httpClient.send(request, bodyHandler);
return process(data);
// 任务完成,虚拟线程结束,内存很快被 GC 回收
}));
}
// 收集结果
for (var future : futures) {
results.add(future.get());
}
}
这种模式下,虚拟线程的数量像水波一样起伏——请求到来时创建,处理完成后消亡。系统在任何时刻只保留活跃任务所需的虚拟线程。
6.3 避免创建大量长期存活的虚拟线程
虽然虚拟线程很轻量,但"轻量"不等于"零成本"。如果你创建了数十万个虚拟线程且它们长期存活(例如每个虚拟线程维护一个长连接、等待推送消息),这些虚拟线程的栈帧将持续占用堆内存:
// ⚠ 需要谨慎:大量长期存活的虚拟线程
for (int i = 0; i < 500_000; i++) {
Thread.startVirtualThread(() -> {
while (true) {
var message = connection.waitForMessage(); // 长期阻塞等待
process(message);
}
});
}
// 50万个虚拟线程的栈帧常驻堆内存
// 假设每个挂起的虚拟线程栈帧占 2-10KB
// 总计约 1GB - 5GB 堆内存被栈帧占用
// 还会增加 GC 扫描和压力
建议:
- 如果确实需要大量长连接,评估总内存预算,确保堆空间充足
- 监控 GC 表现,避免频繁 Full GC
- 考虑是否真的需要每个连接一个虚拟线程,或者是否可以用更少的虚拟线程配合事件驱动
6.4 避免在虚拟线程内申请操作系统资源
虚拟线程的数量可以轻松达到数十万甚至百万级别,但操作系统资源(文件描述符、Socket、端口等)是有硬性上限的,应当避免在虚拟线程中开启如下资源,如果必须开启,那么考虑使用信号量进行限流:
| 资源类型 | 典型上限 | 风险 |
|---|---|---|
| 文件描述符 | ulimit -n(默认 1024) | Too many open files |
| TCP 连接(Socket) | 端口数 65535 / conntrack 表 | 端口耗尽 / 连接表溢出 |
| 临时端口 | net.ipv4.ip_local_port_range(默认约 28000 个) | Cannot assign requested address |
| 数据库连接 | 连接池大小 / 数据库 max_connections | 连接池耗尽 |
| 进程/子进程 | ulimit -u | Resource temporarily unavailable |
七、与现有框架集成
7.1 不要与 Netty 或 WebFlux 等异步框架混合使用
虚拟线程和响应式框架(Reactor / WebFlux / Vert.x / Netty)都致力于解决同一个问题:用有限的线程处理大量并发 IO。它们只是走了不同的路径:
| 维度 | 响应式框架 | 虚拟线程 |
|---|---|---|
| 编程模型 | 异步回调 / 流式链 | 同步阻塞 |
| 线程模型 | 少量事件循环线程 | 大量虚拟线程 + 少量载体线程 |
| 适配成本 | 需要全链路异步 | 兼容现有同步代码 |
| 学习曲线 | 陡峭 | 平缓 |
| 背压机制 | 内建(Reactive Streams) | 需要手动实现(信号量等) |
如果你在 WebFlux 应用中引入虚拟线程,可能产生以下混乱:
// ✗ 混乱的混合使用
@GetMapping("/users/{id}")
Mono<User> getUser(@PathVariable Long id) {
return Mono.fromCallable(() -> {
// 这段代码在虚拟线程中执行?在 Reactor 的调度器上执行?
// 阻塞调用在 Reactor 的事件循环线程上会导致严重问题
return userRepository.findById(id); // 阻塞!
}).subscribeOn(Schedulers.boundedElastic()); // 还是用弹性线程池?
}
建议原则:
- 新项目:如果目标 JDK ≥ 25,优先选择虚拟线程 + Spring MVC(而非 WebFlux)
- 存量 WebFlux 项目:不要为了使用虚拟线程而重构,WebFlux 本身已经解决了并发问题
- 存量 Spring MVC 项目:这是虚拟线程的最佳迁移对象,只需替换底层线程池即可
// Spring Boot 3.2+ 启用虚拟线程(仅 Spring MVC)
// application.properties
spring.threads.virtual.enabled=true
// 或编程方式
@Bean
TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
7.2 注意原生线程数量的规划
实际应用中,除了虚拟线程的载体线程,还有许多框架会创建自己的原生线程,当这些原生线程与虚拟线程的载体线程争抢 CPU 资源时,可能影响虚拟线程的调度延迟。
常见来源:
- 各类连接池的 housekeeping 线程
- 缓存、日志、指标系统
- JVM 内部线程、GC、JIT
- 业务自己建的线程池(尤其是无界)
如果平台线程总量失控,会造成:
- CPU 被线程调度开销吞噬
- 虚拟线程载体线程得不到足够 CPU 时间片
- 延迟抖动
建议:
- 梳理应用中所有原生线程的来源和数量
- 在 CPU 核心数有限的容器环境(例如 Kubernetes 中分配 2-4 核)中尤其注意
- 适当调整虚拟线程调度器的并行度:
# 4核容器中,可能需要为其他原生线程预留一些CPU
-Djdk.virtualThreadScheduler.parallelism=2
-Djdk.virtualThreadScheduler.maxPoolSize=2
八、调试
虚拟线程给传统的调试和监控手段带来了新的挑战。原生线程数量有限(几十到几百),每个线程都有清晰的身份和状态;虚拟线程可能有数十万个,传统工具在面对如此量级时往往力不从心。
Thread Dump 的变化
传统的 jstack 或 kill -3 产生的线程转储会包含所有虚拟线程,当虚拟线程数量庞大时,输出可能高达数百 MB,几乎不可读:
# 传统方式:输出可能极其庞大
jstack <pid>
# JDK 21+ 推荐:使用 jcmd 生成结构化线程转储(JSON 格式)
jcmd <pid> Thread.dump_to_file -format=json threads.json
# JSON 格式的输出包含虚拟线程的分组信息,更易于分析
JDK 21 引入的新格式会将虚拟线程按其执行器(Executor)分组,而不是平铺列出所有线程。
九、监控
建议把监控重点从“线程数”转向“调度与阻塞质量”:
- 载体线程(平台线程)饱和度
- 载体线程数量、CPU 使用率
- 运行队列长度/调度延迟(如果可观测到)
- Pinned 相关
- pinned 虚拟线程次数/持续时间
- 是否出现“载体线程被阻塞占满”的迹象(吞吐断崖式下降、延迟尖刺)
- 虚拟线程数量(总量与峰值)
- 创建速率、并发峰值
- 结合 GC/堆使用观察是否因上下文/ThreadLocal/栈片段导致内存上升
- 下游资源指标(比线程更关键)
- DB 连接池:活跃/等待/超时
- HTTP 客户端连接池:租借等待、连接建立失败
- 文件描述符使用量、socket 数
- 下游请求延迟、错误率、限流/熔断触发次数
- 端到端延迟分解
- 排队时间、服务时间、依赖耗时
- 超时分布(P95/P99/P999)
工具层面可结合:JFR(Java Flight Recorder)、线程/事件采样、以及针对虚拟线程/锁/阻塞的事件分析。生产环境建议以“持续 profiling + 聚合指标”替代“靠 thread dump 人肉找”。
结语
虚拟线程不是一种全新的编程范式,恰恰相反——它的价值在于让 Java 开发者回归最朴素的编程模型。一个请求一个线程,用顺序代码表达顺序逻辑,异常处理符合直觉,调用栈完整可读。
但"简单"不等于"无脑"。虚拟线程的轻量性是一种诱惑——它让你能够轻松创建数十万个并发任务,但你必须清楚地认识到:你的应用不是一座孤岛。上游的请求速率、下游的处理能力、操作系统的资源限制,这些才是真正的边界。虚拟线程突破的只是 JVM 内部的线程数量限制,它不会凭空扩展整个系统的容量。
如果你正在启动新项目,虚拟线程(配合 JDK 24+)是处理 IO 密集型工作负载的首选方案。如果你有存量的 Spring MVC 应用,虚拟线程提供了一条几乎零成本的迁移路径。如果你已经在使用 WebFlux 并且运行良好——不必急于迁移,它们殊途同归。
选择适合你的场景的工具,而不是最新的工具。 虚拟线程是 Java 并发工具箱中一个强大的新成员,但它最终只是工具箱中的一员。理解它的能力边界,才能真正发挥它的价值。