虚拟线程是JDK 19以预览特性引入、JDK 21正式转正的轻量级线程方案。它的出现改变了Java并发模型的基本格局:过去一个请求一个线程的写法受限于操作系统线程数量,动辄需要引入异步框架才能支撑高并发;现在直接创建百万级虚拟线程也不会有太大压力。但这不意味着虚拟线程可以无脑替换传统线程,它有自己的脾气和使用边界。本文结合实际项目经验,梳理虚拟线程的原理、最佳实践和常见坑点,帮助你少走弯路。

虚拟线程的底层原理:载体线程与挂载机制
理解虚拟线程的关键在于理解它和平台线程的关系。虚拟线程本身不直接映射到操作系统线程,而是由JVM调度到载体线程(carrier thread)上运行。默认情况下,JDK会创建与CPU核心数相同的ForkJoinPool作为调度器。当虚拟线程执行到阻塞操作(如网络IO、Thread.sleep()、LockSupport.park())时,JVM会把它从载体线程上卸载,载体线程转而去执行其他虚拟线程,等到可运行时再重新挂载。
这个机制带来了两个重要推论。第一,虚拟线程的创建成本极低,它只是堆上的一个对象,占用几百字节起步,因此可以放心地按任务粒度创建,不必池化。第二,阻塞操作对虚拟线程来说不再是昂贵行为,Thread.sleep()一百万个虚拟线程也不会耗尽系统资源。但要注意,并非所有阻塞都能触发卸载,如果阻塞发生在synchronized块内或调用了本地方法,虚拟线程会被钉在载体线程上,这就是后文要讲的pinning问题。
另一个容易误解的点是调度策略。虚拟线程调度器是工作窃取式的FIFO调度,不保证执行顺序,也没有优先级概念。调用setPriority()在虚拟线程上是无效操作。如果你的业务逻辑依赖线程优先级或严格的时间片轮转,虚拟线程并不适合。
最佳实践:不要池化,用信号量限流
使用平台线程多年的开发者最深的习惯就是线程必须放进线程池。但虚拟线程的设计初衷就是让其变得廉价可丢弃,正确的做法是每个任务创建一个新的虚拟线程,用完即弃。Executors.newVirtualThreadPerTaskExecutor()返回的执行器内部就是这么做的:每次提交任务都新建一个虚拟线程,没有复用逻辑。
// 正确用法:每个任务一个虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// 业务逻辑
return handleRequest(taskId);
});
}
} // close()会等待所有任务完成
如果你需要限制并发数量,比如下游数据库只允许100个连接,正确工具是Semaphore而不是线程池。线程池限流的问题在于它把并发控制线程数量和任务队列混在一起,排队中的任务无法及时感知取消信号,而且池大小需要人为估算。信号量则把问题简化为纯粹的并发数控制,语义更清晰:
private final Semaphore dbPermits = new Semaphore(100);
public void queryWithLimit() throws InterruptedException {
dbPermits.acquire();
try {
// 数据库操作,最多100个虚拟线程同时进入
runQuery();
} finally {
dbPermits.release();
}
}
同时要避免缓存虚拟线程本身。有些框架会把Thread对象缓存复用,这种做法对虚拟线程有害无益,因为虚拟线程携带的栈信息和ThreadLocal状态会随着复用产生脏数据。创建新虚拟线程的开销远低于维护缓存逻辑的复杂度。
避开钉住问题:synchronized与本地方法
钉住是虚拟线程使用中最常见的性能杀手。当虚拟线程在synchronized块或方法内发生阻塞时,JVM无法将其从载体线程卸载,载体线程会被白白占用。假设你的调度器有8个载体线程,而8个虚拟线程同时卡在被synchronized保护的数据库调用上,整个应用就彻底瘫痪了,其余虚拟线程全部排队等待。
解决方案是把synchronized替换为ReentrantLock。ReentrantLock基于JUC框架实现,阻塞时会正确触发虚拟线程卸载。如果遗留代码难以改动,可以先通过JVM参数-Djdk.tracePinnedThreads=full在测试环境定位钉住发生的堆栈,再针对性修复。JDK 24对这个机制做了改进,synchronized不再导致钉住,但在更早的LTS版本上这个问题真实存在。
// 有钉住风险的写法
public synchronized void process() {
callRemoteService(); // 阻塞调用发生在synchronized内
}
// 改进后的写法
private final ReentrantLock lock = new ReentrantLock();
public void process() {
lock.lock();
try {
callRemoteService();
} finally {
lock.unlock();
}
}
除了synchronized,调用JNI本地方法时的阻塞同样无法卸载,这类场景需要评估第三方库的实现。文件IO在多数操作系统上也依赖载体线程阻塞实现,虽然JDK做了内部优化,但密集的文件操作仍可能占用载体线程,设计时要留意。
ThreadLocal的隐患与替代方案
虚拟线程数量动辄百万,ThreadLocal在这种规模下会暴露两个问题。首先是内存压力,每个虚拟线程都有自己的ThreadLocalMap,如果在线程中塞入大对象(比如缓存了整个用户会话),百万线程乘以单份大小,内存很快就会爆掉。其次是生命周期混乱,虚拟线程用完即弃但ThreadLocal的值需要显式清理,遗漏remove()会造成对象滞留。
JDK 21提供了作用域值作为更合适的替代。ScopedValue的生命周期与代码块绑定,不可变且自动清理,非常适合传递请求上下文这类只读数据:
private static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();
public void handleRequest() {
UserContext ctx = loadContext();
ScopedValue.where(CURRENT_USER, ctx)
.run(() -> processBusinessLogic());
}
private void processBusinessLogic() {
// 在调用链任意深处获取上下文
UserContext ctx = CURRENT_USER.get();
// ...
}
对于确实需要可变状态或池化场景,可以用ScopedValue.Carrier配合结构化并发管理。结构化并发本身也是Project Loom的一部分,StructuredTaskScope能把父子任务的生命周期绑定在一起,父任务取消时子任务自动取消,配合虚拟线程使用可以写出既清晰又安全的并发代码。
Spring Boot项目接入实战与压测建议
Spring Boot从3.2开始支持虚拟线程,开启方式非常简单,在配置文件中加一行:
# application.properties spring.threads.virtual.enabled=true
开启后Tomcat的请求处理、@Async异步方法、Spring Boot 3.2+中的调度任务都会切换到虚拟线程执行。但接入前建议逐项检查依赖:连接池组件如HikariCP本身是兼容的,因为池化的是连接而不是线程;但一些老版本的网络库(如Netty 4.1.90之前的部分场景)、使用synchronized保护阻塞调用的驱动需要升级确认。JDBC驱动方面,MySQL Connector/J 8.0.33之后和PostgreSQL 42.6之后的版本对虚拟线程友好度更高。
压测环节要重点关注两个指标:载体线程的占用情况和GC压力。可以通过JFR事件的jdk.VirtualThreadPinned监控钉住,通过jdk.VirtualThreadSubmitFailed发现创建失败。压测对比时不要只看吞吐量,还要看P99延迟,虚拟线程在IO密集型场景下吞吐量提升明显,但CPU密集型任务上不会有收益,反而可能因为调度开销略有下降。
最后给一个务实的上线策略:先在只读查询类的服务上灰度开启虚拟线程,观察一到两周的JFR数据确认没有钉住和内存异常,再逐步扩大范围。遇到疑难问题时,jcmd <pid> Thread.dump_to_file -format=json dump.json可以输出包含虚拟线程的完整线程转储,是排查死锁和挂起问题的利器。虚拟线程是Java并发能力的重大升级,用好它需要理解原理、尊重边界,而不是简单地做一次字符串替换。
Java虚拟线程Project LoomJDK 21修改时间:2026-09-03 21:59:20