Riccardo Cardin 2023-03-08
Java 19 版本于 2022 年底发布,带来了许多令人兴奋的新特性。其中最引人注目的是 Project Loom(织梦项目)中两项热门特性的预览:虚拟线程(JEP 425)和结构化并发(JEP 428)。尽管这两项功能目前仍处于预览阶段(实际上,结构化并发还处于孵化器模块中),但它们有望将已在 Kotlin(协程)、Scala(Cats Effect 和 ZIO fibers)等语言中成熟的现代并发范式引入 JVM 的主流语言——Java。
话不多说,我们先来介绍虚拟线程。正如前文所述,这两个特性仍在演进中,最终版本的功能可能会与本文所展示的内容有所不同。后续文章将聚焦于结构化并发以及其他 Project Loom 的酷炫特性。
环境配置
由于这两个 JEP 仍处于预览/孵化阶段,我们需要在项目中显式启用它们。文章末尾会提供完整的 Maven 配置示例,此处仅展示关键部分。
首先,需使用 Java 19 或更高版本。其次,必须为 JVM 添加 --enable-preview 标志。虽然本文不讨论结构化并发,但我们仍需配置环境以访问相关模块,因此还需启用并导入 jdk.incubator.concurrent 模块。
在 src/main/java 目录下创建一个名为 module-info.java 的文件,内容如下:
module virtual.threads.playground {
requires jdk.incubator.concurrent;
}
模块名称可以任意指定(本文使用 virtual.threads.playground),关键是通过 requires 指令启用孵化器模块。
我们将使用 SLF4J 在控制台输出日志。所有代码片段均使用以下 logger:
static final Logger logger = LoggerFactory.getLogger(App.class);
但为了便于观察虚拟线程的行为,我们封装了一个自定义的 log 方法:
static void log(String message) {
logger.info("{} | " + message, Thread.currentThread());
}
该方法会打印当前线程信息,对理解虚拟线程运行状态非常有帮助。
此外,我们还使用 Lombok 减少处理受检异常(checked exceptions)时的样板代码。例如,用 @SneakyThrows 注解将受检异常当作非受检异常处理(注意:生产环境慎用!):
@SneakyThrows
private static void sleep(Duration duration) {
Thread.sleep(duration);
}
由于项目启用了 Java 模块系统,还需声明依赖模块。因此,上述 module-info.java 文件应更新为:
module virtual.threads.playground {
requires jdk.incubator.concurrent;
requires org.slf4j;
requires static lombok;
}
为何需要虚拟线程?
熟悉我们的读者可能记得,我们在《Kotlin 101:协程快速入门》一文中提出过类似问题。这里简要回顾一下虚拟线程试图解决的核心痛点。
JVM 是一个多线程环境。众所周知,JVM 通过 java.lang.Thread 类型对操作系统线程进行了抽象。在 Project Loom 之前,JVM 中的每个线程本质上只是对 OS 线程的一层轻量级封装,这种实现被称为 平台线程(platform thread)。
平台线程存在诸多成本问题:
- 创建开销大:每次创建平台线程时,操作系统需为其分配大量内存(通常为 MB 级别)作为栈空间,用于存储线程上下文、本地调用栈和 Java 调用栈。
- 栈不可动态调整:由于栈大小固定,必须预先分配足够大的空间以应对最坏情况。
- 调度代价高:当调度器抢占线程执行权时,需移动大量内存数据,既耗时又耗空间。
这些限制导致可创建的线程数量极为有限。以下代码会迅速触发 OutOfMemoryError:
private static void stackOverFlowErrorExample() {
for (int i = 0; i < 100_000; i++) {
new Thread(() -> {
try {
Thread.sleep(Duration.ofSeconds(1L));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
}
}
运行结果(取决于操作系统和硬件):
[0.949s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN)...
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread...
这揭示了传统 Java 并发编程的根本约束。
Java 自诞生起就追求简洁性。理想情况下,并发程序应像顺序程序一样编写。最直观的并发模型是 “一个任务对应一个线程”(one task per thread):每个线程使用自己的局部变量存储状态,极大减少了共享可变状态带来的复杂性(即并发编程中最棘手的部分)。然而,该模型受限于平台线程的资源瓶颈。
近年来,业界提出了多种解决方案:
- 回调地狱(Callback Hell):通过嵌套回调处理异步操作,但代码难以阅读和维护。
- 响应式编程(Reactive Programming):通过 DSL 声明数据流,由框架管理并发。但 DSL 学习曲线陡峭,牺牲了 Java 的简洁性。
- async/await 模型(如 Kotlin 协程):虽模拟了“一任务一线程”模型,但依赖非阻塞 I/O(如 Netty),无法覆盖所有场景,且需将程序拆分为阻塞/非阻塞两部分,增加了复杂性。
正因如此,JVM 社区亟需一种更优的并发编程方案。Project Loom 应运而生,其核心组件之一便是虚拟线程。
如何创建虚拟线程?
虚拟线程是一种新型线程,旨在解决平台线程的资源限制问题。它是 java.lang.Thread 的另一种实现,其栈帧存储在堆内存(垃圾回收区域)而非固定栈中。
因此,虚拟线程的初始内存占用极小(仅几百字节),且栈空间可动态伸缩,无需为所有场景预分配大量内存。
创建虚拟线程非常简单,可通过 Thread.ofVirtual() 工厂方法:
private static Thread virtualThread(String name, Runnable runnable) {
return Thread.ofVirtual()
.name(name)
.start(runnable);
}
以早晨例行事务为例:
- 洗澡:
static Thread bathTime() { return virtualThread("Bath time", () -> { log("I'm going to take a bath"); sleep(Duration.ofMillis(500L)); log("I'm done with the bath"); }); } - 烧水泡茶:
static Thread boilingWater() { return virtualThread("Boil some water", () -> { log("I'm going to boil some water"); sleep(Duration.ofSeconds(1L)); log("I'm done with the water"); }); }
通过并行执行加速流程:
@SneakyThrows
static void concurrentMorningRoutine() {
var bathTime = bathTime();
var boilingWater = boilingWater();
bathTime.join();
boilingWater.join();
}
运行输出:
08:34:46.217 [boilWater] INFO ... VirtualThread[#21,boilWater]/runnable@ForkJoinPool-1-worker-1 | I'm going to take a bath
08:34:46.218 [boilWater] INFO ... VirtualThread[#23,boilWater]/runnable@ForkJoinPool-1-worker-2 | I'm going to boil some water
08:34:46.732 [bath-time] INFO ... VirtualThread[#21,boilWater]/runnable@ForkJoinPool-1-worker-2 | I'm done with the bath
08:34:47.231 [boilWater] INFO ... VirtualThread[#23,boilWater]/runnable@ForkJoinPool-1-worker-2 | I'm done with the water
使用 ExecutorService
也可通过专为虚拟线程设计的 ThreadPerTaskExecutor:
@SneakyThrows
static void concurrentMorningRoutineUsingExecutors() {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var bathTime = executor.submit(() -> {
log("I'm going to take a bath");
sleep(Duration.ofMillis(500L));
log("I'm done with the bath");
});
var boilingWater = executor.submit(() -> {
log("I'm going to boil some water");
sleep(Duration.ofSeconds(1L));
log("I'm done with the water");
});
bathTime.get();
boilingWater.get();
}
}
若需命名线程以便调试,可自定义 ThreadFactory:
final ThreadFactory factory = Thread.ofVirtual().name("routine-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
// 提交任务...
}
虚拟线程的工作原理
JVM 维护一个平台线程池(由专用 ForkJoinPool 管理),默认大小等于 CPU 核心数(上限 256)。每个虚拟线程被调度到平台线程上执行时,其栈帧会从堆复制到平台线程的栈中。此时,平台线程成为虚拟线程的 载体线程(carrier thread)。
日志中的关键信息解析:
VirtualThread[#23,routine-1]/runnable@ForkJoinPool-1-worker-2
VirtualThread[#23,routine-1]:虚拟线程 ID 和名称ForkJoinPool-1-worker-2:载体平台线程
当虚拟线程遇到阻塞操作(如 sleep())时:
- 载体线程被释放,虚拟线程栈帧移回堆内存。
- 载体线程可执行其他就绪的虚拟线程。
- 阻塞结束后,虚拟线程被重新调度(可能在不同载体线程上继续执行)。
载体线程池规模验证
以下代码创建 CPU 核心数 + 1 个虚拟线程:
static void viewCarrierThreadPoolSize() {
final ThreadFactory factory = Thread.ofVirtual().name("routine-", 0).factory();
try (var executor = Executors.newThreadPerTaskExecutor(factory)) {
IntStream.range(0, numberOfCores() + 1)
.forEach(i -> executor.submit(() -> {
log("Hello, I'm virtual thread " + i);
sleep(Duration.ofSeconds(1L));
}));
}
}
输出显示 4 个载体线程(假设 4 核 CPU),其中 worker-4 被复用两次,验证了载体线程的共享机制。
调度器与协作式调度
虚拟线程采用 协作式调度(cooperative scheduling):
- 虚拟线程在阻塞操作(如 I/O、
sleep())时主动让出载体线程。 - 若线程陷入无限循环且无阻塞点,则会独占载体线程,导致其他虚拟线程无法执行。
实验验证
场景 1:单载体线程 + 无限循环(无阻塞)
static Thread workingHard() {
return virtualThread("Working hard", () -> {
log("I'm working hard");
while (alwaysTrue()) { /* 无阻塞操作 */ }
sleep(Duration.ofMillis(100L)); // 永远不会执行到此处
});
}
启动参数:-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1
结果:takeABreak 线程永远无法执行。
场景 2:循环中加入阻塞操作
static Thread workingConsciousness() {
return virtualThread("Working consciousness", () -> {
log("I'm working hard");
while (alwaysTrue()) {
sleep(Duration.ofMillis(100L)); // 定期让出载体线程
}
});
}
结果:两个虚拟线程交替执行,共享同一载体线程。
结论:虚拟线程适用于 I/O 密集型任务,对 CPU 密集型任务无性能提升(此类场景应使用 Java 并行流)。
被钉住的虚拟线程(Pinned Virtual Threads)
某些情况下,虚拟线程无法从载体线程卸载,导致载体线程被阻塞,称为 “钉住”(pinned)。这会限制应用扩展性(但 JVM 可在配置允许时创建新载体线程)。
两种钉住场景:
- 执行
synchronized同步块/方法 - 调用本地方法(JNI)或外部函数
示例:同步方法导致钉住
static class Bathroom {
synchronized void useTheToilet() {
log("I'm going to use the toilet");
sleep(Duration.ofSeconds(1L));
log("I'm done with the toilet");
}
}
在单载体线程环境下,useTheToilet() 会阻塞整个载体线程,导致其他虚拟线程排队等待。
解决方案:改用 java.util.concurrent.locks.ReentrantLock:
static class Bathroom {
private final Lock lock = new ReentrantLock();
@SneakyThrows
void useTheToiletWithLock() {
if (lock.tryLock(10, TimeUnit.SECONDS)) {
try {
log("I'm going to use the toilet");
sleep(Duration.ofSeconds(1L));
log("I'm done with the toilet");
} finally {
lock.unlock();
}
}
}
}
使用 ReentrantLock 后,虚拟线程在等待锁时会卸载,载体线程可执行其他任务。
调试技巧:添加 JVM 参数追踪钉住事件-Djdk.tracePinnedThreads=short(简略)或 full(完整堆栈)
ThreadLocal 与线程池
传统线程池 vs 虚拟线程
- 平台线程:创建昂贵 → 需复用(线程池)
- 虚拟线程:创建廉价 → 无需线程池,直接“一请求一线程”
ThreadLocal 的隐患
ThreadLocal 为每个线程提供独立变量副本。虚拟线程数量可能极大(百万级),导致:
- 内存占用激增
- 在“一请求一线程”模型中,
ThreadLocal无法跨请求共享数据
替代方案:Java 20 将引入 作用域值(Scoped Values),用于在线程间安全传递不可变数据。
虚拟线程内部机制(简析)
虚拟线程本质是 延续性(Continuation)的封装:
- 延续性:可挂起和恢复的执行单元(Kotlin 协程也基于此概念)
- JVM 原生支持:虚拟线程的挂起/恢复通过 JVM 内部原生调用实现(非编译器生成代码)
状态机
虚拟线程生命周期包含多个状态(见原文状态图):
- 绿色状态:已挂载到载体线程
- 浅蓝色状态:已从载体线程卸载
- 紫色状态(PINNED):被钉住
关键流程
- 创建虚拟线程 → 初始化
VThreadContinuation - 调用
start()→ 提交runContinuation到调度器 - 执行
Continuation.run()→ 挂载到载体线程 - 遇到阻塞 → 调用
park()→ 尝试卸载(yieldContinuation())- 若成功卸载:状态变为
PARKED,调度器重新排队 - 若被钉住:调用
parkOnCarrierThread()阻塞载体线程
- 若成功卸载:状态变为
结论
本文深入探讨了 Java 虚拟线程:
- 诞生背景:解决平台线程资源瓶颈
- 核心优势:轻量级、高并发、简化并发编程模型
- 使用要点:
- 避免
synchronized(改用ReentrantLock) - 谨慎使用
ThreadLocal - 适用于 I/O 密集型场景
- 避免
- 未来展望:Project Loom 还将带来结构化并发、作用域值等革命性特性
虚拟线程将彻底改变 Java 并发编程范式,值得开发者深入掌握。