Java虚拟线程实践指南:从底层原理到工程陷阱

0 阅读22分钟

引言

Java 21 正式引入了虚拟线程(Virtual Thread,JEP 444),这是自 Java 5 引入 java.util.concurrent 以来,Java 并发模型最重大的一次变革(Java 5到现在已经过了20多年了!烂大街的东西终于在Java这里成了新特性!不过有总比没有好)。虚拟线程的目标并不是让程序"跑得更快",而是让高并发 IO 密集型应用的开发回归简单

本文将从基本原理出发,深入探讨虚拟线程的工作机制、常见陷阱、与现有框架的集成策略以及调试监控方法,帮助你在生产环境中正确且高效地使用这一特性。


一、基本原理

1.1 主要作用:让每请求每线程再次成为可能

在传统 Java 服务端编程中,我们习惯用一个线程处理一个请求(Thread-per-Request)。这种模型代码直观、易于调试,异常处理也符合直觉:

// 传统的阻塞式写法:简单、直观
Response handle(Request req) {
    var user = userService.findById(req.getUserId());   // 阻塞等待数据库
    var orders = orderService.listByUser(user.getId());  // 阻塞等待数据库
    var result = buildResponse(user, orders);
    return result;
}

问题在于,原生线程(Platform Thread)是昂贵的。每个原生线程默认占用约 1MB 栈内存,且与操作系统内核线程 1:1 映射。当并发连接数达到数万甚至数十万时,操作系统无法承载如此多的内核线程,线程池成为瓶颈。

为了突破这一限制,社区发展出了异步非阻塞的编程范式:

// Reactor 风格:高性能,但代码复杂度剧增
Mono<Response> handle(Request req) {
    return userService.findById(req.getUserId())
        .flatMap(user -> orderService.listByUser(user.getId())
            .map(orders -> buildResponse(user, orders)));
}

Reactor、RxJava、Vert.x 等框架通过事件循环(Event Loop)和回调链将阻塞操作从线程中解耦,让少量线程服务大量并发请求。代价是:代码变得晦涩,调用栈丢失,调试困难,异常处理反直觉,学习曲线陡峭。

虚拟线程的核心价值,正是消除这个两难困境。 它让你用回最朴素的阻塞式顺序代码,同时获得接近异步框架的并发能力:

// 虚拟线程:写法和传统完全一致,但底层不再绑定原生线程
Response handle(Request req) {
    var user = userService.findById(req.getUserId());   // 虚拟线程在此挂起,不占用原生线程
    var orders = orderService.listByUser(user.getId());  // 同上
    var result = buildResponse(user, orders);
    return result;
}
// 启动方式
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request req : requests) {
        executor.submit(() -> handle(req));
    }
}

这就是虚拟线程的核心主张:用同步的写法,享受异步的吞吐

1.2 常见误区:虚拟线程不是银弹

必须首先澄清一个广泛存在的误解:

虚拟线程不会让你的程序运行得更快。

虚拟线程提升的是并发度(Concurrency) ,而非计算性能(Performance)

具体而言:

  • CPU 密集型场景:虚拟线程毫无优势。最终所有代码都要在原生线程上执行,虚拟线程数量再多,可用的 CPU 核心数不会增加。如果你的瓶颈是 CPU 计算,虚拟线程只会增加调度开销。
  • IO 密集型场景:虚拟线程的优势体现在"等待"上。当一个虚拟线程阻塞在 IO 操作上时,底层的原生线程被释放去执行其他虚拟线程,从而让有限的原生线程服务远超其数量的并发请求。
  • 受限于总吞吐:即使你创建了 100 万个虚拟线程,如果下游数据库每秒只能处理 1000 个查询,整体吞吐也不会超过 1000 QPS。虚拟线程不会凭空增加上下游系统的处理能力。

一句话总结:虚拟线程解决的是"线程不够用"的问题,而不是"CPU 不够快"的问题。

1.3 与原生线程的映射关系

虚拟线程采用 M:N 调度模型:M 个虚拟线程映射到 N 个原生线程上(也称为载体线程,Carrier Thread)。

JVM 默认使用一个专用的 ForkJoinPool 作为虚拟线程的调度器,其载体线程数默认等于 可用 CPU 核心数Runtime.getRuntime().availableProcessors())。这意味着在一台 8 核机器上,默认只有 8 个载体线程,却可以同时运行数十万甚至上百万个虚拟线程。

1.4 轻量级切换

切换的时机

虚拟线程的切换发生在 JVM 内部,而非操作系统层面。当虚拟线程执行到以下操作时,会主动让出(yield) 载体线程:

  • 阻塞 IO 操作Socket.read()Socket.write()FileChannel 操作等
  • Thread.sleep()
  • BlockingQueue.take() / put()
  • Lock.lock()ReentrantLockjava.util.concurrent 锁)
  • CompletableFuture.get() / join()

与操作系统级别的上下文切换相比,虚拟线程的切换成本极低:

维度原生线程切换虚拟线程切换
切换层面内核态用户态(JVM 内部)
栈处理保存/恢复完整寄存器将栈帧拷贝到堆 / 从堆恢复
典型耗时1-10 微秒约 200 纳秒
TLB 刷新可能需要不需要

如何处理阻塞 IO

这是虚拟线程最关键的机制。JDK 对标准库中的阻塞 IO 操作进行了改造:当虚拟线程调用阻塞 IO 时,JVM 并不会让底层的载体线程真正阻塞在系统调用上,而是:

  1. 将当前虚拟线程的栈帧(continuation)保存到堆内存
  2. 将虚拟线程标记为"等待 IO"状态
  3. 载体线程被释放,去执行调度队列中的其他虚拟线程
  4. 当 IO 就绪时(通过底层的 NIO/epoll/io_uring 机制通知),虚拟线程被重新调度到某个载体线程上继续执行

本质上,虚拟线程在 JVM 层面实现了操作系统的 IO 多路复用,但对应用开发者完全透明。

要注意饥饿问题

由于载体线程数量有限(默认等于 CPU 核心数),如果某些虚拟线程长时间占用载体线程不释放,会导致其他虚拟线程无法获得执行机会,产生饥饿(Starvation)

典型的饥饿场景:

  • 虚拟线程中执行了长时间的 CPU 密集计算
  • 虚拟线程持有 synchronized 锁并在锁内执行阻塞 IO(即 Pinning,后文详述)
  • 调用了未经适配的 native 方法导致载体线程阻塞

饥饿的表现通常是:系统吞吐量骤降,大量虚拟线程处于"可运行但未运行"的状态,而 CPU 利用率可能并不高。


二、注意隔离

2.1 限制虚拟线程底层的原生线程数目

虚拟线程的调度器(ForkJoinPool)默认使用与 CPU 核心数相同的载体线程。在某些场景下,你可能需要调整这个数量:

# 通过 JVM 参数设置载体线程数
-Djdk.virtualThreadScheduler.parallelism=4
-Djdk.virtualThreadScheduler.maxPoolSize=4

什么时候需要调整?

  • 当你的应用中混合使用虚拟线程和原生线程,且原生线程也有较大的 CPU 需求时,应适当减少虚拟线程调度器的并行度,为原生线程预留 CPU 资源。
  • 当你需要控制对下游系统的并发压力时(虽然信号量是更推荐的方式)。

2.2 CPU 密集型任务使用原生线程

这是一条重要的设计原则:虚拟线程专用于 IO 密集型任务,CPU 密集型任务应继续使用原生线程池。

// IO 密集型:使用虚拟线程
ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor();
​
// CPU 密集型:使用固定大小的原生线程池
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors()
);
​
// 混合场景示例
void processRequest(Request req) {
    // IO 操作在虚拟线程中执行
    var data = fetchFromDatabase(req);  // 当前已在虚拟线程中
​
    // CPU 密集计算提交到原生线程池
    var future = cpuExecutor.submit(() -> heavyComputation(data));
    var result = future.get();  // 虚拟线程在此挂起等待,不浪费载体线程
​
    sendResponse(result);
}

这样做的原因是:

  1. CPU 密集任务会长时间占用载体线程,导致其他虚拟线程饥饿
  2. 虚拟线程的调度开销虽小,但对于纯计算任务来说仍是不必要的额外开销
  3. 原生线程池的大小可以精确控制,更适合 CPU 资源管理

三、最大的问题:Pinning

3.1 问题的形式

Pinning(固定) 是虚拟线程目前面临的最严重的实际问题。当虚拟线程在以下两种情况下执行阻塞操作时,它会被"钉"在载体线程上,导致载体线程无法被释放:

  1. synchronized 代码块或方法中执行阻塞操作
  2. 在 native 方法帧内执行阻塞操作
// 典型的 Pinning 场景
synchronized (lock) {
    // 虚拟线程在此被固定到载体线程上
    var result = httpClient.send(request, bodyHandler);  // 阻塞IO!载体线程也被阻塞
    process(result);
}

在上述代码中,虚拟线程持有 synchronized 锁并执行了网络 IO。正常情况下,虚拟线程会在 IO 阻塞时让出载体线程。但由于 synchronized 的存在,JVM 无法安全地将虚拟线程从载体线程上卸载,因为 synchronized 的监视器(monitor)与特定的操作系统线程绑定。结果就是:载体线程被白白阻塞,和传统原生线程无异。

标准库中隐藏的 synchronized

更棘手的是,Pinning 问题往往隐藏在你看不到的地方。Java 标准库和第三方库中大量使用了 synchronized

一些常见的"陷阱"来源:

来源典型类/方法
标准 IO 流PrintStreamBufferedOutputStreamBufferedInputStream
JDBC 驱动多数驱动内部使用 synchronized 保护连接状态
日志框架Logback/Log4j2 的某些 Appender
HTTP 客户端Apache HttpClient 4.x 的连接池
序列化ObjectOutputStream / ObjectInputStream

你可以通过以下 JVM 参数检测 Pinning 事件:

# JDK 21+:当发生 Pinning 时打印警告
-Djdk.tracePinnedThreads=short
​
# 输出示例:
# Thread[#37,ForkJoinPool-1-worker-3,5,CarrierThreads] 
#     java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
#     java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
#     java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:631)
#     java.base/java.lang.System$2.parkVirtualThread(System.java:2643)
#     java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)
#     java.base/java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:269)
#     ...
#     com.example.MyService.fetchData(MyService.java:42) <== blocked here
#         while holding monitor for java.lang.Object@6b71769e  <== pinning cause

3.2 问题的根源

要理解 Pinning 为何发生,需要了解虚拟线程挂起(unmount)的底层机制。

虚拟线程的挂起本质上是 Continuation 的让出(yield) 。当虚拟线程需要挂起时,JVM 会:

  1. 将当前虚拟线程的调用栈(即 Continuation)从载体线程的栈上拷贝到堆内存
  2. 恢复载体线程的状态,让调度器将其分配给其他虚拟线程

问题出在 synchronized 的实现上。Java 的 synchronized 使用的是 对象监视器(Object Monitor) ,而对象监视器在 HotSpot JVM 中与操作系统线程(即载体线程)深度绑定:

synchronized 的内部机制(简化):
​
1. monitorenter 指令
   └── 将当前 OS 线程的 ID 记录在对象头的 Mark Word 中
   └── 如果需要膨胀为重量级锁,则关联一个 OS 级的 mutex
​
2.synchronized 块内部
   └── monitor 持有者 = 特定的 OS 线程(载体线程)
   └── 如果此时要 unmount 虚拟线程并将载体线程交给另一个虚拟线程...
       └── 新的虚拟线程运行在同一个载体线程上
       └── 它"看起来"像是 monitor 的持有者(因为是同一个 OS 线程)
       └── 监视器的语义被破坏!
   
3. monitorexit 指令
   └── 验证当前 OS 线程是否是 monitor 持有者
   └── 如果虚拟线程被调度到了不同的载体线程上恢复执行
       └── OS 线程 ID 不匹配 → 解锁失败 → 崩溃

因此,JVM 不得不在虚拟线程持有 synchronized 锁时禁止卸载,将其固定在当前载体线程上。这不是设计缺陷,而是在当前 synchronized 实现下保证正确性的必要措施。

相比之下,java.util.concurrent.locks.ReentrantLock 完全在 Java 层面实现,基于 AbstractQueuedSynchronizer(AQS),其锁的持有者以 Thread 对象而非 OS 线程 ID 来标识。因此 ReentrantLock 可以正确地与虚拟线程协作——虚拟线程在持有 ReentrantLock 时可以正常挂起和恢复。

// ✗ 会导致 Pinning
synchronized (lock) {
    blockingIO();
}
​
// ✓ 不会导致 Pinning
private final ReentrantLock lock = new ReentrantLock();
​
lock.lock();
try {
    blockingIO();
} finally {
    lock.unlock();
}

3.3 JDK 24 的解决方案

Oracle 在 JDK 24(2025 年 3 月发布)中通过 JEP 491 从根本上解决了 synchronized 的 Pinning 问题。

JEP 491 重新实现了对象监视器,使其不再与操作系统线程绑定:

  • 当虚拟线程持有 synchronized 锁并需要阻塞时,JVM 现在可以安全地将其从载体线程上卸载
  • 监视器的持有者信息改为跟踪虚拟线程而非载体线程
  • 这一改变对应用代码完全透明

这意味着从 JDK 24 开始,你不再需要将所有 synchronized 替换为 ReentrantLock,也不必过度担忧第三方库中隐藏的 synchronized

注意:JDK 24 解决的是 synchronized 的 Pinning 问题。Native 方法帧导致的 Pinning 仍然存在,因为 JVM 无法操控 native 代码的栈帧。但在实际开发中,native 方法帧内的阻塞远不如 synchronized 常见。


四、在虚拟线程中不要再用 ThreadLocal

4.1 问题描述

ThreadLocal 是 Java 中实现线程局部存储的经典机制,广泛用于传递请求上下文、数据库连接、事务信息等。

在原生线程模型下,这种用法完全合理——线程池中的线程数量有限(通常几十到几百),每个 ThreadLocal 变量只会有与线程数相同的实例。

但在虚拟线程模型下,情况完全不同:

问题一:内存浪费

虚拟线程可以轻松创建数十万甚至上百万个。如果每个虚拟线程都持有 ThreadLocal 副本,内存消耗将是巨大的:

// 假设每个 ThreadLocal 值占 1KB
// 100 万个虚拟线程 × 1KB = ~1GB 仅用于 ThreadLocal 数据

问题二:生命周期不匹配

原生线程通常通过线程池复用,ThreadLocal 的值在线程生命周期内持续存在(配合 remove() 清理)。虚拟线程是一次性的,不应该被池化。每次创建新的虚拟线程都会重新初始化 ThreadLocal,失去了"复用"的意义。

问题三:可继承性混乱

InheritableThreadLocal 在创建子线程时会拷贝父线程的值。如果在虚拟线程中大量创建子虚拟线程,每次拷贝都有内存和时间开销。

4.2 替代方案:Scoped Values

JDK 21 引入了 Scoped Values(JEP 429,Preview),在 JDK 24 中进入第四轮预览(JEP 487),预计在未来版本中正式发布。Scoped Values 专为虚拟线程时代设计:

// 声明一个 ScopedValue
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
​
void handleRequest(Request req) {
    ScopedValue.where(CONTEXT, new UserContext(req.getUserId()))
        .run(() -> {
            businessLogic();  // 内部通过 CONTEXT.get() 获取用户信息
        });
    // 离开 run() 作用域后,值自动不可见,无需手动 remove
}

ScopedValue 相比 ThreadLocal 的优势:

维度ThreadLocalScopedValue
可变性可变(get/set)不可变(绑定后不可修改)
作用域线程生命周期显式的代码块作用域
清理需要手动 remove()(容易遗忘)自动随作用域结束清理
继承InheritableThreadLocal 深拷贝自动对子线程可见,零拷贝
内存效率每个线程一份副本基于栈帧,天然更轻量
适合虚拟线程

实际建议:

  • 如果你的项目仍在 JDK 21 上且 ScopedValue 尚未正式发布,短期内继续使用 ThreadLocal 是可以接受的,但要注意控制虚拟线程的数量,并确保及时 remove()
  • 新项目应优先评估 ScopedValue,即使它仍处于预览状态。

五、要注意上下游

虚拟线程让你可以轻松创建数十万个并发任务,但这种"无限并发"的能力如果不加约束,反而会引发严重问题。

5.1 上游吞吐有限:创建太多虚拟线程没有意义

如果你的应用接收请求的速率是有限的(例如前端网关限制为 5000 QPS),那么无论你创建多少虚拟线程,在某一时刻活跃的虚拟线程数也就在数千的量级。创建虚拟线程的策略应当匹配实际的请求到达速率。

// 正确做法:按请求创建虚拟线程,不预分配
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    while (server.isRunning()) {
        var socket = serverSocket.accept();  // 一个请求一个虚拟线程
        executor.submit(() -> handle(socket));
    }
}
// 虚拟线程数自然与并发请求数匹配

5.2 下游吞吐有限:太多虚拟线程可能将下游打垮

这是更危险的场景。假设你的应用接收了 10 万个并发请求,每个请求都需要查询数据库。如果每个请求都在虚拟线程中直接发起数据库查询,下游数据库将瞬间收到 10 万个并发连接请求——这几乎必然导致数据库崩溃。

解决方案:使用信号量(Semaphore)限流

// 用信号量限制对下游系统的并发访问
private static final Semaphore DB_LIMITER = new Semaphore(200);  // 匹配数据库连接池大小void queryDatabase(String sql) throws Exception {
    DB_LIMITER.acquire();  // 虚拟线程在此挂起等待,不消耗载体线程
    try {
        // 最多 200 个虚拟线程同时执行数据库查询
        return jdbcTemplate.query(sql);
    } finally {
        DB_LIMITER.release();
    }
}

Semaphore.acquire() 已经被 JDK 适配为虚拟线程友好的操作——当信号量不可用时,虚拟线程会正常挂起(unmount),不会占用载体线程。这使得信号量成为虚拟线程场景下最自然的限流工具。

对其他下游系统同样适用:

// 限制对外部 HTTP API 的并发调用
private static final Semaphore API_LIMITER = new Semaphore(50);
​
// 限制对 Redis 的并发访问
private static final Semaphore REDIS_LIMITER = new Semaphore(500);
​
// 限制对消息队列的并发写入
private static final Semaphore MQ_LIMITER = new Semaphore(100);

核心原则:虚拟线程让你的应用本身不再是瓶颈,但下游系统仍然有容量上限。你有责任用信号量、连接池或其他限流手段保护下游。


六、快速释放

6.1 虚拟线程的栈是惰性增长的

原生线程在创建时就需要预分配固定大小的栈空间(默认约 1MB),无论是否用到。虚拟线程则不同——它的栈存储在堆内存中,并且是惰性增长(lazily growing) 的:这意味着虚拟线程的内存占用与其实际调用深度成正比,而非固定开销。一个刚创建的虚拟线程可能只占用几百字节到几 KB。

6.2 可以创建很多快速结束的虚拟线程

虚拟线程的设计哲学是短暂的、一次性的、按需创建的。它们不应该被池化,也不应该被复用。每个任务一个虚拟线程,任务完成后虚拟线程随即消亡,GC 负责回收。

// ✓ 推荐:大量短生命周期虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Result>> futures = new ArrayList<>();
    for (var task : tasks) {
        futures.add(executor.submit(() -> {
            // 快速完成的 IO 操作
            var data = httpClient.send(request, bodyHandler);
            return process(data);
            // 任务完成,虚拟线程结束,内存很快被 GC 回收
        }));
    }
    // 收集结果
    for (var future : futures) {
        results.add(future.get());
    }
}

这种模式下,虚拟线程的数量像水波一样起伏——请求到来时创建,处理完成后消亡。系统在任何时刻只保留活跃任务所需的虚拟线程。

6.3 避免创建大量长期存活的虚拟线程

虽然虚拟线程很轻量,但"轻量"不等于"零成本"。如果你创建了数十万个虚拟线程且它们长期存活(例如每个虚拟线程维护一个长连接、等待推送消息),这些虚拟线程的栈帧将持续占用堆内存:

// ⚠ 需要谨慎:大量长期存活的虚拟线程
for (int i = 0; i < 500_000; i++) {
    Thread.startVirtualThread(() -> {
        while (true) {
            var message = connection.waitForMessage();  // 长期阻塞等待
            process(message);
        }
    });
}
// 50万个虚拟线程的栈帧常驻堆内存
// 假设每个挂起的虚拟线程栈帧占 2-10KB
// 总计约 1GB - 5GB 堆内存被栈帧占用
// 还会增加 GC 扫描和压力

建议:

  • 如果确实需要大量长连接,评估总内存预算,确保堆空间充足
  • 监控 GC 表现,避免频繁 Full GC
  • 考虑是否真的需要每个连接一个虚拟线程,或者是否可以用更少的虚拟线程配合事件驱动

6.4 避免在虚拟线程内申请操作系统资源

虚拟线程的数量可以轻松达到数十万甚至百万级别,但操作系统资源(文件描述符、Socket、端口等)是有硬性上限的,应当避免在虚拟线程中开启如下资源,如果必须开启,那么考虑使用信号量进行限流:

资源类型典型上限风险
文件描述符ulimit -n(默认 1024)Too many open files
TCP 连接(Socket)端口数 65535 / conntrack端口耗尽 / 连接表溢出
临时端口net.ipv4.ip_local_port_range(默认约 28000 个)Cannot assign requested address
数据库连接连接池大小 / 数据库 max_connections连接池耗尽
进程/子进程ulimit -uResource temporarily unavailable

七、与现有框架集成

7.1 不要与 Netty 或 WebFlux 等异步框架混合使用

虚拟线程和响应式框架(Reactor / WebFlux / Vert.x / Netty)都致力于解决同一个问题:用有限的线程处理大量并发 IO。它们只是走了不同的路径:

维度响应式框架虚拟线程
编程模型异步回调 / 流式链同步阻塞
线程模型少量事件循环线程大量虚拟线程 + 少量载体线程
适配成本需要全链路异步兼容现有同步代码
学习曲线陡峭平缓
背压机制内建(Reactive Streams)需要手动实现(信号量等)

如果你在 WebFlux 应用中引入虚拟线程,可能产生以下混乱:

// ✗ 混乱的混合使用
@GetMapping("/users/{id}")
Mono<User> getUser(@PathVariable Long id) {
    return Mono.fromCallable(() -> {
        // 这段代码在虚拟线程中执行?在 Reactor 的调度器上执行?
        // 阻塞调用在 Reactor 的事件循环线程上会导致严重问题
        return userRepository.findById(id);  // 阻塞!
    }).subscribeOn(Schedulers.boundedElastic());  // 还是用弹性线程池?
}

建议原则:

  • 新项目:如果目标 JDK ≥ 25,优先选择虚拟线程 + Spring MVC(而非 WebFlux)
  • 存量 WebFlux 项目:不要为了使用虚拟线程而重构,WebFlux 本身已经解决了并发问题
  • 存量 Spring MVC 项目:这是虚拟线程的最佳迁移对象,只需替换底层线程池即可
// Spring Boot 3.2+ 启用虚拟线程(仅 Spring MVC)
// application.properties
spring.threads.virtual.enabled=true// 或编程方式
@Bean
TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
    return protocolHandler -> {
        protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    };
}

7.2 注意原生线程数量的规划

实际应用中,除了虚拟线程的载体线程,还有许多框架会创建自己的原生线程,当这些原生线程与虚拟线程的载体线程争抢 CPU 资源时,可能影响虚拟线程的调度延迟。

常见来源:

  • 各类连接池的 housekeeping 线程
  • 缓存、日志、指标系统
  • JVM 内部线程、GC、JIT
  • 业务自己建的线程池(尤其是无界)

如果平台线程总量失控,会造成:

  • CPU 被线程调度开销吞噬
  • 虚拟线程载体线程得不到足够 CPU 时间片
  • 延迟抖动

建议:

  • 梳理应用中所有原生线程的来源和数量
  • 在 CPU 核心数有限的容器环境(例如 Kubernetes 中分配 2-4 核)中尤其注意
  • 适当调整虚拟线程调度器的并行度:
# 4核容器中,可能需要为其他原生线程预留一些CPU
-Djdk.virtualThreadScheduler.parallelism=2
-Djdk.virtualThreadScheduler.maxPoolSize=2

八、调试

虚拟线程给传统的调试和监控手段带来了新的挑战。原生线程数量有限(几十到几百),每个线程都有清晰的身份和状态;虚拟线程可能有数十万个,传统工具在面对如此量级时往往力不从心。

Thread Dump 的变化

传统的 jstackkill -3 产生的线程转储会包含所有虚拟线程,当虚拟线程数量庞大时,输出可能高达数百 MB,几乎不可读:

# 传统方式:输出可能极其庞大
jstack <pid>
​
# JDK 21+ 推荐:使用 jcmd 生成结构化线程转储(JSON 格式)
jcmd <pid> Thread.dump_to_file -format=json threads.json
​
# JSON 格式的输出包含虚拟线程的分组信息,更易于分析

JDK 21 引入的新格式会将虚拟线程按其执行器(Executor)分组,而不是平铺列出所有线程。

九、监控

建议把监控重点从“线程数”转向“调度与阻塞质量”:

  1. 载体线程(平台线程)饱和度
  • 载体线程数量、CPU 使用率
  • 运行队列长度/调度延迟(如果可观测到)
  1. Pinned 相关
  • pinned 虚拟线程次数/持续时间
  • 是否出现“载体线程被阻塞占满”的迹象(吞吐断崖式下降、延迟尖刺)
  1. 虚拟线程数量(总量与峰值)
  • 创建速率、并发峰值
  • 结合 GC/堆使用观察是否因上下文/ThreadLocal/栈片段导致内存上升
  1. 下游资源指标(比线程更关键)
  • DB 连接池:活跃/等待/超时
  • HTTP 客户端连接池:租借等待、连接建立失败
  • 文件描述符使用量、socket 数
  • 下游请求延迟、错误率、限流/熔断触发次数
  1. 端到端延迟分解
  • 排队时间、服务时间、依赖耗时
  • 超时分布(P95/P99/P999)

工具层面可结合:JFR(Java Flight Recorder)、线程/事件采样、以及针对虚拟线程/锁/阻塞的事件分析。生产环境建议以“持续 profiling + 聚合指标”替代“靠 thread dump 人肉找”。


结语

虚拟线程不是一种全新的编程范式,恰恰相反——它的价值在于让 Java 开发者回归最朴素的编程模型。一个请求一个线程,用顺序代码表达顺序逻辑,异常处理符合直觉,调用栈完整可读。

但"简单"不等于"无脑"。虚拟线程的轻量性是一种诱惑——它让你能够轻松创建数十万个并发任务,但你必须清楚地认识到:你的应用不是一座孤岛。上游的请求速率、下游的处理能力、操作系统的资源限制,这些才是真正的边界。虚拟线程突破的只是 JVM 内部的线程数量限制,它不会凭空扩展整个系统的容量。

如果你正在启动新项目,虚拟线程(配合 JDK 24+)是处理 IO 密集型工作负载的首选方案。如果你有存量的 Spring MVC 应用,虚拟线程提供了一条几乎零成本的迁移路径。如果你已经在使用 WebFlux 并且运行良好——不必急于迁移,它们殊途同归。

选择适合你的场景的工具,而不是最新的工具。 虚拟线程是 Java 并发工具箱中一个强大的新成员,但它最终只是工具箱中的一员。理解它的能力边界,才能真正发挥它的价值。