传统 Java 开发里,处理大量并发 I/O 操作时,线程池的配置总是让人头疼:线程数设少了吞吐量上不去,设多了内存和上下文切换开销又难以承受。Java 21 正式引入的虚拟线程(Virtual Threads)让局面变得完全不同。它把开发者从“线程是一种昂贵资源”的思维定式中解放出来,让你可以像创建字符串一样创建线程。这篇文章不讲虚的,直接带你用代码上手虚拟线程,并且对比它在真实并发场景下的表现。
一、虚拟线程解决了什么问题
先回顾一下传统平台线程的工作方式。一个典型的 Web 服务器会为每个请求分配一个线程,这个线程在执行数据库查询或调用外部 API 时会被阻塞,操作系统会暂停它、保留它的栈、切换到另一个线程。如果同时有几千个这样的阻塞操作,操作系统就需要在几千个线程之间频繁切换,CPU 大量时间耗费在上下文切换和内核调度上,真正干活的时间反而减少了。
异步编程(比如使用 CompletableFuture 或响应式框架)可以缓解这个问题,但它牺牲了代码的可读性:原本一个方法从上到下写完的逻辑,被迫拆成若干个回调,调试和排查异常变得痛苦。
虚拟线程的思路截然不同。它仍然让你使用熟悉的同步、阻塞式代码风格,但阻塞一个虚拟线程时,底层只挂起一个极轻量级的对象,操作系统的真实线程(载体线程)立即去执行另一个虚拟线程。当阻塞操作完成,虚拟线程被恢复,继续往下执行。整个过程不需要操作系统的线程调度参与,完全由 JVM 在用户态完成。结果是:你可以安全地创建数十万甚至上百万个虚拟线程,而不用担心内存耗尽或调度风暴。
二、创建并运行第一个虚拟线程
确保你的环境是 Java 21 或更高版本。虚拟线程的 API 主要在java.lang.Thread类和java.util.concurrent.Executors类中。
最简单的创建方式跟普通线程几乎一样,只是多了一个工厂方法:
// 方式1:直接启动一个虚拟线程
Thread.startVirtualThread(() -> {
System.out.println("Hello from a virtual thread: " + Thread.currentThread());
});
// 让主线程等待一会儿,因为虚拟线程是守护线程
Thread.sleep(1000);
运行后你会看到类似这样的输出,注意线程名称里带有“VirtualThread”:
Hello from a virtual thread: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
另一种更常用的方式是通过Thread.ofVirtual() builder:
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.unstarted(() -> {
// 你的任务
});
vThread.start();
vThread.join();
还可以用虚拟线程专用的执行器:
import java.util.concurrent.Executors;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
System.out.println("Task running in virtual thread");
});
}
newVirtualThreadPerTaskExecutor()返回一个为每一个任务都创建全新虚拟线程的执行器,用完记得关闭。这种执行器特别适合批处理任务,你不需要操心池子里有多少线程,提交十万个任务就会创建十万个虚拟线程,JVM 内部会用少量载体线程来承载它们。
三、实战对比:模拟高并发HTTP请求处理
光说理论没说服力,我们写一个小型模拟程序:假设一个服务需要处理大量并发请求,每个请求内部要调用一个“外部服务”(模拟网络 I/O),阻塞等待 100 毫秒。我们来比较传统固定线程池和虚拟线程的吞吐量差异。
传统平台线程池版本
import java.util.concurrent.*;
public class TraditionalServer {
public static void main(String[] args) throws Exception {
int requestCount = 1000;
ExecutorService pool = Executors.newFixedThreadPool(200);
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(requestCount);
for (int i = 0; i {
try {
// 模拟等待外部服务响应 100ms
simulateIoBlock();
} finally {
latch.countDown();
}
});
}
latch.await();
long time = System.currentTimeMillis() - start;
System.out.println("平台线程池处理 " + requestCount + " 个请求耗时: " + time + " ms");
pool.shutdown();
}
static void simulateIoBlock() {
try { Thread.sleep(100); } catch (InterruptedException e) { }
}
}
典型输出:平台线程池处理 1000 个请求耗时: 518 ms (结果因硬件而异)
线程池只有 200 个线程,1000 个请求需要排队执行,每一组 200 个请求并行,耗时大约等于 5 个 100ms 周期,加上调度开销,500 多毫秒符合预期。
虚拟线程版本
import java.util.concurrent.*;
public class VirtualServer {
public static void main(String[] args) throws Exception {
int requestCount = 1000;
long start = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(requestCount);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i {
try {
simulateIoBlock();
} finally {
latch.countDown();
}
});
}
}
latch.await();
long time = System.currentTimeMillis() - start;
System.out.println("虚拟线程处理 " + requestCount + " 个请求耗时: " + time + " ms");
}
static void simulateIoBlock() {
try { Thread.sleep(100); } catch (InterruptedException e) { }
}
}
运行输出会让人眼前一亮:虚拟线程处理 1000 个请求耗时: 127 ms
1000 个并发虚拟线程几乎同时启动,每个阻塞 100 毫秒后几乎同时恢复,总耗时只略大于 100 毫秒。这就是虚拟线程的威力:在 I/O 密集型场景中,吞吐量不再受限于操作系统能支撑的线程数量,而是受限于 I/O 本身的延迟和 CPU 处理能力。
你可以把requestCount改成 10000 甚至 50000,虚拟线程版本依然能在 100 多毫秒内完成,而平台线程池版本要么 OOM,要么耗时长得无法接受。
四、虚拟线程在真实场景中的用法
上面的例子毕竟只是模拟,接入真实业务我们可以用 Spring Boot 3.2+ (内置了虚拟线程支持)来体验。如果你还在用 Servlet 容器,Tomcat 10.1+ 和 Jetty 12 都支持将请求处理交给虚拟线程执行。
Spring Boot 启用虚拟线程
在application.properties中加入一行即可:
spring.threads.virtual.enabled=true
这样,Spring Boot 的 Tomcat 请求处理线程、定时任务线程,甚至@Async注解的方法都会运行在虚拟线程上。你的控制器代码不用做任何改动,依然是熟悉的同步风格:
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable String id) {
// 这里可以放心使用阻塞的数据库查询、HTTP调用
var order = orderRepository.findById(id); // 假设这是一个阻塞调用
var delivery = restTemplate.getForObject("http://delivery/status/" + id, String.class);
return order.withDeliveryStatus(delivery);
}
}
当大量并发请求进来时,每个请求都会在独立的虚拟线程中处理,即使orderRepository和restTemplate是阻塞调用,底层的载体线程也不会被白白占用,整体吞吐量会显著提升。
数据库连接池与虚拟线程的注意事项
使用虚拟线程时,数据库连接池通常会成为一个新的瓶颈。因为每个虚拟线程都需要从池里获取连接,如果池大小只有 20,那么同时只有 20 个虚拟线程能真正执行数据库操作,其余都在等连接。这时需要适当调大连接池大小,或者考虑使用支持异步的非阻塞数据库驱动(如 R2DBC),但 R2DBC 本身就已经是异步编程了,与你使用虚拟线程简化代码的初衷有些相悖。一种折中方案是为不同的外部资源合理分配连接池上限,让虚拟线程的主要威力发挥在外部 HTTP 调用、消息队列交互等慢 I/O 上。
五、虚拟线程的调度与可观测性
虚拟线程的调度由 JVM 内部的 ForkJoinPool 负责,默认使用一个公共的载体线程池。你可以通过系统属性调整载体线程的数量,但通常不需要,因为调度逻辑已经做了大量优化。
如果你需要对虚拟线程进行监控,JDK 自带的 jcmd 和 Java Flight Recorder 都已支持虚拟线程事件。使用 JFR 录制时勾选 jdk.VirtualThreadStart 和 jdk.VirtualThreadEnd 等事件,就能看到每个虚拟线程的创建、挂起和恢复时间,对于排查性能瓶颈非常有用。
简单演示一下代码中查看当前虚拟线程数量:
ThreadMXBean mxBean = ManagementFactory.getThreadMXBean();
// 开启线程统计(默认可能关闭)
if (mxBean instanceof com.sun.management.ThreadMXBean sunMxBean) {
sunMxBean.setThreadAllocatedMemoryEnabled(true);
sunMxBean.setThreadCpuTimeEnabled(true);
}
// 遍历所有线程,通过线程名称判断虚拟线程
for (long id : mxBean.getAllThreadIds()) {
ThreadInfo info = mxBean.getThreadInfo(id);
if (info != null && info.getThreadName().startsWith("VirtualThread")) {
System.out.println(info.getThreadName() + " state: " + info.getThreadState());
}
}
这种工具性的检查在开发调试阶段很有帮助,可以及时发现是否有虚拟线程泄漏(比如忘记 join 或关闭执行器)。
六、当虚拟线程不适用时
虚拟线程虽然强大,但并不是所有场景的万能药。当你的任务主要是 CPU 密集型计算(如图像处理、加密解密),且极少阻塞时,使用虚拟线程不会有任何好处,反而可能因为频繁切换载体线程带来额外开销。此时普通平台线程或 ForkJoinPool 的并行流更合适。
另外,如果你在虚拟线程内使用synchronized块或 JNI 调用长时间持有监视器锁,可能会导致载体线程被固定(pinning),暂时降低调度的灵活性。Java 团队正在逐步优化这种情况,但在完全消除之前,极度频繁且长时间持有的锁应尽量替换为java.util.concurrent.locks.Lock。
七、迁移策略小结
对于现有的 Java 项目,引入虚拟线程不必一蹴而就。你可以遵循下面几步渐进式地享受它带来的好处:
- 将 JDK 升级到 21 或更高版本。
- 在需要高并发 I/O 的模块中,用
Executors.newVirtualThreadPerTaskExecutor()替换原先的newFixedThreadPool(n),并调整连接池等资源上限。 - Web 应用如果使用 Spring Boot 3.2+,直接在配置中开启虚拟线程支持。
- 监控 GC 和内存使用情况,因为大量虚拟线程对象会占用堆内存,如果并发量极高,可能需要按需调整堆大小。
- 逐步从异步编码风格回归到同步阻塞代码,只要底层 I/O 操作能被虚拟线程有效调度,代码的可读性将得到巨大提升。
虚拟线程标志着 Java 并发编程从“线程池精细化控制”进入“直接基于业务逻辑建模”的新阶段。它让你不再为“应该开多少个线程”而纠结,把注意力放回业务本身。这是一个值得立即投入实战的特性,尤其是在微服务调用链、网关代理、消息消费等典型的 I/O 密集型系统中。

