🧵 虚拟线程

原理 · 最佳实践 · 调优参数

从底层原理到生产落地,一张图彻底搞懂

💡 一句话本质

虚拟线程是JVM 实现的轻量级用户态线程, 采用 M:N 调度模型:M 个虚拟线程映射到 N 个操作系统线程。 它不是"更快的线程",而是"更多的线程"——百万级并发也只占用少量系统资源, 专门解决 IO 密集型场景 的线程数量瓶颈。

☕
JVM 工程师
1

核心模型:1:1 vs M:N

🏭 平台线程:1:1 模型

Java 平台线程
平台线程 1
平台线程 2
平台线程 3
操作系统内核线程
OS 线程 1
OS 线程 2
OS 线程 3

特点:

  • • 一个 Java 线程 = 一个 OS 线程
  • • 线程创建、切换都要内核介入
  • • 每个线程预分配 ~1MB 栈内存
  • • 几千个线程就把资源耗光

🚀 虚拟线程:M:N 模型

虚拟线程(成百上千个)
VT 1
VT 2
VT 3
VT 4
VT 5
VT 6
VT 7
VT 8
VT 9
载体线程(少量)
载体 1
载体 2
载体 3
操作系统线程
OS 1
OS 2
OS 3

特点:

  • • 大量虚拟线程 → 少量载体线程
  • • 调度在用户态完成,内核不感知
  • • 栈内存按需分配,存放在堆中
  • • 轻松支持百万级并发

🏢 通俗比喻

操作系统线程 = 工位(数量有限,租一个很贵); 虚拟线程 = 打工人(可以很多,人轻量)。 打工人工作时占用工位,去接水/上厕所(IO阻塞)时就离开工位,让别人用。 调度器就是行政,负责安排谁什么时候用哪个工位。

2

底层实现三大支柱

① 载体线程

Carrier Thread

  • • 本质就是普通的平台线程
  • • 是虚拟线程的"执行载体"
  • • 一个载体同一时间只跑一个虚拟线程
  • • 数量默认 = CPU 核心数
  • • 虚拟线程阻塞时,载体线程会被让出
② 调度器

ForkJoinPool

  • • 默认使用 ForkJoinPool 作为调度器
  • • 采用工作窃取(Work-Stealing)算法
  • • 负责把虚拟线程分配给载体线程
  • • 全部在用户态完成,无内核开销
  • • 调度开销远低于操作系统调度
③ 分段栈

Segmented Stack

  • • 虚拟线程的栈不固定大小
  • • 由多个栈块(Stack Chunk)组成
  • • 栈内存存储在 Java 堆中
  • • 调用深度增加时自动追加栈块
  • • 方法返回后自动释放栈块
  • • 初始只有几十 KB,内存占用极小

📊 栈内存结构对比

平台线程:固定连续栈

固定 1MB
用不用都占着

虚拟线程:分段栈

栈块1
栈块2
栈块3
按需分配
用多少占多少
3

完整调度生命周期

① 创建虚拟线程

Thread.ofVirtual().start(...) 创建虚拟线程, 分配初始栈块,任务进入调度队列,此时未挂载。

💡 这一步非常轻量,成本和创建一个对象差不多,所以可以随便建,不要池化。

② 挂载(Mount)执行

调度器选中一个空闲载体线程,把虚拟线程的栈帧绑定到载体线程上, 开始执行代码。此时虚拟线程 = 跑在载体线程上。

③ 阻塞时卸载(Unmount)

遇到 IO、sleep、LockSupport.park() 等阻塞操作时, 虚拟线程会从载体线程上卸载下来, 栈数据完整保存到堆内存,载体线程被释放去跑其他虚拟线程。

⚠️ 注意:这是"真阻塞",但阻塞的是虚拟线程,载体线程不会阻塞,继续干别的活。

④ 唤醒后重新挂载

IO 完成、sleep 结束等唤醒事件发生后, 虚拟线程重新进入调度队列,等待分配空闲载体线程, 栈数据重新挂载,从上次中断的地方继续执行。

⑤ 执行结束,资源释放

任务执行完毕,虚拟线程退出,分段栈内存全部释放, 载体线程继续待命,执行下一个虚拟线程。

4

为什么虚拟线程切换快?

平台线程切换(内核态)

  1. 触发 CPU 特权级切换(用户态 → 内核态)
  2. 保存线程上下文到内核栈
  3. 内核调度器选择下一个线程
  4. 恢复新线程上下文
  5. 切回用户态继续执行

成本:微秒级,涉及内核态切换、缓存失效

虚拟线程切换(用户态)

  1. 完全在用户态执行,不需要内核参与
  2. 只保存虚拟线程栈指针等少量信息
  3. JVM 调度器直接选择下一个虚拟线程
  4. 切换栈指针,恢复执行

成本:纳秒级,相当于一次方法调用

🎯 核心结论

虚拟线程的高效,本质是把昂贵的内核态线程切换, 换成了廉价的用户态栈切换。 对于大量等待 IO 的场景,线程大部分时间都在阻塞, 用少量载体线程就能带动海量虚拟线程,吞吐量直接起飞。

5

常见误区澄清

✕

误区1:虚拟线程比平台线程快

错。单个任务执行速度没有区别,甚至因为挂载卸载还有轻微开销。 它的优势是数量多、并发高,IO密集场景下整体吞吐量提升,而不是单个任务变快。

✕

误区2:CPU 密集型用虚拟线程也能提速

错。CPU 满负载时,载体线程本身就没空,虚拟线程再多也没用。 虚拟线程只适合IO 密集型场景(网络请求、数据库、文件读写)。

✕

误区3:虚拟线程要用线程池管理

错。虚拟线程创建成本几乎为0,每个任务新建一个就行,不要池化。 池化是平台线程的产物,因为创建成本高才需要池。

✕

误区4:synchronized 会阻塞载体线程

对(这是一个坑)。synchronized 目前会导致载体线程一起阻塞, 相当于退化成平台线程。高并发场景建议用 ReentrantLock 替代。

6

生产最佳实践

🎯 场景选型

✅ 推荐场景
  • • 微服务间大量 HTTP/RPC 调用
  • • 数据库访问、Redis 操作等 IO 等待
  • • 消息消费、网关代理等 IO 密集型服务
  • • 高并发短任务,大量时间在等待
  • • 并行批量数据处理(外部依赖多)
❌ 不推荐场景
  • • CPU 密集型计算(加密、压缩、排序)
  • • 长时间持有 synchronized 锁
  • • 执行本地文件大量顺序读写
  • • 任务本身已经占满 CPU 资源
  • • 极低并发场景(体现不出优势)

💻 编码最佳实践

推荐:每个任务新建虚拟线程 + 结构化并发

// ✅ 推荐:使用 Executors.newVirtualThreadPerTaskExecutor()
// 每个任务一个虚拟线程,自动管理生命周期
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List> futures = new ArrayList<>();
    for (int i = 0; i < 1000; i++) {
        futures.add(executor.submit(() -> callRemoteApi()));
    }
    // 等待所有结果
    for (Future f : futures) {
        f.get();
    }
} // 退出自动清理,不会泄漏

// ✅ 更推荐:结构化并发(Java 21 预览)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user = scope.fork(() -> userService.query(id));
    var order = scope.fork(() -> orderService.query(id));
    scope.join().throwIfFailed();
    return new Result(user.get(), order.get());
}

✅ 正确做法

  • • 每个任务新建虚拟线程,不要池化
  • • 使用 try-with-resources 管理作用域
  • • 优先使用 ReentrantLock 替代 synchronized
  • • IO 操作尽量使用支持虚拟线程的客户端
  • • 配合结构化并发管理任务生命周期

❌ 错误做法

  • • 用线程池池化虚拟线程
  • • 长时间占用 synchronized 锁
  • • ThreadLocal 大量使用不清理
  • • 用 Thread.sleep 做业务等待
  • • CPU 密集型任务强行用虚拟线程

🚀 迁移落地建议

  1. 灰度验证:先在非核心接口开启,观察性能和稳定性
  2. 压测对比:对比相同流量下平台线程与虚拟线程的表现
  3. 依赖检查:确保使用的 HTTP 客户端、数据库驱动支持虚拟线程
  4. 锁优化:把关键路径的 synchronized 替换为 ReentrantLock
  5. 监控覆盖:关注载体线程数、虚拟线程数、挂载卸载频率
  6. 逐步推广:从边缘服务到核心服务,逐步全量切换
7

JVM 调优参数大全

参数 默认值 说明 调优建议
-XX:ParallelGCThreads CPU核心数 同时也是虚拟线程载体线程池的大小 IO密集型可适当调大,如 2*CPU
-XX:MaxDirectMemorySize 等于堆大小 NIO 直接内存大小 高并发IO场景适当调大,避免OOM
-XX:VirtualThreadStackSize 0(自适应) 虚拟线程初始栈大小 一般保持默认,深度递归可适当调大
-XX:+UseVThreadSchedulerPinning true 是否允许虚拟线程固定在载体上 保持默认,减少不必要的挂载卸载
-XX:ReservedCodeCacheSize 240M JIT 代码缓存 大应用建议调到 512M,避免编译降级
-XX:+UseZGC 关闭 启用 ZGC 垃圾回收器 大堆+虚拟线程强烈推荐,低延迟
-XX:+ZGenerational 关闭 启用分代 ZGC Java 21+ 推荐开启,提升吞吐量

⚙️ 生产推荐配置示例

8 核 16G 机器 IO 密集型服务推荐配置

java -Xms8g -Xmx8g \
     -XX:+UseZGC \
     -XX:+ZGenerational \
     -XX:ParallelGCThreads=16 \
     -XX:MaxDirectMemorySize=2g \
     -XX:ReservedCodeCacheSize=512m \
     -jar your-app.jar

🔍 诊断与监控参数

开启虚拟线程日志

-Djdk.virtualThreadScheduler.parallelism=16
-Djdk.virtualThreadScheduler.maxPoolSize=256
-XX:+TraceVirtualThreads

常用诊断命令

# 查看虚拟线程数量
jcmd  Thread.print | grep -c Virtual

# 查看载体线程池状态
jcmd  VM.info

# 持续观察线程状态
watch -n 1 'jstack  | grep state'

⚠️ 调优注意事项

  • • 载体线程数不是越大越好,过多会导致频繁上下文切换
  • • 虚拟线程栈默认很小,深度递归要注意 StackOverflowError
  • • 开启虚拟线程后,堆内存占用会上升(栈放堆里了)
  • • 配合 ZGC 使用效果最佳,避免 GC 停顿影响并发
  • • 优先优化业务逻辑和 IO 性能,参数调优是锦上添花

📌 知识体系总结

底层原理

M:N 调度模型 + 载体线程 + 分段栈 + 用户态切换

核心价值

IO密集场景下,极低内存成本获得极高并发量

最佳实践

每个任务新建,配合结构化并发,用ReentrantLock

调优方向

载体线程数 + ZGC + 直接内存 + 代码缓存

Java 21 虚拟线程 · 原理+实践+调优 漫画图解 | 从入门到生产落地