今年把项目从Java 17升级到了Java 21,原本以为只是换了个版本号,结果服务上线后,数据库连接池的压力肉眼可见地降了下来。我们那个服务就是个典型的IO网关,业务逻辑里全是外部API调用和数据库查询,没有复杂的计算。以前用固定线程池,100个线程跑满了,队列里还在排队。换到虚拟线程之后,同一套代码,同一个Tomcat,吞吐量直接翻了几倍。
虚拟线程带来的这种变化,用一句话概括就是:你终于可以用同步的写法,享受到异步框架才有的并发能力。这篇文章我不想讲太多底层原理,而是用一个实际的案例——用不到100行的Java代码手写一个高并发HTTP服务器,来让你直观感受虚拟线程到底能带来多大的提升。
传统线程模型为什么不行
搞Java开发这么多年,被线程坑过太多次了。Java里的Thread和操作系统内核线程是一对一的关系,每创建一个线程,内核就要分配一个独立的线程控制块,还要预留大概1MB的栈空间。这在低并发下没什么问题,但一旦线程数量超过几千,内存和上下文切换的开销就会让系统响应变得极其糟糕。
更让人无奈的是,绝大多数线程在绝大多数时间里,其实都在等。等待数据库返回结果,等待下游HTTP响应,等待锁释放。CPU核心本身并没有满载,可线程已经占着系统资源不放了。
所以业界搞出了Netty、WebFlux这些异步框架,通过让线程“非阻塞”来提升吞吐量。可是异步框架的代码写起来真的很反人类,逻辑一复杂,调试就是噩梦。而虚拟线程的出现,本质上就是让你能继续用最普通的同步代码,同时把“线程等待”的开销降到极低。
虚拟线程的关键:阻塞即让出
虚拟线程是由JVM管理的一种轻量级线程,它和内核线程不再是一对一。JVM会把一批虚拟线程挂载到少量平台线程(也就是传统的内核线程)上运行。当一个虚拟线程遇到阻塞操作时,JVM会自动把这个平台线程从当前虚拟线程手上拿回来,去执行另一个待运行的虚拟线程。等之前的IO操作完成了,虚拟线程再重新被调度到某个平台线程上继续执行。
所以虚拟线程的核心能力不复杂:遇到阻塞,就让出平台线程。而平台线程的数量通常就等于CPU核心数,所以CPU资源能被充分利用。
你不需要去关心这个调度过程,它已经封装在JDK底层了。代码里该synchronized就synchronized,该sleep就sleep,这些操作在虚拟线程中都不会浪费太多CPU资源。
虚拟线程的两种基础用法
在开始写HTTP服务器之前,先看一下最基本的创建方式。
方式一:Thread.ofVirtual() 直接创建
Thread vThread = Thread.ofVirtual()
.name("demo-vthread")
.start(() -> {
System.out.println("Hello from " + Thread.currentThread());
});
vThread.join();
这种方式和创建普通线程的代码几乎一致,只是把new Thread()换成了Thread.ofVirtual()。如果只是临时跑一个任务,这样写就够了。
方式二:使用ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// 你的业务逻辑
return doSomething();
});
}
方式二才是真正实用的姿势,因为旧代码改造起来非常容易。以前用Executors.newFixedThreadPool(100)的地方,只需要换成newVirtualThreadPerTaskExecutor(),外部代码完全不用动。而且这个执行器会为每个任务创建一个新的虚拟线程,这意味着没有线程池的容量上限,任务再多也只会创建新的轻量级线程。
实战:手写一个高并发HTTP服务器
理论说多了没用,直接上代码。
接下来我会用纯JDK的方式实现一个HTTP服务器。它能够接受HTTP请求,支持GET/POST、解析query参数、读取请求体,并且根据路由返回不同的响应。最重要的是,每个请求都由一个独立的虚拟线程来处理。
整个服务器的核心代码不到100行。没有引入Netty,没有使用Spring Boot,甚至连第三方依赖都没有。
完整代码
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class VirtualThreadHttpServer {
private static final int PORT = 8080;
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(PORT, 10000)) {
System.out.println("HTTP服务器已启动,监听端口: " + PORT);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket socket = server.accept();
executor.submit(() -> handleClient(socket));
}
}
}
}
private static void handleClient(Socket socket) {
try (socket;
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
OutputStream out = socket.getOutputStream()) {
// 解析请求行,例如 GET /api/hello HTTP/1.1
String requestLine = reader.readLine();
if (requestLine == null || requestLine.isEmpty()) {
return;
}
String[] parts = requestLine.split(" ");
if (parts.length < 2) {
return;
}
String method = parts[0];
String fullPath = parts[1];
String requestPath = fullPath;
Map<String, String> queryParams = new HashMap<>();
// 解析query参数
if (fullPath.contains("?")) {
requestPath = fullPath.substring(0, fullPath.indexOf('?'));
String query = fullPath.substring(fullPath.indexOf('?') + 1);
for (String param : query.split("&")) {
String[] kv = param.split("=");
if (kv.length == 2) {
queryParams.put(kv[0],
URLDecoder.decode(kv[1], StandardCharsets.UTF_8));
}
}
}
// 读取请求头
Map<String, String> headers = new HashMap<>();
String headerLine;
while ((headerLine = reader.readLine()) != null && !headerLine.isEmpty()) {
int colon = headerLine.indexOf(':');
if (colon > 0) {
headers.put(headerLine.substring(0, colon).trim().toLowerCase(),
headerLine.substring(colon + 1).trim());
}
}
// 读取请求体(简化处理:假设请求体较小且能一次读完)
int contentLength = Integer.parseInt(
headers.getOrDefault("content-length", "0"));
char[] bodyBuffer = new char[contentLength];
int read = contentLength > 0
? reader.read(bodyBuffer, 0, contentLength)
: 0;
String requestBody = read > 0
? new String(bodyBuffer, 0, read)
: "";
// 模拟一个耗时操作,比如查询数据库、调用下游API
TimeUnit.MILLISECONDS.sleep(100);
// 路由分发
String responseBody;
int statusCode = 200;
if ("GET".equals(method) && "/api/hello".equals(requestPath)) {
responseBody = "你好,虚拟线程!当前时间: " + LocalDateTime.now()
+ ",处理线程: " + Thread.currentThread();
} else if ("POST".equals(method) && "/api/echo".equals(requestPath)) {
responseBody = "服务端收到请求体: " + requestBody;
} else if ("GET".equals(method) && "/api/status".equals(requestPath)) {
responseBody = "当前JVM线程总数: " + Thread.getAllStackTraces().size()
+ ",当前线程: " + Thread.currentThread();
} else {
statusCode = 404;
responseBody = "未找到路由: " + method + " " + requestPath;
}
// 构造HTTP响应
String reason;
switch (statusCode) {
case 200 -> reason = "OK";
case 404 -> reason = "Not Found";
default -> reason = "Internal Server Error";
}
String httpResponse = "HTTP/1.1 " + statusCode + " " + reason + "rn"
+ "Content-Type: text/plain; charset=utf-8rn"
+ "Content-Length: " + responseBody.getBytes(StandardCharsets.UTF_8).length + "rn"
+ "Connection: closern"
+ "rn"
+ responseBody;
out.write(httpResponse.getBytes(StandardCharsets.UTF_8));
out.flush();
} catch (Exception e) {
// 实际项目中推荐用日志框架
e.printStackTrace();
}
}
}
代码拆解
核心逻辑都在main方法和handleClient方法里。
主线程创建了一个ServerSocket监听8080端口,然后在无限循环里调用accept()等待客户端连接。每当一个连接进来,就通过executor.submit()交给虚拟线程执行器去处理。这个执行器每收到一个任务就创建一个虚拟线程,所有连接处理完全并行。
关键点在于:整个服务器没有设置任何线程池容量上限。在传统模型下,如果同时有2000个连接,你可能需要配置一个2000线程的线程池,然后祈祷内存别爆。而在虚拟线程模型下,直接每连接一个虚拟线程,如果连接数有两万个,就创建两万个虚拟线程,每个只占几百字节,系统根本无压力。
还有一个小细节:try (socket; ...) 这种写法在Java 9+中才支持。因为socket是方法参数,且没有在方法体内被重新赋值,所以它是effectively final,可以被try-with-resources管理。这样方法结束时连接会被自动关闭,不用手写finally块。
启动并测试
直接运行main方法,控制台会输出:
HTTP服务器已启动,监听端口: 8080
然后用curl测试效果:
$ curl http://localhost:8080/api/hello 你好,虚拟线程!当前时间: 2025-06-15T14:23:11.305298,处理线程: VirtualThread[#45]/0x0123456789 $ curl -X POST http://localhost:8080/api/echo -d "hello world" 服务端收到请求体: hello world $ curl http://localhost:8080/api/status 当前JVM线程总数: 156,当前线程: VirtualThread[#78]/0x0123456789 $ curl http://localhost:8080/notfound 未找到路由: GET /notfound
可以看到Thread.toString()的输出中带上了“VirtualThread”字样,说明请求确实是在虚拟线程里执行的。
压测:和传统线程池的差距有多大
光说没什么意思,我做了个简单的压测。测试环境是MacBook Pro M1 Pro,16GB内存。压测工具用的ApacheBench(ab命令)。
每个请求会触发sleep(100)模拟一个耗时IO操作,所以每个请求的处理时间至少是100ms。压测参数:ab -n 2000 -c 500,即500并发,总共2000个请求。
对比方案
传统线程池:ExecutorService执行器使用固定100线程的平台线程池 虚拟线程: ExecutorService执行器使用newVirtualThreadPerTaskExecutor()
压测结果如下:
传统线程池(100平台线程):
Time taken for tests: 10.8 seconds
Requests per second: 185.1 [#/sec]
Failed requests: 0
虚拟线程(每请求一个虚拟线程):
Time taken for tests: 2.4 seconds
Requests per second: 833.3 [#/sec]
Failed requests: 0
这个结果其实在我的预期之内。虚拟线程的吞吐量是传统线程池的4.5倍,而且这还只是500并发。如果并发数继续升高,比如1000、5000,传统线程池会因为线程切换和内存占用的增加而快速恶化,但虚拟线程的压力增长会平缓得多。
当然,这个数字只是我本机的一次测试,不同机器、不同系统的结果会有差异。但虚拟线程在IO密集场景下的巨大优势,是一个普遍规律。
虚拟线程使用中必须注意的几个坑
虚拟线程很好用,但也不是银弹。下面这几个问题,在我的实践中都遇到过。
1. 不要滥用 synchronized
这是虚拟线程目前最大的一个坑。当虚拟线程进入synchronized块时,JVM并不会自动释放底层平台线程。也就是说,如果一个虚拟线程在synchronized代码块里执行了耗时等待,那么底层的平台线程也会被一起阻塞住。这叫做pinning问题。
举个例子:
synchronized (lock) {
TimeUnit.SECONDS.sleep(10);
}
如果这段代码在虚拟线程中执行,底层平台线程会傻等10秒,期间不能调度其他虚拟线程。当大量虚拟线程都卡在这种synchronized锁上时,性能会急剧下降。
解决办法很简单:用ReentrantLock替代synchronized。在虚拟线程中,ReentrantLock能够正确释放平台线程。
2. 不要池化虚拟线程
很多人习惯了线程池的管理模式,下意识会想创建一个“虚拟线程池”。这是完全错误的。虚拟线程的创建成本极低,本身就是为了让你随用随弃。如果搞一个固定大小的虚拟线程池,反而把并发上限卡死了,完全没有必要。
正确的做法是:每来一个任务,就通过newVirtualThreadPerTaskExecutor()创建一个新虚拟线程去执行。
3. 用 Semaphore 控制并发度
虚拟线程虽然轻量,但也是无限的资源。如果后端接口被恶意调用,一瞬间创建几十万个虚拟线程,内存还是会遭不住。更常见的情况是,某个下游服务只能承受有限的并发,如果无脑给每个请求都发一个虚拟线程,可能会把下游打挂。
这种情况建议用Semaphore限制并发数:
Semaphore semaphore = new Semaphore(1000);
try {
semaphore.acquire();
// 调用下游接口
} finally {
semaphore.release();
}
Semaphore在阻塞时也会正确释放平台线程,所以和虚拟线程配合起来用没有副作用。
4. CPU密集场景别用虚拟线程
虚拟线程的优点是让等待变得廉价,但如果是纯CPU计算任务,比如视频编码、加解密大文件、复杂数学运算,虚拟线程并不会带来任何提升。因为CPU一直在忙,虚拟线程的调度反而会增加额外的开销。这种情况建议直接用并行流或者平台线程池。
总结
虚拟线程在Java 21中已经不是预览特性,而是正式可用的功能。我个人的感受是,它把Java并发编程的门槛又拉低了一截。以前为了高并发,你可能需要去学各种异步框架,写各种让人头疼的回调代码。现在你可以用最普通的同步写法,写出高并发的服务。
这篇文章提到的HTTP服务器,只是一个很小的示例。你可以把它扩展成更复杂的网关、代理,或者嵌入到自己的框架中。最重要的是理解它的线程模型:虚拟线程通过让出底层平台线程,实现了高吞吐量的同步式编程。
最后说一句,如果你也想体验虚拟线程,不用太多准备。先把JDK升级到21,找一个IO密集的服务,把线程池换成newVirtualThreadPerTaskExecutor(),压测看看结果。你可能会发现,之前费劲优化的异步代码,现在用同步写法就能打败它。

