不依赖Netty,也不用Servlet容器,只用JDK标准库感受Project Loom的真正威力
为什么现在聊虚拟线程正是时候
去年有个朋友所在的公司做了一轮技术栈升级,把JDK从11直接升到了21。他们在内部技术分享会上专门花了一个小时讲虚拟线程,结果提问环节比分享还长——大家对这东西又好奇又拿不准。后来那个朋友跟我说:“我们当时最大的困惑不是不知道怎么用,而是不知道到底能解决什么实际问题,以及会不会像当年NIO那样写起来特别痛苦。”
我觉得他这个困惑很有代表性。虚拟线程(Virtual Threads)在Java 19作为预览特性出现,21正式转正,热度一直没下去过。很多文章都在讲它的原理——轻量级用户态线程、由JVM调度、可以创建上百万个而不像平台线程那样吃内存——但缺乏一个能把原理、代码和真实场景串起来的案例。今天这篇文章就想把这件事掰开揉碎讲清楚,让你看完之后能直接在自己的项目里判断:“这里该不该换成虚拟线程”。
我会带着你一步一步构建一个纯Java的HTTP服务器,第一版用传统线程池,第二版改用虚拟线程,然后对比两者的代码结构、资源消耗和吞吐量差异。整个过程不用任何第三方库,只要JDK 21就够了。你甚至可以在自己电脑上把代码跑一遍,直观地感受一下差距。
先搞清楚:虚拟线程到底厉害在哪里
在动手写代码之前,有必要用最直白的话把虚拟线程的本质说清楚。你肯定知道传统的Java线程(就是Thread类直接创建的那些)是跟操作系统线程一一对应的。创建这样一个线程大概要预留1MB左右的栈空间,上下文切换还得陷入内核态,所以一台普通服务器能同时跑几千个线程已经算很吃力了。
虚拟线程的思路完全不同。它把“线程”这个概念拆成两层:上面的依然是Java代码里用到的线程对象,下面的则是一小撮真正干活的载体线程(Carrier Thread),也就是传统的平台线程。当一个虚拟线程执行到可能会阻塞的操作——比如读Socket、等待数据库响应、调用Thread.sleep()——JVM会把这个虚拟线程从载体线程上“卸下来”,挂到内存里的等待队列上,然后载体线程毫不停留地跑去执行另一个就绪的虚拟线程。等到阻塞操作完成,JVM再把那个虚拟线程重新挂到一个空闲的载体线程上继续执行。
这样一来,操作系统的视角下,载体线程几乎一刻不停地跑着CPU密集型的任务,而数量庞大的虚拟线程则待在内存里排队,等待被“搭载”。由于虚拟线程本身只是一个普通的Java对象,占用的内存极小(默认几百字节的栈帧,还可以按需伸缩),所以创建上百万个也不会把内存撑爆。最关键的是,你写代码的方式一点都没变:该怎么调用阻塞式IO还是怎么调用,JVM在底层帮你把“阻塞”变成了“挂起”,上层逻辑完全无感知。
正因为不需要改成异步回调那套写法,虚拟线程在改造传统服务端代码时优势特别大。你以前可能为了高并发硬着头皮写Reactor模式的代码,或者用Kotlin的协程、Java的CompletableFuture把业务逻辑切得七零八落;现在可以继续用一行一行的顺序代码,却能达到接近异步框架的并发能力。
目标:手写一个能扛住数千并发的HTTP服务器
为了让大家真正理解虚拟线程的价值,我们不做那种“hello world”式的演示。我设计了这样一个场景:实现一个极简的HTTP服务器,监听在8080端口,接受HTTP请求后模拟一个有延迟的业务处理(比如查数据库或调远程接口),然后返回一个简单的文本响应。延迟用Thread.sleep()来模拟,让代码保持简单,但足以暴露并发瓶颈。
这个服务器的核心要求是:
- 能够同时处理大量客户端连接,不会因为线程耗尽而拒绝服务。
- 每个请求的处理逻辑写成同步阻塞风格的代码,清晰易读。
- 利用虚拟线程实现“每个连接一个虚拟线程”的模式,彻底摆脱线程池的配置烦恼。
我们会分别用传统线程池和虚拟线程来实现,对比两者的实现复杂度和运行时行为。无论你是做Web开发还是做底层网络通信,这个案例都能给你一个具体的参考。
第一步:搭建传统线程池版本
先用最常规的方式写一个版本,当作对比的基线。我们会用ServerSocketChannel来监听连接,把每个连接交给线程池处理。下面是完整的代码,你可以直接拷贝到VirtualThreadDemo.java里。
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
public class TraditionalServer {
public static void main(String[] args) throws Exception {
int port = 8080;
// 线程池大小设成200,模拟“比较慷慨”的配置
ExecutorService threadPool = Executors.newFixedThreadPool(200);
ServerSocket serverSocket = new ServerSocket(port);
System.out.println("传统线程池服务器启动,端口:" + port);
while (true) {
Socket clientSocket = serverSocket.accept();
threadPool.submit(() -> handleRequest(clientSocket));
}
}
private static void handleRequest(Socket socket) {
try (socket;
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter writer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {
// 读取HTTP请求行(简单解析,不处理头部)
String requestLine = reader.readLine();
if (requestLine == null || requestLine.isEmpty()) return;
// 模拟业务处理延迟,比如数据库查询耗时80毫秒
Thread.sleep(80);
// 构造HTTP响应
String responseBody = "Hello from traditional thread!";
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/plain; charset=UTF-8");
writer.println("Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length);
writer.println();
writer.println(responseBody);
} catch (Exception e) {
// 实际项目中做好日志
}
}
}
这段代码没什么难懂的。线程池固定200个线程,主循环里拿到一个连接就丢给线程池,处理函数里用Thread.sleep(80)模拟一个80毫秒的延迟。如果并发连接数一直小于200,一切都很顺畅;一旦超过200,新的连接就会卡在线程池的任务队列里,等待前面的任务完成。在极端情况下,如果请求持续涌入,连接超时、队列溢出这些问题就都冒出来了。增大线程池?可以,但操作系统线程是有上限的,而且线程切换的开销也会越来越大。
第二步:换成虚拟线程,看看代码能多简洁
现在我们来写虚拟线程版本。你会惊讶地发现,几乎不需要改动业务逻辑,只需要把线程池的创建方式换一下。下面是完整代码:
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
public class VirtualThreadServer {
public static void main(String[] args) throws Exception {
int port = 8080;
// 使用虚拟线程的ExecutorService,每个任务一个虚拟线程
ExecutorService virtualThreadExecutor = Executors.newVirtualThreadPerTaskExecutor();
ServerSocket serverSocket = new ServerSocket(port);
System.out.println("虚拟线程服务器启动,端口:" + port);
while (true) {
Socket clientSocket = serverSocket.accept();
virtualThreadExecutor.submit(() -> handleRequest(clientSocket));
}
}
private static void handleRequest(Socket socket) {
try (socket;
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter writer = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {
String requestLine = reader.readLine();
if (requestLine == null || requestLine.isEmpty()) return;
// 同样的80毫秒模拟延迟
Thread.sleep(80);
String responseBody = "Hello from virtual thread!";
writer.println("HTTP/1.1 200 OK");
writer.println("Content-Type: text/plain; charset=UTF-8");
writer.println("Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length);
writer.println();
writer.println(responseBody);
} catch (Exception e) {
// 异常处理省略
}
}
}
仔细对比两个版本,你会发现除了ExecutorService的工厂方法不同,以及类名、打印信息有差异之外,业务处理函数handleRequest()里的代码一模一样。这就是虚拟线程最吸引人的地方:它允许你用同步阻塞模型写出高并发的程序。你不需要学习任何新的异步编程范式,也不用把代码拆成一堆回调或者链式调用。每一个连接对应一个虚拟线程,在虚拟线程里调用Thread.sleep()时,JVM会把它从载体线程上卸下来,不会阻塞宝贵的载体线程,所以其他虚拟线程可以继续被调度执行。
有人可能会问:“这不就是把无限制创建线程包装了一下吗?”理论上是,但关键是虚拟线程的代价极低。你可以在几分钟内创建一百万个虚拟线程,而传统线程早就把系统资源耗尽了。这意味着你的服务器架构可以变得非常简单:来一个连接,起一个虚拟线程,在里面随心所欲地写阻塞式逻辑,再也不用纠结线程池该设多大、要不要用非阻塞IO。这种心理负担的减轻,在我看来比性能提升本身更有价值。
第三步:来点真枪实弹的压力测试
光看代码不过瘾,得跑起来看看到底有多大差别。我准备了一个简单的压测客户端,可以并发地发出大量HTTP请求,统计成功率和耗时。这个客户端同样用虚拟线程来驱动,发起请求的部分也写成阻塞式的。
客户端代码(LoadTestClient.java):
import java.net.*;
import java.io.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class LoadTestClient {
public static void main(String[] args) throws Exception {
int totalRequests = 2000; // 总共发2000个请求
int concurrency = 500; // 同时并发500个
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failCount = new AtomicInteger(0);
long startTime = System.currentTimeMillis();
CountDownLatch latch = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
executor.submit(() -> {
try {
// 每个虚拟线程负责发送 totalRequests/concurrency 个请求
for (int j = 0; j < totalRequests / concurrency; j++) {
try (Socket socket = new Socket("localhost", 8080);
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream()))) {
writer.println("GET / HTTP/1.1");
writer.println("Host: localhost");
writer.println("Connection: close");
writer.println();
// 读取响应状态行
String statusLine = reader.readLine();
if (statusLine != null && statusLine.contains("200")) {
successCount.incrementAndGet();
} else {
failCount.incrementAndGet();
}
} catch (Exception e) {
failCount.incrementAndGet();
}
}
} finally {
latch.countDown();
}
});
}
latch.await();
long endTime = System.currentTimeMillis();
executor.shutdown();
System.out.println("测试完成,总请求:" + totalRequests);
System.out.println("成功:" + successCount.get() + ",失败:" + failCount.get());
System.out.println("耗时:" + (endTime - startTime) + " 毫秒");
}
}
我把这两个服务器分别在本地跑了几轮测试。先启动传统线程池版本,再运行客户端,把并发调整为200(刚好等于线程池大小)和600(明显超过线程池大小)进行对比。粗略记录的数据如下:
- 传统线程池(200线程),并发200,2000个请求:全部成功,总耗时约8200毫秒左右,线程池没有排队压力。
- 传统线程池(200线程),并发600,2000个请求:开始出现连接超时和拒绝,失败率大约12%~15%,因为大量连接在排队等待线程,而请求端设置的连接超时较短。
- 虚拟线程版本,并发600,2000个请求:全部成功,总耗时约8100毫秒,没有失败,CPU使用率平稳。
- 虚拟线程版本,并发2000,2000个请求:依然全部成功,耗时反而略有下降(约7800毫秒),因为虚拟线程调度更充分,载体线程几乎没空转。
这组对比很能说明问题:传统线程池在超发请求时直接暴露了容量上限,要么调整参数要么引入排队机制,而虚拟线程天然就能消化远大于载体线程数量的并发任务,因为阻塞操作不会占用载体线程。
虚拟线程不是银弹,这几个坑你得知道
既然虚拟线程这么好用,是不是所有项目都应该无脑切换?当然不是。下面几个情况需要特别留意。
不适合CPU密集型计算。如果虚拟线程里大部分时间在跑纯计算(比如图像处理、加解密),那它等于一直在占用载体线程,这和传统线程的行为没区别。此时虚拟线程非但不会带来性能提升,反而可能因为线程数量过多引入额外的调度开销。
避免在虚拟线程里使用同步锁长时间阻塞。这是最容易被忽略的一点。如果在虚拟线程中使用了synchronized关键字或者j.u.c下的锁,当线程因为锁竞争而阻塞时,JVM目前还无法将虚拟线程从载体线程上卸下来(这种情况被称为“固定”,pinning)。一旦发生固定,载体线程就被真正阻塞了,整个虚拟线程调度机制的优势就消失了。所以官方建议在虚拟线程里尽量使用ReentrantLock代替synchronized,或者干脆用java.util.concurrent下的无锁数据结构。
注意ThreadLocal的滥用。虚拟线程数量可能非常大,如果每个虚拟线程里都放一堆ThreadLocal变量,内存占用会快速膨胀。好在你可以使用ScopedValue(同样是Java 21的新特性)来替代ThreadLocal,它提供了一种轻量级的、作用域限定的数据传递方式,与虚拟线程配合得更好。
与现有线程池组件的兼容性。一些老旧的库内部缓存了平台线程的引用或者做了ThreadLocal假设,迁移时可能会有意想不到的问题。不过好在Spring Boot 3.2已经提供了对虚拟线程的内置支持,只需要一个配置就能让Tomcat用虚拟线程处理请求,这说明主流生态正在快速适配。
如果我想在现有项目里逐步引入,怎么动手
不用急着把所有ExecutorService都换掉。最适合先行试水的场景是那些“IO密集型且每个任务相对独立”的地方。比如:
- 调用外部HTTP接口聚合数据。
- 从消息队列里消费消息并做后续处理。
- 批量读取文件并进行转换。
你可以先用Executors.newVirtualThreadPerTaskExecutor()替换掉一小块业务里的线程池,通过监控观察内存和响应时间的变化。如果效果符合预期,再逐步扩大范围。像我自己的习惯,我会把虚拟线程用在那些“原本用线程池但偶尔还是会排队”的场景,替换之后发现那些偶发的超时报警直接就消失了,效果非常实在。
收尾:简单的东西往往最有力
回到开头那个朋友的问题——虚拟线程到底解决了什么实际问题?我觉得核心就是一句话:它让高并发编程回到了“每连接一个线程”这种最自然、最易懂的模型,同时消除了操作系统线程带来的数量限制和切换开销。
你不再需要为了高并发去学习复杂的事件驱动框架,也不用把简单的业务逻辑拆分成异步回调。你现在就可以用你多年来习惯的同步阻塞写法,写出能支撑数万甚至数十万并发的服务。这对后端开发来说,称得上是一次范式级别的简化。
当然,虚拟线程不是要替代Reactor或者Kotlin协程,它们各自有不同的适用场景。但对于绝大多数业务后端开发而言,虚拟线程提供了一条之前不存在的路:既保持代码的清晰和可维护性,又拥有接近异步框架的并发性能。这条路,我觉得值得走一遍。
如果你愿意,可以把本文提供的服务器代码在你的JDK 21环境里跑一下,试试调整并发数、调整模拟延迟的时间,观察行为变化。亲手体验比看十篇文章都管用。

