虚拟线程阻塞时由JVM自动挂起并释放载体线程,依赖Continuation机制保存/恢复上下文,不暴露API;纯CPU循环不触发挂起,可能导致载体线程饥饿。

虚拟线程在阻塞时不会真“卡住”载体线程,而是靠 JVM 的 Continuation 机制做状态快照和恢复——这不是用户代码能直接调用的 API,但理解它的行为模型,能帮你避开调度陷阱和调试盲区。
Continuation 不是 Kotlin 的 suspend,也不是你能 new 出来的对象
Continuation 在 Project Loom 中是 JVM 内部原语,不暴露为 public API。它不像 Kotlin 协程里那个 Continuation<T> 接口,你无法在 Java 代码中声明、实现或手动 resume 它。JVM 在字节码层面自动插入挂起点(比如进入 Thread.sleep()、Object.wait()、文件读写、网络 socket 阻塞等),并在这些点触发 Continuation 的捕获与还原。
常见误解是以为“加了 suspend 就能进虚拟线程”,但 Java 虚拟线程根本不认 suspend 关键字——那是 Kotlin 编译器干的事。Java 侧的挂起完全由 JVM 对阻塞调用的识别驱动。
- 你写的
Thread.sleep(1000)在虚拟线程里会触发挂起,但在平台线程里就是真睡 1 秒 -
System.in.read()、Files.readString(path)(未用 NIO)这类同步 I/O 同样会被拦截并挂起虚拟线程 - 但
while(true) { i++; }这类纯 CPU 循环不会触发挂起,虚拟线程会一直霸占载体线程,造成饥饿
阻塞时的状态机:RUNNABLE → PARKED → UNPARKED → RUNNABLE
虚拟线程的生命周期状态不是传统 Thread.State 枚举能完整描述的。JVM 内部用更细粒度的状态跟踪 Continuation 行为:
- 当执行到阻塞点,JVM 把当前栈帧、局部变量、PC 计数器等打包进
Continuation实例,线程状态对外仍可能是RUNNABLE(这是故意设计,保持 API 兼容) - 底层载体线程被释放,去拉取下一个就绪的虚拟线程任务
- 阻塞条件满足后(如 sleep 超时、IO 数据到达),JVM 找到对应
Continuation,把上下文恢复到载体线程栈上,继续执行后续字节码
所以你看不到 PARKED 状态出现在 Thread.getState() 返回值里——那是平台线程的状态,不是虚拟线程的。真正反映挂起行为的,是 Thread.currentThread().isVirtual() 为 true,且该线程正在执行阻塞调用。
怎么验证挂起是否生效?别只看线程数,要看载体线程利用率
光看 jstack 或 JFR 里虚拟线程数量暴涨,不能说明挂起机制在工作。关键指标是载体线程(Carrier Thread)是否真的被复用:
- 启动时加
-XX:+UnlockExperimentalVMOptions -XX:+UseLoom(Java 21+ 默认启用,但显式确认更稳) - 用 JFR 录制,筛选事件
jdk.VirtualThreadPinned:出现这个事件,说明某个虚拟线程因执行了禁止挂起的操作(如 native 方法、synchronized 块内阻塞)而“钉住”了载体线程,这是典型调度失效信号 - 监控
jdk.CarrierThreadStart和jdk.CarrierThreadEnd,正常情况下载体线程数应远小于虚拟线程峰值(比如 10 万虚拟线程只启了 200 个载体线程)
如果你发现载体线程数接近虚拟线程数,或者 jdk.VirtualThreadPinned 频繁出现,说明有阻塞操作逃逸了 Continuation 挂起机制——这时候得检查是否用了自定义 native 库、是否在 synchronized 块里做 I/O、或者用了老版本 NIO Channel(未适配 Loom)。
Continuation 的挂起不是魔法,它依赖 JVM 对阻塞点的精确识别和字节码重写。一旦你写的代码绕过了 JVM 的监控路径(比如通过反射调用阻塞方法、或用 Unsafe 操作内存锁),挂起就会失效——这种边界情况很难 debug,但恰恰是线上高并发系统出问题最隐蔽的地方。

















