找回密码
 立即注册
首页 业界区 业界 Java虚拟线程详解

Java虚拟线程详解

榕闹 昨天 11:40
引言

依稀还记得2016年开始学Java的场景,时光的距离是如此简短,十年时间仿佛隔桌而坐。刚学java时用的还是jdk1.6(jdk6),到现在最新的jdk版本已经是29了,在java圈子里有一个诙谐的说法来形容jdk的快速迭代,“新版任你发,我用java8,你升随你升,我用java8”,这既是玩笑,也是真实写照。就我个人而言,使用的最多的也是jdk8,在22年后新项目都升级到了jdk17,今年的一个项目又开始使用jdk21,而jdk21的一个重要特性,便是正式引入了虚拟线程(Virtual Thread)。
为什么要引入虚拟线程?

在jdk21之前,Java 的线程模型(平台线程 Platform Thread)是一对一映射到操作系统线程(OS Thread)的。这带来了几个根本性问题:
OS 线程的代价很昂贵:

  • 每个 OS 线程默认栈大小约 512KB~1MB
  • 线程创建/销毁涉及系统调用,开销大
  • 上下文切换(context switch)由 OS 调度,成本高
  • 一台普通服务器能稳定支撑的 OS 线程数通常只有几千个
这也是网关、RPC、HTTP服务这类IO密集型服务的核心性能瓶颈。
在jdk21中如何创建线程:
  1. //创建平台线程Thread.ofPlatform().start(() -> System.out.println("platform thread"));//传统方式 - 创建平台线程Thread t1 = new Thread(() -> System.out.println("platform thread"));t1.start();//创建虚拟线程Thread vt = Thread.ofVirtual().start(() -> System.out.println("virtual thread"));
复制代码
虚拟线程的原理

结合Linux中的内核线程(Kernel Thread)和用户线程(User Thread)的关系更容易理解虚拟线程的原理。
Linux 中线程分两个层面:
内核线程(Kernel Thread): 由 OS 内核管理,真正在 CPU 上执行,是 OS 调度的基本单位。
用户线程(User Thread): 在用户空间实现,内核不感知,由用户空间的线程库管理调度。
两者的映射关系历史上有三种模型:

  • 1:1 模型:一个用户线程对应一个内核线程(Linux NPTL,现代Linux的默认实现)
  • N:1 模型:所有用户线程映射到一个内核线程(内核完全不感知多线程)
  • M:N 模型:M个用户线程映射到N个内核线程(两级调度)
jdk21虚拟线程与平台线程的关系与 M:N 模型 类似:
Linux M:N 模型Java 虚拟线程用户线程(M个)≈虚拟线程(M个)内核线程(N个)≈平台线程/载体线程(N个)用户空间调度器≈JVM调度器(ForkJoinPool)内核调度器≈OS调度器核心思想都是用少量的“重”线程驱动大量的“轻”线程,调度器把对应的内核线程/载体线程切换去执行其他任务,避免资源浪费。
阻塞时的处理思路也相同:用户线程/虚拟线程阻塞时,调度器把对应的内核线程/载体线程切换去执行其他任务,避免资源浪费。
但是两者又有着重要区别:

  • 内核感知程度不同
  1. Linux 用户线程(N:1或M:N):内核完全不感知用户线程的存在                            内核只看到内核线程,不知道上面跑了多少用户线程Java 虚拟线程:载体线程是真实的 OS 线程,内核完全感知载体线程              虚拟线程本身确实对内核不可见,但载体线程对内核完全可见
复制代码

  • 阻塞处理机制不同
这是最关键的区别:
  1. Linux 用户线程阻塞时的问题:用户线程发起系统调用(如 read())→ 内核线程被阻塞→ 整个内核线程卡住→ 该内核线程上的所有用户线程都无法执行→ 这是 N:1 模型的致命缺陷Java 虚拟线程阻塞时:虚拟线程发起 I/O → JVM 拦截,改写为异步 I/O→ 虚拟线程从载体线程卸载→ 载体线程立刻去执行其他虚拟线程→ I/O 完成后虚拟线程重新挂载
复制代码
Java 虚拟线程能解决阻塞问题,根本原因是 JVM 在底层把阻塞式 I/O 替换成了异步 I/O(基于 Linux 的 io_uring 或 epoll),这是 Linux 原始用户线程库做不到的事情。

  • 历史背景不同
Linux 的 M:N 模型(如早期的 NGPT)最终被放弃了,现代 Linux 默认使用 1:1 模型(NPTL),原因是 M:N 实现复杂、调试困难、性能不稳定。
Java 虚拟线程建立在现代 OS 已经非常成熟的异步 I/O 基础上,站在了更高的起点,规避了当年 M:N 模型的主要问题。

  • 调度器位置不同
  1. Linux M:N:调度器在用户空间的线程库中(如 libpthread)Java 虚拟线程:调度器在 JVM 内部(ForkJoinPool),且 JVM 可以感知               所有的 I/O 操作,在恰当时机主动触发卸载
复制代码
java 虚拟线程与 Linux M:N 用户线程是同一思想的不同实现。核心区别在于:Java 虚拟线程依托 JVM 对 I/O 的全面拦截和异步化改造,真正解决了阻塞穿透问题,而这正是当初 Linux M:N 模型最终失败的根本原因。
用下图可以总结:
  1. Linux 历史模型:N:1  用户线程A ──┐     用户线程B ──┼──→ 内核线程1        ← 一个阻塞全部卡死     用户线程C ──┘M:N  用户线程A ──→ 内核线程1     用户线程B ──→ 内核线程2          ← 理想但实现复杂,已被放弃     用户线程C ──┘1:1  用户线程A ──→ 内核线程A          ← 现代Linux默认,简单但线程数受限     用户线程B ──→ 内核线程BJava 虚拟线程(本质是改良的M:N):     虚拟线程A ──→ 载体线程1(OS线程)     虚拟线程B ──┘  ↑ 阻塞时自动卸载,靠JVM拦截I/O实现     虚拟线程C ──→ 载体线程2(OS线程)     虚拟线程D ──┘
复制代码
虚拟线程背后的载体线程是什么?

虚拟线程的载体线程其实也就是平台线程(Platform Thread),平台线程对应真实的 OS 线程。
使用如下代码测试:
  1.      private static Thread getCarrierThread(Thread virtualThread) throws Exception {            // Java 21 中 VirtualThread 内部有 carrierThread 字段            Class vtClass = Class.forName("java.lang.VirtualThread");            Field carrierField = vtClass.getDeclaredField("carrierThread");            carrierField.setAccessible(true);            return (Thread) carrierField.get(virtualThread);     }     private static void createVirtualThread() {            Thread vt = Thread.ofVirtual().start(() -> {            try {                Thread carrier = getCarrierThread(Thread.currentThread());                System.out.println("虚拟线程: " + Thread.currentThread());                System.out.println("载体线程: " + carrier);            } catch (Exception e) {                e.printStackTrace();            }    });
复制代码
通过main方法启动执行createVirtualThread,vm参数中增加--add-opens java.base/java.lang=ALL-UNNAMED,控制台输出日志如下:
  1. 虚拟线程: VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1载体线程: Thread[#22,ForkJoinPool-1-worker-1,5,CarrierThreads]
复制代码
可以看出载体线程实际上是ForkJoinPool-1-worker-线程池中的线程1,疯狂创建100个虚拟线程并打印:
  1. for (int i = 0; i < 100; i++) {    int finalI = i;    Thread vt2 = Thread.ofVirtual().start(() -> {        try {            Thread carrier = getCarrierThread(Thread.currentThread());            System.out.println("虚拟线程 " + finalI + " : " + Thread.currentThread());            System.out.println("载体线程 " + finalI + " : " + carrier);        } catch (Exception e) {            e.printStackTrace();        }    });}
复制代码
输出结果如下:
  1. 虚拟线程 0 : VirtualThread[#27]/runnable@ForkJoinPool-1-worker-2载体线程 0 : Thread[#24,ForkJoinPool-1-worker-2,5,CarrierThreads]虚拟线程 2 : VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3载体线程 2 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]虚拟线程 6 : VirtualThread[#33]/runnable@ForkJoinPool-1-worker-3虚拟线程 7 : VirtualThread[#34]/runnable@ForkJoinPool-1-worker-3载体线程 7 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]虚拟线程 8 : VirtualThread[#35]/runnable@ForkJoinPool-1-worker-3载体线程 8 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]虚拟线程 9 : VirtualThread[#36]/runnable@ForkJoinPool-1-worker-3载体线程 9 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]虚拟线程 10 : VirtualThread[#37]/runnable@ForkJoinPool-1-worker-3载体线程 10 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]虚拟线程 11 : VirtualThread[#38]/runnable@ForkJoinPool-1-worker-3载体线程 11 : Thread[#26,ForkJoinPool-1-worker-3,5,CarrierThreads]...
复制代码
可以确认虚拟线程默认使用的就是ForkJoinPool的一个线程池实例中的创建的线程。
对java stream 并行流比较熟悉的同学会意识到:这会与stream并行流所使用的线程池有线程复用吗?
答案是不会,实际是两个ForkJoinPool实例。
如下图所示,stream并行流使用的线程池是ForkJoinPool.commonPool-worker-

使用虚拟线程应该注意什么?

虚拟线程解决的是 I/O 等待造成的线程资源浪费问题,让你用简单的同步代码写出高并发程序。
什么情况下适合使用虚拟线程

核心判断标准:任务是 I/O 密集型(线程大量时间在等待)

  • 高并发 Web 服务:每个 HTTP 请求一个虚拟线程,支撑万级并发
  • 数据库访问:大量 JDBC 查询等待数据库响应
  • 微服务间 HTTP 调用:调用下游服务时的网络等待
  • 文件 I/O 操作:读写磁盘的等待时间
  • 消息队列消费:等待消息的长轮询
  • 需要从异步代码迁回同步代码:将 reactive 代码简化重写
简单说:线程的大部分时间在 wait / 阻塞,而不是在跑 CPU,就适合用虚拟线程。
什么情况下不适合使用虚拟线程


  • CPU 密集型任务
    如图像处理、加密计算、机器学习推理、大量数学计算。虚拟线程不会让 CPU 跑得更快,瓶颈是 CPU 核心数,不是线程数。这种场景用 ForkJoinPool 或固定大小线程池反而更合适。
  • 线程数量本来就很少的场景
    如果你的应用本来只需要几十个线程,传统线程完全够用,引入虚拟线程没有收益。
  • 存在 synchronized 持有锁时发生阻塞(Pinning 问题)
  1. // ❌ 危险:synchronized 块内有 I/O 阻塞 → pinningsynchronized (lock) {    result = jdbcConnection.query(...); // 虚拟线程被 pin 住}// ✅ 改用 ReentrantLockprivate final ReentrantLock lock = new ReentrantLock();lock.lock();try {    result = jdbcConnection.query(...); // 虚拟线程可以正常卸载} finally {    lock.unlock();}
复制代码
这是虚拟线程目前最重要的陷阱。当虚拟线程在 synchronized 块内部发生阻塞,它无法从 carrier thread 卸载(称为 pinning,jdk24中已经解决),退化成平台线程的行为,甚至可能造成 carrier thread 耗尽。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册