Java 虚拟线程终极指南

更新于 2025-12-28

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):每个线程使用自己的局部变量存储状态,极大减少了共享可变状态带来的复杂性(即并发编程中最棘手的部分)。然而,该模型受限于平台线程的资源瓶颈。

近年来,业界提出了多种解决方案:

  1. 回调地狱(Callback Hell):通过嵌套回调处理异步操作,但代码难以阅读和维护。
  2. 响应式编程(Reactive Programming):通过 DSL 声明数据流,由框架管理并发。但 DSL 学习曲线陡峭,牺牲了 Java 的简洁性。
  3. 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())时:

  1. 载体线程被释放,虚拟线程栈帧移回堆内存。
  2. 载体线程可执行其他就绪的虚拟线程。
  3. 阻塞结束后,虚拟线程被重新调度(可能在不同载体线程上继续执行)。

载体线程池规模验证

以下代码创建 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 可在配置允许时创建新载体线程)。

两种钉住场景

  1. 执行 synchronized 同步块/方法
  2. 调用本地方法(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 为每个线程提供独立变量副本。虚拟线程数量可能极大(百万级),导致:

  1. 内存占用激增
  2. 在“一请求一线程”模型中,ThreadLocal 无法跨请求共享数据

替代方案:Java 20 将引入 作用域值(Scoped Values),用于在线程间安全传递不可变数据。


虚拟线程内部机制(简析)

虚拟线程本质是 延续性(Continuation)的封装:

  • 延续性:可挂起和恢复的执行单元(Kotlin 协程也基于此概念)
  • JVM 原生支持:虚拟线程的挂起/恢复通过 JVM 内部原生调用实现(非编译器生成代码)

状态机

虚拟线程生命周期包含多个状态(见原文状态图):

  • 绿色状态:已挂载到载体线程
  • 浅蓝色状态:已从载体线程卸载
  • 紫色状态(PINNED):被钉住

关键流程

  1. 创建虚拟线程 → 初始化 VThreadContinuation
  2. 调用 start() → 提交 runContinuation 到调度器
  3. 执行 Continuation.run() → 挂载到载体线程
  4. 遇到阻塞 → 调用 park() → 尝试卸载(yieldContinuation()
    • 若成功卸载:状态变为 PARKED,调度器重新排队
    • 若被钉住:调用 parkOnCarrierThread() 阻塞载体线程

结论

本文深入探讨了 Java 虚拟线程:

  • 诞生背景:解决平台线程资源瓶颈
  • 核心优势:轻量级、高并发、简化并发编程模型
  • 使用要点
    • 避免 synchronized(改用 ReentrantLock
    • 谨慎使用 ThreadLocal
    • 适用于 I/O 密集型场景
  • 未来展望:Project Loom 还将带来结构化并发、作用域值等革命性特性

虚拟线程将彻底改变 Java 并发编程范式,值得开发者深入掌握。