这阵子Java圈子讨论最热闹的除了JDK 21发布了之外,就是虚拟线程了。虽然虚拟线程在Java 19和20都是预览版,但到21终于转正,算是给Java的高并发开发带来了一种全新的思路。网上讲原理的不少,但很多都停在“是什么”和“怎么用”的层面,实际项目里一跑就会碰见各种奇奇怪怪的问题。今天我结合自己的测试和踩坑经验,把虚拟线程从基础API到Spring Boot集成,再到性能对比和注意点,一条龙讲清楚。
一、为什么虚拟线程突然这么火?
以前一提到Java高并发,脑子里就是线程池、Connections、NIO、Netty这些词。为什么?因为传统的一个Java线程对应一个操作系统线程,创建和切换成本都很高。一台普通机器开几千个线程就差不多到极限了,再多CPU光顾着做线程上下文切换,业务代码跑不了几行。
于是大家想了很多办法,异步回调、Reactor模型、CompletableFuture……代码是能写了,但调试起来真是一言难尽。而虚拟线程不一样——它由JVM管理,底层用少量平台线程承载大量虚拟线程,一个虚拟线程就是一个完整的“线程”,但创建成本极低。你可以开十万个虚拟线程去处理并发任务,而不必担心系统资源耗尽。这就是它被称作“解决了Java异步编程复杂性问题”的原因。
二、虚拟线程的核心API
Java 21中,虚拟线程的使用非常简单,主要有三种创建方式。
1. Thread.ofVirtual()
Thread vThread = Thread.ofVirtual()
.name("virtual-thread-")
.start(() -> System.out.println("Hello from " + Thread.currentThread()));
如果你想先创建但不启动,可以调用unstarted(),之后手动启动。
Thread unStarted = Thread.ofVirtual().unstarted(() -> doWork());
unStarted.start();
2. Executors.newVirtualThreadPerTaskExecutor()
这是更常用的方式,特别是处理大量任务时。每次提交任务都会创建一个新的虚拟线程,执行完就回收,不用自己管理线程池大小。
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
System.out.println("Task running in " + Thread.currentThread());
});
}
} // try-with-resources 会自动调用 shutdown
三、一个真实对比实验:传统线程池 vs 虚拟线程
光说没用,跑个实验看看效果。我模拟一个典型的IO密集型场景:每个任务里“睡”100毫秒,假装在远程调用或者查数据库。用传统的固定线程池和虚拟线程分别执行1000个任务,看谁先跑完。
实验代码
import java.util.concurrent.*;
public class VThreadCompare {
public static void main(String[] args) throws InterruptedException {
int taskCount = 2000;
long sleepMillis = 100;
// 传统固定线程池,50个线程
ExecutorService fixedPool = Executors.newFixedThreadPool(50);
long start1 = System.currentTimeMillis();
CountDownLatch latch1 = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
fixedPool.submit(() -> {
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch1.countDown();
}
});
}
latch1.await();
long end1 = System.currentTimeMillis();
fixedPool.shutdown();
System.out.println("固定线程池耗时: " + (end1 - start1) + " ms");
// 虚拟线程,每个任务一个虚拟线程
ExecutorService vPool = Executors.newVirtualThreadPerTaskExecutor();
long start2 = System.currentTimeMillis();
CountDownLatch latch2 = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
vPool.submit(() -> {
try {
Thread.sleep(sleepMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch2.countDown();
}
});
}
latch2.await();
long end2 = System.currentTimeMillis();
vPool.shutdown();
System.out.println("虚拟线程耗时: " + (end2 - start2) + " ms");
}
}
结果分析
在我的测试机上,2000个任务,每个睡100ms:
- 固定线程池(50线程):大约4000ms左右
- 虚拟线程:大约105ms,几乎就是第一个任务结束后的时间
原因很简单:虚拟线程在sleep时释放了底层平台线程,让其他虚拟线程顶上。而固定线程池里的50个线程只能反复调度,排队等待。看到这个差距,你是不是也手痒了?
四、Spring Boot 3 中使用虚拟线程
如果你用的是Spring Boot 3.2及以上版本,开启虚拟线程简直不要太简单。只用改一行配置文件:
spring:
threads:
virtual:
enabled: true
这一个配置会同时影响Tomcat的Web容器线程和@Async异步方法的执行器。也就是说,你的Controller默认就会跑在虚拟线程上,不用改任何业务代码。前提是你的Spring Boot版本是3.2+,并且Java版本是21+。
如果是Spring Boot 3.0或3.1,就需要自己创建一个Tomcat定制器来设置虚拟线程执行器,稍微麻烦一点,不过也可以这么干:
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadProtocolHandlerCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
五、实战中的4个大坑,千万别踩
虚拟线程看着很美,但用错了照样翻车。下面这几个坑是我自己踩过的,也是社区里常见的问题。
坑1:synchronized会钉住平台线程
如果在虚拟线程里用了synchronized同步块,并且同步块里的操作涉及IO(比如sleep、网络请求),那么JVM的调度器可能会阻塞底层平台线程,导致虚拟线程调度无法复用该平台线程。这就是所谓的“Pinned Thread”(线程钉住)。
解决办法:尽量用ReentrantLock代替synchronized,因为ReentrantLock的lock/unlock在JDK 21中已经做了特殊处理,不会被钉住。
坑2:ThreadLocal滥用导致内存爆炸
虚拟线程非常便宜,你可能会一次性创建几十万个。如果每个虚拟线程里都放了ThreadLocal,并且没有清理,那内存压力会相当大。因为ThreadLocal是绑定在线程实例上的,虚拟线程同样继承了这一点。
建议:要么别用ThreadLocal,要么在finally里调用remove()。尤其是使用虚拟线程池时,线程不是复用的,用完就丢,ThreadLocal未清理的对象就变成垃圾了,但实际上并不会立刻回收,因为JVM线程栈还在整理。所以能不用就不用。
坑3:CPU密集型任务请绕道
虚拟线程的强项是IO密集,如果任务里全是CPU计算,比如大循环、加密解密,虚拟线程不仅没有优势,还会因为线程切换增加性能开销。因为CPU核数是固定的,虚拟线程在CPU密集场景下无法并行更多,反而多了一层调度。
坑4:Tomcat的连接器限制
如果你在Spring Boot中启用了虚拟线程,Tomcat的默认接受连接数可能成为瓶颈。默认的acceptCount是100,maxThreads是200,但虚拟线程模式下maxThreads几乎可以设置得非常大。建议把maxThreads调到10000,并适当调整acceptCount,否则流量一大,连接照样拒绝。
server:
tomcat:
accept-count: 1000
max-threads: 10000
六、虚拟线程与JDBC、数据库连接池
很多人担心虚拟线程跟JDBCTemplate、MyBatis的兼容性。其实完全没问题,因为JDBC调用本身就是阻塞的,虚拟线程最擅长的就是把阻塞时间让给别的线程。但要注意:数据库连接池的大小需要重新考虑。以前一个Tomcat容器用200个线程,我们配20个连接就够。现在虚拟线程可以有5000个,如果每个请求都分一个数据库连接,那连接池就崩了。所以虚拟线程模式下,连接池要么调大,要么用像HikariCP这类支持动态增长的池(但也要设置最大上限),或者直接在应用层做信号量限流。
七、一段完整的“虚拟线程应用”示例
写一个小程序,模拟一个Web服务器用虚拟线程处理请求:每个请求先查缓存(sleep 20ms),再查数据库(sleep 200ms),最后返回。用固定线程池和虚拟线程分别跑,看吞吐量。
import java.util.concurrent.*;
import java.util.*;
public class SimulatedWebServer {
public static void main(String[] args) throws InterruptedException {
int requests = 500;
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 如果改成 newFixedThreadPool(100) 再跑一下试试
CountDownLatch done = new CountDownLatch(requests);
long start = System.currentTimeMillis();
for (int i = 0; i < requests; i++) {
final int id = i;
executor.submit(() -> {
try {
// 模拟缓存查询
Thread.sleep(20);
// 模拟数据库查询
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
done.await();
long cost = System.currentTimeMillis() - start;
System.out.println("总耗时: " + cost + " ms, 平均响应时间约: " + (cost / (double) requests) + " ms");
executor.shutdown();
}
}
跑一次你会发现:500个请求,固定100线程池大概需要1秒多,而虚拟线程只需要220ms左右,因为大量sleep被让出来了。
八、总结
虚拟线程这个特性,从API设计到实际表现,都算是Java这几年里最有价值的功能之一。它让“用同步代码写高并发”成为可能,也让我们少学一大堆Reactive框架。但凡事都有两面,虚拟线程不是银弹,用之前先判断业务是IO密集还是CPU密集,再想想线程局部变量和同步锁的用法,才能真正发挥它的威力。强烈建议你在自己的项目里跑一跑实验,感受一下差距。
如果你正在用Spring Boot,直接把虚拟线程开关打开,大多数接口不用改就能提升并发能力。但注意监控Tomcat的线程数和连接池状态,遇到问题再回到上面提到的那几个坑里找原因。
希望这篇实战能帮到你。有更好玩的想法,欢迎在评论区里交流。

