Java的虚拟线程

传统线程机制的缺点

在Java,线程是程序执行的基本单位,也是最小单位。每条java.lang.Thread都对应着操作系统上的一条线程。在Java推出虚拟线程之前,Java的线程机制存在一些问题:

**(1)线程创建成本高昂,数量严重受限。**由于每条Thread都对应着一条真实的操作系统的线程,所以Thread的创建、切换、销毁等都依赖操作系统,属于操作系统级的开销,而且创建的数量上限也受操作系统的限制(通常几千到几万条),对于服务器端这种高并发的场景显得很微不足道。

(2)阻塞操作导致资源浪费。 很典型的场景就是当线程执行 I/O 操作(如数据库查询、HTTP 调用、文件读写、Thread.sleep())时,线程会进入阻塞状态,操作系统会将其调度出 CPU,并可能发生上下文切换,但是虽然让出了CPU,却仍然会继续占用内存。

**(3)催生了大量的异步框架。**为了摆脱以上两条的线程,大量异步、非阻塞框架开始出现,包括官方自己的Future,额外带来了大量的学习成本。

虚拟线程

在JDK 19上,Java提出了虚拟线程这一预览特性,并在JDK 21上正式成为标准特性。虚拟线程是由 JVM(Java虚拟机)管理的轻量级线程。它不像传统"平台线程"那样与昂贵的操作系统线程一一对应,而是一种"用户态线程。它的核心优势是 "M:N调度":只需少量OS平台线程(载体线程),就能运行海量的虚拟线程。在之前的线程机制里,一个任务只能运行在一条线程上,在遇到IO等阻塞的场景时,线程就只能阻塞住,等待阻塞条件消失后再继续运行,等于是线程白白浪费了一段时间,而且线程Thread间的切换在底层是操作系统线程之间的切换,这个开销就比较大了,而虚拟线程相当于把任务分成(JVM自动完成)了一段一段的,不同的段可以运行在不同的线程上,而一条线程也可以并发处理不同的任务和任务段,例如,任务A的a2段跑在线程T1上,遇到IO阻塞了,a2段就等待IO回应,但线程T1不再阻塞,而是马上去拿任务B的b5段执行,等a2段等到了IO回应,再跑到线程T2上继续执行。这样就可以充分利用线程资源了,而且这种切换是纯粹只在上层用户态之间切换,并不涉及到操作系统线程之间的切换,这样同样节省了切换的开销。

示例代码如下

复制代码
public class VirtualThreadPlay {
    static void main() {
        Thread vt1 = Thread.startVirtualThread(() -> {
            System.out.println("vt1 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt1 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(2000);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread vt2 = Thread.startVirtualThread(() -> {
            System.out.println("vt2 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt2 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(2000);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread vt3 = Thread.startVirtualThread(() -> {
            System.out.println("vt3 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt3 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(2000);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        Thread vt4 = Thread.startVirtualThread(() -> {
            System.out.println("vt4 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt4 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(2000);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            vt1.join();
            vt2.join();
            vt3.join();
            vt4.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("主线程执行结束");
    }
}

运行结果如下

从打印出的线程名可以看出,打印出的是虚拟线程的名字。那么,虚拟线程又是运行在了哪条载体线程(即java.lang.Thread)上呢?因为咱们并没有主动创建载体线程。载体线程由 JVM 内置的、专门负责虚拟线程调度的 默认调度器 自动创建、管理、复用!这个默认调度器,底层是一个 专用的 ForkJoinPool 线程池,载体线程全是它生成的。

再回头看下上面的运行结果,也就不难发现了:26号虚拟线程和30号虚拟线程运行在了同一条载体线程上。虚拟线程启动后,剩下的所有工作,JVM 自动完成:

(1)JVM 把这个虚拟线程交给 虚拟线程默认调度器;

(2)调度器检查:有没有空闲的载体线程

有 → 直接复用;

没有 → 调度器自动创建一条新的载体线程(平台线程 / OS 线程);

(3)调度器把虚拟线程挂载到这个载体线程上运行;

载体线程的名字:ForkJoinPool-1-worker-N → 就是调度器创建的标志。

如果把代码稍改一下,虚拟线程的运行机制就可以表现地更明显了

复制代码
public class VirtualThreadPlay {
    static void main() {
        Thread vt1 = Thread.startVirtualThread(() -> {
            System.out.println("vt1 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt1 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(1000);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Thread vt2 = Thread.startVirtualThread(() -> {
            System.out.println("vt2 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt2 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(800);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Thread vt3 = Thread.startVirtualThread(() -> {
            System.out.println("vt3 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt3 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(600);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Thread vt4 = Thread.startVirtualThread(() -> {
            System.out.println("vt4 虚拟线程执行中: " + Thread.currentThread());
            System.out.println("vt4 是否虚拟线程: " + Thread.currentThread().isVirtual());
            try {
                Thread.sleep(400);// true
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
        try {
            vt1.join();
            vt2.join();
            vt3.join();
            vt4.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("主线程执行结束");
    }
}

运行结果

由结果可知,4条虚拟线程全都跑在了同一条载体线程上,为什么会这样呢?虚拟线程vt1启动后主线程sleep 10毫秒,这就给了vt1足够的时间去执行sleep(1000),然后vt1交出cpu和载体线程,之后vt2启动并执行,此时vt1是空闲的,所以优先分配给了vt1去执行,同样由于主线程sleep 10毫秒,vt2也有足够的时间去执行到sleep(800),......依此类推,也就全都跑在了同一载体线程上了。

**阻塞到底是什么?经常会遇到说IO阻塞、网络连接阻塞等,到底阻塞了什么?**发起文件读写、发起网络连接后不是相关的逻辑都继续执行了吗?不然读取的文件数据是哪来的?怎么能说是阻塞住了呢?在宏观上,通常确实如此,比如发起一个读取文件的逻辑,发起一个网络连接的操作,咱们确实很快就能收到反馈的数据,感觉不到有任何阻塞,但是这是咱们的感受,却不是CPU微观上的感受。CPU的运行速度可是比硬盘文件读取、网络连接的速度要快得多,例如咱们发起文件读取,这个操作其实会被传递到系统内核,由内核去读取文件数据,再返回到上层给应用线程,这个过程可能需要20ms,在这20ms内上层的应用线程只能一直等着,20ms对于人来说确实丝毫感受不到,但是对于cpu来说,这算是一段很长的时间,在这段时间内线程什么也没干,一直在等着,这就是传统线程机制里的阻塞。

虚拟线程的底层原理

虚拟线程(VirtualThread):继承自Thread。

载体线程 (Carrier Thread):虚拟线程的载体,也就是一条Thread。

调度器 (Scheduler):基于 ForkJoinPool 实现,负责将就绪的虚拟线程分配给空闲的载体线程。其并行度默认等于CPU核心数。

延续 (Continuation):虚拟线程实现可挂起/恢复的核心机制,用于保存和恢复虚拟线程的执行状态(即栈帧和程序计数器)。

M:N调度模型:M个虚拟线程(JVM管理)映射到N个载体线程(OS线程)。

每条虚拟线程并不直接绑定一个操作系统线程,而是把自身的执行过程封装成一个 Continuation(可暂停/恢复的执行体,它的StackChunk里面包含栈)。当代码运行时,虚拟线程的栈(即存于Continuation的栈)会被复制到载体线程的栈上,然后载体线程(Carrier Thread)执行;一旦遇到阻塞操作(如 I/O、sleep),JVM 会在底层调用类似 Continuation.yield() 的机制,把当前方法调用栈从线程栈"拷贝/冻结"到堆中的 Continuation的StackChunk 结构里(也就是把执行现场保存下来),载体线程的栈回滚,然后立即释放这个载体线程去执行别的虚拟线程;等阻塞结束后,再通过调度器(基于 ForkJoinPool)把这个虚拟线程重新提交执行,JVM 再把之前保存的栈"恢复/解冻"回线程栈,从中断的位置继续运行。这样一来,线程的"阻塞"就不再占用操作系统线程,实现了用少量内核线程高效调度大量虚拟线程的效果。

既然VirtualThread也是Thread,为什么Thread对应一条操作系统线程,VirtualThread又是怎么不对应一条操作系统线程的?

Thread的start()执行了一个native方法,JVM会将Thread映射到一条实际的操作系统线程上;VirtualThread对start()进行了重写,所以没有绑定操作系统线程。

虚拟线程可以在不同载体线程上接力执行,那么会不会导致数据同步异常?

不会。虚拟线程每次暂停都会把栈里的数据保存下来(这也是Continuation存在的意义),下次执行时是在上次断点处继续执行,这些栈里的数据不存在数据不统一的情况;而对于不在栈里的数据,比如共享数据(多条虚拟线程、普通线程共享),则由Java内存模型(JMM)来保障数据安全,与普通Thread之间的数据同步没有任何区别。

虚拟线程也是Thread,而且还可以在不同载体Thread上执行,理论上就该有更多的辅助数据来实现,所以它是怎么做到更省资源的?

传统的Thread,每次创建都要涉及内核操作,还要分配栈(通常1MB左右),在JVM里属于虚拟机栈区,实际对应到内核里,这就又有了一层空间限制;Thread之间切换也涉及用户态和内核态的切换,整体开销很大,而虚拟线程则不涉及内核操作,分配的栈也是按需分配,而且是分配在堆上,可用空间更大;虚拟线程之间的切换也仅涉及用户态之间的切换,虽然确实多了一些辅助数据的开销,但是大大提高了CPU的利用率,综合来看是用少量内存的增涨大大提高了性能,可以更轻松应对高并发的场景,在一定程度上可以理解为用空间换时间。

虚拟线程有缺点吗?有不适用的场景吗?

虚拟线程不提升 CPU 密集型任务性能,因为它优化的是"等待成本",不是"计算能力",它最适用的典型场景是 IO 密集 + 高并发 + 大量阻塞等待 。 不适用的场景:
(1)synchronized 。例如

synchronized(lock) {

Thread.sleep(1000);

}

前面讲解底层原理时提到当遇到阻塞时,当前方法的调用栈会复制到虚拟线程的栈里,载体线程栈回滚,然后去执行其他的虚拟线程,但是其实还有一个条件:卸载的触发条件是 虚拟线程主动让出载体线程 ,并且整个过程中 **不能有任何"绑定"在载体线程上的、无法迁移的资源。**这条规则其实很好理解,如果有什么资源被绑定在了载体线程上,无法复制到虚拟线程的栈里保存起来,那么恢复执行时必然就要出错了。

synchronized的机制中使用了monitor (详情参考另一篇博客 Java中的synchronized和锁)。轻量级锁 在大部分无竞争情况下使用:JVM 会在当前线程的栈帧中分配一个 Lock Record,将锁对象的 mark word 复制到 lock record 中,然后通过 CAS 将锁对象的 mark word 指向该 lock record。当锁膨胀为重量级锁时,会创建一个 ObjectMonitor 对象,并将 _owner 指向当前线程。所以无论是轻量级锁还是重量级锁,synchronized 的锁持有信息都直接与当前线程的栈帧(或者线程对象本身)绑定。例如:轻量级锁的 lock record 位于线程栈上,地址依赖于载体线程的栈位置;重量级锁的 _owner 是 Thread* 指针,指向具体的 Java 线程对象。monitor(锁)和线程执行栈强绑定了, 这就导致虚拟线程的栈和锁之间无法复制、交换。所以只能退化到普通Thread那样:虚拟线程继续阻塞(专业术语叫 钉住)。这其实是锁的实现机制和虚拟线程的实现机制之间的一种冲突。但是......把锁的mark word指向虚拟线程的栈不行吗?把ObjectMonitor的_owner指向虚拟线程不行吗?确实是值得尝试的思路,不过这要涉及到synchronized的实现机制,需要研究很多更深入的东西、思考更全面的场景,为此,Java还发展出了"同步虚拟线程而不固定"的方案,我不打算再深究下去了。这些是JDK 24上解决的问题,它重构了synchronized的底层实现,在JDK 24中,synchronized 和 ReentrantLock 都能完美支持虚拟线程的高扩展性。这意味着开发者在选择同步机制时,可以只根据语义灵活选用,而无需担心性能瓶颈。

ReentrantLock为什么没有这些问题?

ReentrantLock 完全不依赖载体线程的栈地址,也不依赖操作系统线程 ID 来管理锁。

ReentrantLock 的锁状态、等待队列、所有者线程引用全部都位于 Java 堆 中,并且阻塞操作是通过 LockSupport.park() 这一可被虚拟线程拦截的 API 实现的。

(2)本地方法调用(JNI)。 涉及C/C++层面的栈帧,JVM无法安全接管其执行状态,因此虚拟线程执行本地方法时也会被钉住

**(3)ThreadLocal:内存陷阱。**虚拟线程数量可达百万级,若每个线程都携带独立的 ThreadLocal 变量,内存开销将极为巨大。某些场景下(如每个任务实例化 SimpleDateFormat),使用 ThreadLocal 的本意是为了复用昂贵对象,但在虚拟线程模型中反而可能违背设计初衷。

(4)文件I/O。 JDK 21对文件I/O的处理方式特殊------遇到文件I/O阻塞时,会临时增加平台线程来缓解阻塞,但是即使使用了虚拟线程来文件I/O,虚拟线程也会被钉住,因为文件读写的底层实现用到了synchronized。得知这一点时,我确实也挺惊讶!好在在JDK 24上由于synchronized实现了重构,该问题已解决。

(5)不要用线程池执行虚拟线程。 虚拟线程必须通过 start() 启动,而不是 run()。线程池的submit(Runnable task) 最终会调用 task.run(),而不会调用 start()。也不要像对待普通Thread那样自己写一个虚拟线程池来复用虚拟线程,因为虚拟线程的开销很小,池化可能得不偿失,不值得复用。

官方推荐的使用方法

try (var executor = Executors.newVirtualThreadPerTaskExecutor() ) {

for (int i = 0; i < 100_000; i++) {

executor.submit(() -> {

// 这里面的任务会在新的虚拟线程中执行

});

}

}

newVirtualThreadPerTaskExecutor() 使用的是专为虚拟线程设计的默认调度器。为了让调度更高效,JDK 的默认调度器已经历了演进:

JDK 21:虚拟线程首次作为正式特性发布,其默认调度器是一个基于 ForkJoinPool 但独立工作的线程池。

JDK 25 及以后 (未来公开版本):默认调度器已演进为全新的 VirtualThreadScheduler 专用组件,性能更优。

对于官方公开正式版本,只要您在代码中直接调用 Executors.newVirtualThreadPerTaskExecutor(),使用的就始终是它对应的默认内部调度器。它没有其他内置参数,也不允许手动指定用哪个默认调度器执行任务。

简单来说,虚拟线程不支持指定调度器,只需按照API(如 Executors.newVirtualThreadPerTaskExecutor() 、 Thread.startVirtualThread(runnable))来使用即可,其底层的调度器(即线程池)完全由JVM来自动管理。

相关推荐
趣魂17 天前
五种并发/异步模型整理
并发·异步
恼书:-(空寄19 天前
虚拟线程:Java 高并发编程的终局?
java·虚拟线程
鸿乃江边鸟21 天前
Nanobot 从 Channel 消息处理看python协程的使用
人工智能·ai·协程
Flying pigs~~23 天前
检索增强生成RAG项目tools_04:flask➕fastapi➕高并发
数据库·python·flask·大模型·fastapi·异步
切糕师学AI24 天前
深入浅出 协程(Coroutine):从原理到实践
高并发·协程·异步·async/await·coroutine·并发编程模型
丁劲犇1 个月前
QMetaObject的invokeMethod异步阻塞调用在MCPServer开发中的巧妙应用
qt·ai·agent·异步·阻塞·mcp·mcp server
zztfj1 个月前
C# 异步方法 async / await CancellationToken 设置任务超时并手动取消耗时处理
c#·异步
罗山仔1 个月前
【Vertx构建异步响应式reactive mybatis,mybatis-vertx-adaptor】
mybatis·orm·异步·reactive·响应式·webflux·vertx
LcGero1 个月前
Lua 协程(Coroutine):游戏里的“伪多线程”利器
游戏·lua·游戏开发·协程