java虚拟线程

3 分钟阅读

Thread.ofVirtual() 是 Java 21 引入的一个关键 API,用于创建虚拟线程(Virtual Threads)。 这是 Java 并发编程领域的一次重大变革(Project Loom 项目的成果),旨在解决传统平台线程(Platform Threads)在高并发场景下的性能瓶颈。

1. 核心概念:什么是虚拟线程?

在 Java 21 之前,new Thread() 创建的是平台线程

  • 平台线程:是操作系统内核线程的封装(1:1 模型)。创建一个开销大,占用内存多(默认栈大小约 1MB),且数量受限(通常几千个就会耗尽内存或导致上下文切换过载)。 虚拟线程(通过 Thread.ofVirtual() 创建):
  • 轻量级:由 JVM 管理,而非操作系统直接管理(M:N 模型,即多个虚拟线程映射到少量平台线程上)。
  • 内存占用极小:初始栈大小仅几百字节,按需动态扩容。
  • 数量巨大:可以轻松创建数百万个虚拟线程,而不会耗尽系统资源。
  • 适用场景:特别适合高并发、I/O 密集型任务(如 Web 请求、数据库调用、RPC 调用),因为这些任务大部分时间在“等待”,虚拟线程在等待时会自动挂起,释放底层的平台线程去执行其他任务。

2. Thread.ofVirtual() 的作用

Thread.ofVirtual() 返回一个 Thread.Builder 对象,该对象配置为创建虚拟线程。它通常配合 .factory() 方法生成一个 ThreadFactory,或者直接用于构建线程。

基本用法示例

方式 A:直接使用 Builder 启动线程

// 创建一个虚拟线程并立即启动
Thread.startVirtualThread(() -> {
    System.out.println("这是一个虚拟线程: " + Thread.currentThread());
    // 模拟 I/O 操作
    try { Thread.sleep(1000); } catch (InterruptedException e) {}
    System.out.println("任务完成");
});

方式 B:获取 ThreadFactory(推荐,用于线程池)
这是最常用的方式,特别是与 ExecutorService 配合使用时。

// 创建一个能生产虚拟线程的工厂
ThreadFactory factory = Thread.ofVirtual().factory();

// 使用该工厂创建线程池
try (ExecutorService executor = Executors.newThreadPerTaskExecutor(factory)) {
    for (int i = 0; i < 100_000; i++) {
        final int id = i;
        executor.submit(() -> {
            System.out.println("处理任务: " + id + ", 线程: " + Thread.currentThread());
            // 模拟网络请求
            try { Thread.sleep(2000); } catch (InterruptedException e) {}
        });
    }
}
// 尝试创建 10 万个线程,使用平台线程会直接 OOM 或卡死,但虚拟线程轻松搞定

3. 关键特性解析

(1) 弹性栈内存

  • 平台线程:启动时分配固定栈内存(-Xss,通常 1MB)。如果你创建 10,000 个线程,就需要 ~10GB 内存,极易导致 OutOfMemoryError
  • 虚拟线程:启动时只占用极小的内存(约几 KB),只有在调用深度增加时才动态扩容。这使得创建百万级线程成为可能。

(2) 挂载与卸载(Mounting & Unmounting)

这是虚拟线程高性能的核心秘密:

  • 当虚拟线程执行 Java 代码 时,它“挂载”在一个平台线程(载体线程)上运行。
  • 当虚拟线程执行 阻塞操作(如 Thread.sleep()socket.read()db.query())时,JVM 会自动将其卸载,让出底层的平台线程去执行其他虚拟线程。
  • 当阻塞操作完成,虚拟线程会被重新调度到一个可用的平台线程上继续执行。
  • 结果:少量的平台线程(通常等于 CPU 核心数)就能支撑海量的并发请求,极大地提高了 CPU 利用率。

(3) 兼容性

  • 虚拟线程实现了标准的 java.lang.Thread 接口。
  • 现有的大多数调试工具、线程分析工具(如 JStack)都能识别它们(通常会显示 VirtualThread)。
  • 注意:虚拟线程不能执行长时间运行的 CPU 密集型计算(这会占用宝贵的平台线程),也不支持某些特定的操作(如 Thread.stop()Thread.suspend(), 线程组等,这些操作在虚拟线程上会抛出异常)。

4. 对比总结

特性平台线程 (Platform Thread)虚拟线程 (Virtual Thread)
创建方式new Thread() 或 Executors.newFixedThreadPool()Thread.ofVirtual().factory()
管理者操作系统 (OS)JVM
内存占用大 (默认 ~1MB/个)极小 (~几 KB/个,动态增长)
最大数量受限 (通常几千到几万)极大 (百万级甚至更多)
阻塞行为阻塞时占用 OS 线程,浪费资源阻塞时自动挂起,释放 OS 线程
最佳场景CPU 密集型任务I/O 密集型任务 (Web 服务、微服务)
调度器OS 调度器JVM 调度器 (ForkJoinPool)

5. 实际应用场景建议

如果你正在开发:

  • Spring Boot Web 应用:从 Spring Boot 3.2 开始,可以配置 spring.threads.virtual.enabled=true 轻松启用虚拟线程,大幅提升吞吐量。
  • 高并发网关/代理:需要处理大量并发连接。
  • 微服务调用链:每个请求可能涉及多次下游 RPC 调用,使用虚拟线程可以避免“线程池耗尽”问题。

代码示例:Spring Boot 中启用虚拟线程 (application.yml)

spring:
  threads:
    virtual:
      enabled: true # 一键开启,底层就是使用 Thread.ofVirtual()

6. 劣势

虽然虚拟线程(Virtual Threads)是 Java 并发编程的重大进步,特别适合高并发 I/O 场景,但它们并不是银弹,在某些特定场景下存在明显的劣势和局限性。

以下是虚拟线程的主要劣势和需要注意的“坑”:

1. 不适合 CPU 密集型任务

这是虚拟线程最大的短板。

  • 原因:虚拟线程的设计初衷是为了在 I/O 等待时释放底层的平台线程(载体线程)。如果任务主要是大量的计算(CPU 密集型),虚拟线程会一直占用载体线程,不会发生挂起(unmount)。
  • 后果
    • 此时虚拟线程相比平台线程没有任何性能优势,反而因为 JVM 的调度开销(调度器本身的成本)可能略慢一点点(虽然差异通常很小)。
    • 如果你创建了数百万个 CPU 密集型虚拟线程,它们会争抢有限的 CPU 核心,导致频繁的上下文切换,性能反而急剧下降。
  • 建议:对于 CPU 密集型任务(如图像渲染、复杂加密、大规模数据计算),继续使用传统的平台线程池Executors.newFixedThreadPool),并将线程数设置为 CPU 核心数 或 CPU 核心数 + 1

2. synchronized 导致的“钉住”(Pinning)问题

这是开发中最容易遇到的性能陷阱。

  • 现象:当虚拟线程执行到 synchronized 代码块或方法时,JVM 无法将其从载体线程上卸载(unmount)。该虚拟线程会一直“钉住”载体线程,即使它在 synchronized 块内部调用了阻塞方法(如 Thread.sleep 或 I/O)。
  • 后果
    • 如果大量虚拟线程卡在 synchronized 锁上,它们会耗尽所有的载体线程(通常只有 CPU 核心数那么多个)。
    • 此时,其他原本可以运行的虚拟线程也无法获得载体线程,导致整个应用吞吐量骤降,表现得像单线程一样慢。
  • 解决方案
    • 首选:将 synchronized 替换为 java.util.concurrent.locks.ReentrantLockReentrantLock 支持虚拟线程的挂起。
    • 次选:尽量缩小 synchronized 的粒度,避免在锁内进行 I/O 操作或长时间等待。
    • 检测:JDK 提供了 -Djdk.tracePinnedThreads=full 参数,可以在控制台打印出被钉住的线程堆栈,帮助定位问题。

3. ThreadLocal 的内存泄漏风险

虚拟线程使得创建线程变得极其廉价,这可能导致开发者滥用 ThreadLocal

  • 问题
    • 在传统模式下,线程池限制了线程总数,ThreadLocal 占用的内存也是有限的。
    • 在虚拟线程模式下,你可以轻松创建百万级线程。如果每个虚拟线程都往 ThreadLocal 里存一个大对象,且没有及时清理,内存消耗会瞬间爆炸。
    • 虽然虚拟线程结束后 ThreadLocal 会被回收,但如果线程生命周期管理不当(例如在线程池中复用逻辑错误,虽然虚拟线程通常是一次性的,但框架层面可能有误用),或者创建速度远超销毁速度,内存压力会很大。
  • 建议
    • 谨慎使用 ThreadLocal,确保在使用后立刻 remove()
    • 考虑使用 Java 21 引入的 Scoped Values(作用域值,预览特性,后续版本已稳定),它是专门为虚拟线程设计的轻量级数据共享机制,比 ThreadLocal 更安全、性能更好。

4. 调试和监控工具的兼容性

虽然主流工具(IDEA, VisualVM, JFR)已经支持虚拟线程,但在某些老旧或特定的监控系统中可能会遇到问题:

  • 堆栈显示:虚拟线程的堆栈信息非常深且动态变化,某些日志分析工具可能无法正确解析或展示。
  • 线程转储(Thread Dump):一个包含百万虚拟线程的 Thread Dump 文件会非常巨大(几百 MB 甚至 GB),查看和分析极其困难。
  • 假设失效:很多旧的监控代码假设“线程数 = 并发数”,在虚拟线程环境下这个假设完全失效,可能导致监控报警误报。

5. 不支持某些旧的 Thread API

为了保持轻量级和安全性,虚拟线程禁止了一些对平台线程有效的操作,调用这些方法会抛出 UnsupportedOperationException

  • thread.stop()thread.suspend()thread.resume()(这些方法本身就不推荐使用,但在虚拟线程上是强制禁止)。
  • thread.setPriority()(虚拟线程优先级固定,由 JVM 调度器决定)。
  • 线程组(Thread Groups)的相关操作。
  • 守护线程设置:虚拟线程默认都是守护线程,且不能修改(虽然这通常符合预期)。

6. 生态库的兼容性(历史遗留问题)

虽然 Java 21 发布已久,主流框架(Spring Boot 3.2+, Tomcat 10.1+, Jetty 12+)都已完美支持,但仍有一些老旧的第三方库可能存在隐患:

  • 本地代码(JNI):某些通过 JNI 调用的本地库如果假设线程是平台线程(例如依赖 OS 线程 ID 做缓存),可能会在虚拟线程环境下行为异常。
  • 错误的并发假设:某些库内部可能使用了 synchronized 且逻辑复杂,或者依赖线程池的大小来限制并发度(例如数据库连接池)。如果直接切换到虚拟线程而不调整连接池配置(通常虚拟线程需要更大的连接池,因为并发量变大了),可能会导致数据库连接耗尽。

总结:何时该用虚拟线程?

场景建议原因
纯 CPU 计算❌ 不要用无性能增益,增加调度开销,应限制线程数。
大量使用 synchronized⚠️ 谨慎使用会导致“钉住”效应,需重构为 ReentrantLock
重度依赖 ThreadLocal 存大对象⚠️ 谨慎使用内存泄漏风险高,建议改用 Scoped Values。
依赖旧版 Native 库⚠️ 需测试可能存在兼容性 bug。
需要精细控制线程优先级❌ 不要用虚拟线程不支持优先级设置。
最佳实践建议:
对于典型的 Web 后端服务(处理 HTTP 请求、调用数据库、调用 RPC),虚拟线程是绝佳选择,能带来数量级的吞吐量提升。但对于科学计算、图像处理等计算密集型任务,请坚持使用传统的平台线程池。

总结

Thread.ofVirtual() 是 Java 迈向高吞吐、低延迟并发模型的关键钥匙。它让开发者可以用最简单的“一个请求一个线程”的同步编程模型,去实现以前只有复杂的异步回调(Reactive Programming, 如 WebFlux)才能达到的并发性能。