1. 经典线程:如何工作,痛点在哪里
先回顾一下 Java 中普通线程(也称平台线程或 native 线程)是如何工作的。它们就是通过 new Thread(...) 创建的那些线程。
当你调用 new Thread(() -> { ... }).start(); 时,JVM 不只是启动一段代码,它会请求操作系统创建一个真正的执行线程。操作系统为其分配独立的栈(通常数兆字节),并保留其他服务性资源。
这样的线程会在其任务执行期间一直存在,并在这段时间里占据操作系统的线程表位置。线程越多,分给栈的内存越多,操作系统负担也越重。因此,当同时运行的线程数很多时,应用可能开始“喘不过气”——系统在它们之间切换要耗费大量时间。
示例:经典线程
Thread thread = new Thread(() -> {
System.out.println("来自线程的问候!");
});
thread.start();
看起来很简单,对吧?但如果你创建的不是一两个,而是比如一万个这样的线程——程序很快就会吃力:要么内存耗尽,要么系统提示线程达到上限。这不是 Java 的错误,而是体系结构的自然结果:线程本身就是“沉重而昂贵”的。
为什么会这样?因为每个线程都要有自己的栈(通常 1–2 兆字节),外加一整套由操作系统管理的服务性结构。而且当你塞给操作系统成千上万个线程时,它也并不开心——会有一些限制,而且往往相当严格。
即使内存没用完,也会出现另一种问题——上下文切换。当线程太多时,系统会不停地在它们之间跳转,保存并恢复状态。这些操作会占用时间、吞噬性能,于是就很难从“大规模并行化”中获得实际收益。
“一线程一请求”的问题
在早期的服务器应用(例如 Tomcat 或 Jetty)中,经常使用“thread‑per‑request”模型:为每个传入请求分配一个独立线程。这样做很方便,但如果你有 10_000 个用户,就需要 10_000 个线程!服务器会变得吃力,争的不是处理速度而是内存。
结论:
经典线程适合少量并行任务,但很难扩展到成千上万甚至数十万级别。
2. 什么是虚拟线程(Virtual Threads)?
这就是本次讲座的主角——虚拟线程。它不是“又一种线程”,而是一种完全不同的架构思想。
虚拟线程不是由操作系统管理,而是由 JVM 自身管理。它们完全在 Java 内部实现,可以以极低成本创建海量实例(数万甚至数十万),不会导致内存“膨胀”或性能拖慢。
简要对比:
- Platform Thread(平台线程):与操作系统线程一一对应的普通线程。
- Virtual Thread(虚拟线程):由 JVM 管理的轻量线程,而非由操作系统管理。
它是如何实现的?
虚拟线程是“轻量级”的线程,它们不在操作系统中“存在”,而是存在于 JVM 内部。它们运行在一小组真实线程之上,这些真实线程称为 platform threads。可以把 JVM 想象成指挥家:手里有数量有限的乐手(真实线程),却能灵活地把不同乐段(虚拟线程)分配给他们演奏。
架构示意:
+-------------------+ +-------------------+
| Virtual Thread 1 |---\ | Platform Thread |
| Virtual Thread 2 |---->====> | (Carrier Thread) |
| Virtual Thread 3 |---/ +-------------------+
... (Operating system)
Carrier Thread 是一个普通的操作系统线程,JVM 会在其上执行多个虚拟线程。如果某个虚拟线程发生阻塞——例如等待磁盘或网络数据——JVM 会“冻结”它,释放该 carrier thread 去处理其他任务。
为什么这是一次革命?
因为现在你可以继续写习惯的、线性的代码——无需无休止的回调、CompletableFuture 和“地狱般”的 thenApply 链——同时还能把应用扩展到成千上万的并发操作。
虚拟线程只占用几十 KB(而普通线程通常是 MB 级),创建几乎是瞬时的。因此你可以成千上万地启动和销毁它们,而不必担心操作系统会被压垮。这让 Java 中的并行编程终于变得轻松而自然。
3. 虚拟线程的优势
可扩展性
有了虚拟线程,你可以“奢侈”地同时运行数万甚至数十万并行任务。比如,把每个网络请求放在一个独立线程中处理——也不用担心服务器会“爆掉”。
演示:100 000 个虚拟线程
for (int i = 0; i < 100_000; i++) {
Thread.ofVirtual().start(() -> {
// 这里可以是任意逻辑
try {
Thread.sleep(1000); // 模拟工作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
System.out.println("所有线程都已启动!");
这段代码在普通笔记本上也能从容运行!
试试用普通线程做同样的事——你要么看到 OutOfMemoryError,要么电脑直接“变砖”。
编程更简单
虚拟线程允许你继续写“阻塞式”的直线代码,而无需把它变成异步调用的“面条”。例如,你可以放心使用 Thread.sleep、InputStream.read、Socket.accept——JVM 会确保不把整个 carrier thread 一起阻塞。
提升可读性与可维护性
相比用回调和 CompletableFuture 搭建复杂流程,你可以写更线性、更易懂的代码。这会减少缺陷并降低维护成本.
无需重复造轮子
过去要并行处理成千上万个请求,通常要用异步框架、反应式库(Netty、Vert.x、Project Reactor),并遵循特定的编程风格。现在可以不依赖它们,也一样能获得良好的可扩展性。
4. 架构:虚拟线程在幕后如何工作
映射到 carrier threads
JVM 会创建一个小型真实线程池(carrier threads)——其规模通常与 CPU 核数相当。所有虚拟线程都“搭乘”这些 carrier threads,就像乘客搭乘巴士。
- 当某个虚拟线程阻塞(例如等待网络响应)时,JVM 会把它从 carrier thread 上“卸下”,放入队列。
- 一旦该线程可以继续运行,JVM 就把它重新“装载”到空闲的 carrier thread 上。
类比:
想象你只有 4 辆出租车(carrier threads),却要服务 10 000 位客户(virtual threads)。某位客户到达目的地下车后,出租车立刻去接下一位。车辆不空转,也不会被“压垮”。
调度与切换
JVM 会自行决定当前要执行哪个虚拟线程。如果某个线程在 I/O 上阻塞,它不会妨碍其他线程继续工作。
5. 虚拟线程的限制与注意事项
虚拟不等于万能
不适合长时间的计算任务: 如果你的任务长期占满 CPU(heavy CPU‑bound),虚拟线程不会带来性能提升。因为 carrier threads 仍旧受限于 CPU 核心数量。
某些锁效率不高: 老式同步机制(例如在对象上使用 synchronized,且底层是原生互斥量)可能会让 JVM 无法“冻结”虚拟线程。这种情况下,carrier thread 会与虚拟线程一起等待,从而降低可扩展性。
并非所有库都与虚拟线程友好: 如果某个库进行原生调用或使用特殊类型的锁,虚拟线程的行为可能与预期不一致。
示例:不适合使用 Virtual Threads 的场景
如果你的任务是在一个无限循环里做纯计算,虚拟线程并不会带来任何收益。最终仍然受限于 CPU 核心数。
Thread.ofVirtual().start(() -> {
while (true) {
// 无限计数
}
});
结果:
一个 carrier thread 会被这个虚拟线程占满,其他任务只能排队等待。
6. 对比:Platform Thread vs Virtual Thread
| 指标 | Platform Thread(普通) | Virtual Thread(虚拟) |
|---|---|---|
| 由谁管理 | 操作系统 | JVM |
| 每线程内存 | 兆字节 | 几十千字节 |
| 线程数量 | 通常 < 10_000 | 成千上万,乃至数十万 |
| 创建开销 | 高 | 低 |
| 可扩展性 | 受限 | 几乎不受限 |
| 适用于 | 长时间任务,CPU‑bound | 短任务,I/O‑bound |
| 线程切换 | OS | JVM |
| 兼容性 | 100% | 几乎总是,但有细节 |
7. 示例:引入 Virtual Threads 前后的服务器
之前(Platform Threads)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> handleClient(client)).start();
}
问题:
连接数到 5 000 左右时,服务器会开始“喘不过气”。
之后(Virtual Threads,Java 21+)
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
Thread.ofVirtual().start(() -> handleClient(client));
}
像魔法一样:
现在可以处理数以万计的连接——无需再考虑线程上限!
9. 迁移到虚拟线程的常见错误
错误 1:指望计算型任务加速。 虚拟线程不会加速完全占满 CPU 的任务。这类任务依然受限于核心数量。
错误 2:继续使用老式的阻塞同步。 如果你仍在使用老式锁(例如在对象上使用 synchronized,且可能被原生方式“锁住”),虚拟线程可能无法从 carrier thread 上卸载,从而失去优势。
错误 3:忽视三方库的行为差异。 有些第三方库尚未为虚拟线程做好准备(例如使用 JNI 或原生锁)。
错误 4:期待性能“魔法般”提升。 虚拟线程并非万能。它不会让所有事情都更快,它的价值在于让并行变得廉价且易用,尤其适用于 I/O‑bound 任务。
GO TO FULL VERSION