Carrier Thread是JVM复用的平台线程(即OS线程),负责动态挂载/卸载虚拟线程执行;虚拟线程仅在运行或阻塞时短暂绑定carrier,执行完毕或遇I/O即自动解绑,由JVM在用户态完成上下文切换,实现高效复用。

Carrier Thread 是什么,它和 Virtual Thread 怎么绑定又解耦
Carrier Thread 就是 JVM 用来“驮着”虚拟线程跑的普通平台线程(PlatformThread),本质就是 OS 线程。它不归你管,由 JVM 自动从 ForkJoinPool.commonPool() 或你传入的 ExecutorService 中调度复用。虚拟线程(VirtualThread)本身没有独立的内核栈,也不独占 OS 资源——它只在需要执行时被挂载(mounted)到某个 carrier 上,执行完或遇到阻塞就立刻卸载(unmounted),让 carrier 去跑别的虚拟线程。
这种“挂载/卸载”不是你手动控制的,而是 JVM 在识别到可挂起点(如 Thread.sleep()、SocketChannel.read()、Object.wait())时自动触发的。关键点在于:挂载状态变化不改变 carrier 的生命周期,也不影响其他虚拟线程对它的竞争使用。
为什么 Thread.sleep(1000) 在虚拟线程里不卡住 carrier
因为 JVM 把 Thread.sleep() 识别为标准挂起点。当虚拟线程执行到这行时,JVM 会:
- 保存当前虚拟线程的栈帧和局部变量(即
Continuation快照) - 把 carrier 线程从该虚拟线程上解绑,并立即从调度队列中取出下一个就绪的虚拟线程
- 将 carrier 的栈指针切换过去,继续执行
整个过程发生在用户态,无内核介入,耗时约 0.1–1μs。而传统线程调用 Thread.sleep() 会直接让 OS 挂起整个线程,carrier 被白白闲置 1 秒。
注意:System.in.read() 这类 BIO 阻塞调用不会触发挂起,仍会独占 carrier——必须改用 AsynchronousFileChannel 或封装为 NIO 才能释放。
如何验证 carrier 是否真的被复用
最直接的方式是观察实际 OS 线程数与虚拟线程数的差距:
- 启动参数加
-XX:+PrintGC并发压测,用jcmd查VM.native_memory summary Internal (reserved=xxxKB, committed=yyyKB)下的线程内存总量,再对比jstack得到的总线程数——前者远小于后者,说明 carrier 复用生效| grep "java.lang.Thread" | wc -l - 在虚拟线程中打印
Thread.currentThread().getName(),你会发现多个虚拟线程共享相同名字(如ForkJoinPool-1-worker-3),这就是 carrier 标识 - 调用
((VirtualThread) Thread.currentThread()).isMounted()可判断当前是否已挂载;返回false表示处于 parked 状态,carrier 已被交出
常见误解:以为 Executors.newVirtualThreadPerTaskExecutor() 创建了“新线程”
这个方法返回的 ExecutorService 并不创建任何 OS 线程,它只是个调度门面。真正干活的是背后默认的 ForkJoinPool.commonPool()(或你指定的 carrier 提供者)。每次 submit(),JVM 都分配一个新虚拟线程对象,但它的执行完全依赖于池中有限的 carrier 资源。
容易踩的坑:
- 误在虚拟线程中长期持有
ThreadLocal值——carrier 被复用后,旧值可能污染新虚拟线程,应改用ScopedValue - 在
synchronized块里做 I/O——锁不会因虚拟线程挂起而释放,carrier 会被卡死,等同于传统线程阻塞 - 用
Thread.currentThread().getId()当唯一标识——虚拟线程 ID 是临时生成的,且不同挂载周期 ID 可能重复
carrier 复用不是魔法,它依赖 JVM 对阻塞点的精准识别和调度器的低延迟响应。一旦你用了未适配的 JNI、BIO 或粗粒度锁,复用链就会断裂,退化成平台线程式排队等待。










