乐于分享
好东西不私藏

深挖Spring Boot虚拟线程源码:终于搞懂M:N调度的底层魔术!

深挖Spring Boot虚拟线程源码:终于搞懂M:N调度的底层魔术!

大家好,我是你们的技术博主,上一篇分享Spring Boot集成虚拟线程的实操教程后,后台炸了!好多小伙伴留言:

“博主,一行配置就能启用虚拟线程,底层到底在做什么?”

“M:N调度说的是多虚拟线程映射少载体线程,Spring Boot是怎么实现这种映射的?”

“虚拟线程阻塞时,载体线程怎么被释放、怎么复用的?源码里藏着答案吗?”

确实!我们上次聊了“怎么用”,但作为后端开发者,只知其然可不够,知其所以然才能真正吃透虚拟线程,面试时也能轻松拿捏面试官。

今天这篇文章,就带大家“钻”进Spring Boot和JDK的源码里,不用复杂的底层知识铺垫,用大白话+核心源码片段,一步步拆解虚拟线程M:N调度的底层逻辑——从Spring Boot的自动配置,到JVM的调度核心,每一步都讲得明明白白,新手也能看懂!

先提前划重点:Spring Boot的虚拟线程M:N调度,核心是「SpringBoot自动配置+JDK原生调度」的结合——Spring Boot负责“搭桥”(将虚拟线程接入Web、异步任务等场景),JDK负责“干活”(实现虚拟线程与载体线程的M:N映射、调度、挂载与卸载)。

一、先回顾:M:N调度到底是什么?(极简铺垫)

怕有小伙伴忘记,先花30秒快速回顾核心:

传统平台线程是「1:1调度」:1个Java线程(平台线程)直接绑定1个操作系统内核线程,调度权完全交给操作系统,一旦线程阻塞,内核线程也会跟着阻塞,浪费CPU资源。

虚拟线程是「M:N调度」:M个虚拟线程(轻量级,JVM管理)映射到N个载体线程(本质是平台线程,数量远小于M),调度权由JVM和操作系统共同掌控。当某个虚拟线程阻塞时,JVM会把它从载体线程上“卸下来”,释放载体线程去执行其他就绪的虚拟线程,实现资源最大化利用。

类比一下:载体线程是写字楼的10部电梯(数量固定),虚拟线程是1000名要上班的员工。1:1调度是“一人一部电梯”,电梯跟着人等;M:N调度是“电梯循环接人”,人等电梯时,电梯去接其他人,效率直接拉满 ✅

而我们今天要挖的,就是Spring Boot如何“安排”这10部电梯,让1000名员工高效通行——也就是源码层面的M:N调度实现。

二、Spring Boot的“搭桥”操作:虚拟线程自动配置源码解析

我们上次用的「一行配置启用虚拟线程」(spring.threads.virtual.enabled=true),背后其实是Spring Boot的自动配置在默默干活。它的核心作用,是把JDK的虚拟线程,无缝接入到Spring Boot的Web容器(Tomcat)、异步任务等场景中,为M:N调度铺路。

先看Spring Boot 4.0的核心自动配置类源码(精简关键代码,无关代码已删除,可直接看懂):


// Spring Boot 自动配置类:VirtualThreadsAutoConfiguration
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "spring.threads.virtual", name = "enabled", havingValue = "true")
@ConditionalOnJava(JavaVersion.EIGHTEEN_OR_NEWER) // 要求JDK18+(推荐21+)
public class VirtualThreadsAutoConfiguration {

    // 1. 配置虚拟线程执行器(核心,承接JDK的虚拟线程)
    @Bean
    @ConditionalOnMissingBean
    public TaskExecutor virtualThreadTaskExecutor() {
        // 直接使用Spring封装的虚拟线程执行器
        // 底层调用JDK的Executors.newVirtualThreadPerTaskExecutor()
        return new VirtualThreadTaskExecutor();
    }

    // 2. 适配Tomcat容器,让Tomcat用虚拟线程处理请求
    @Bean
    public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcatProtocolHandlerCustomizer() {
        return protocolHandler -> {
            // 给Tomcat设置虚拟线程执行器
            // 这样Tomcat的请求处理线程,就变成了虚拟线程
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }

    // 3. 适配异步任务,让@Async默认使用虚拟线程
    @Bean
    public TaskExecutionAutoConfiguration.TaskExecutionCustomizer virtualThreadTaskExecutionCustomizer() {
        return (taskExecution -> {
            taskExecution.setVirtualThreads(true); // 开启异步任务的虚拟线程支持
        });
    }
}

这3个核心Bean,就是Spring Boot的“搭桥关键”,我们逐个拆解(大白话翻译):

  1. virtualThreadTaskExecutor:创建虚拟线程执行器,本质是Spring对JDK虚拟线程工具类的封装,负责创建和管理虚拟线程,是M:N调度的“入口”。

  2. virtualThreadTomcatProtocolHandlerCustomizer:给Tomcat设置虚拟线程执行器,让Tomcat不再用传统的平台线程处理HTTP请求,而是用虚拟线程——这就是我们全局启用虚拟线程后,接口能跑在虚拟线程上的原因。

  3. virtualThreadTaskExecutionCustomizer:让@Async异步任务默认使用虚拟线程,不用手动指定执行器,简化配置。

重点:Spring Boot本身不实现M:N调度,它只是“中间商”,把JDK的虚拟线程能力,无缝对接到底层容器和业务场景中;真正的M:N调度核心,藏在JDK的源码里。

三、核心拆解:JDK底层M:N调度的源码实现(重中之重)

M:N调度的核心逻辑,全在JDK 21的java.lang.VirtualThreadjava.util.concurrent.ForkJoinPool两个类里——前者是虚拟线程的“实体”,后者是载体线程池(负责调度虚拟线程)。

我们分3步拆解,每一步都配核心源码,不用深究所有细节,抓住关键逻辑即可。

第一步:载体线程池(ForkJoinPool)——M:N调度的“电梯调度中心”

载体线程(Carrier Thread)本质就是传统的平台线程,它们被维护在ForkJoinPool中,默认数量等于CPU核心数(比如8核CPU,默认8个载体线程)——这就是“N”的来源。

看ForkJoinPool的核心源码(精简,重点看调度逻辑):


// JDK 21 ForkJoinPool 核心源码(精简)
public class ForkJoinPool extends AbstractExecutorService {
    // 载体线程数组(N个载体线程)
    private final ForkJoinWorkerThread[] workers;
    // 虚拟线程就绪队列(存放等待执行的虚拟线程)
    private final WorkQueue[] workQueues;

    // 提交虚拟线程任务(Spring Boot就是调用这个方法提交虚拟线程)
    public void execute(Runnable task) {
        if (task == null) throw new NullPointerException();
        // 核心:将虚拟线程任务提交到就绪队列
        externalPush(task);
    }

    // 载体线程执行任务的核心方法
    final void runWorker(ForkJoinWorkerThread w) {
        Runnable task;
        // 循环从就绪队列中取虚拟线程任务执行
        while ((task = w.pollTask()) != null) {
            // 执行虚拟线程任务(关键:挂载虚拟线程到载体线程)
            task.run();
        }
    }
}

大白话解读:ForkJoinPool就像“电梯调度中心”,workers数组是10部电梯(载体线程),workQueues是等待坐电梯的员工队列(就绪的虚拟线程)。调度中心的逻辑很简单:让电梯(载体线程)循环从队列里接员工(虚拟线程),接一个执行一个。

这里就完成了M:N调度的第一步:将大量虚拟线程(M)放入就绪队列,由少量载体线程(N)循环执行

第二步:虚拟线程(VirtualThread)——“员工”的核心逻辑(挂载与卸载)

虚拟线程的核心,是“可挂载、可卸载”——这也是M:N调度能实现高效复用的关键。当虚拟线程执行阻塞操作(比如数据库查询、Thread.sleep)时,会被从载体线程上卸载,释放载体线程;阻塞结束后,再重新挂载到空闲的载体线程上继续执行。

看VirtualThread的核心源码(精简,重点看挂载/卸载逻辑):


// JDK 21 VirtualThread 核心源码(精简)
final class VirtualThread extends Thread {
    // 关联的载体线程(当前“承载”这个虚拟线程的电梯)
    private volatile Thread carrier;
    // 续体(Continuation):保存虚拟线程的执行状态(比如执行到哪一行代码)
    private final Continuation cont;
    // 调度器(就是我们上面说的ForkJoinPool)
    private final Executor scheduler;

    // 虚拟线程执行的核心方法
    @Override
    public void run() {
        // 1. 将当前虚拟线程挂载到载体线程上
        this.carrier = Thread.currentThread();
        try {
            // 2. 执行虚拟线程的任务(通过续体恢复执行状态)
            cont.run();
        } finally {
            // 3. 任务执行完,卸载虚拟线程(释放载体线程)
            this.carrier = null;
        }
    }

    // 虚拟线程阻塞时,触发卸载的核心方法
    @Override
    void park() {
        if (carrier != null) {
            // 标记虚拟线程为挂起状态
            setState(PARKED);
            // 关键:释放载体线程(让载体线程去执行其他虚拟线程)
            this.carrier = null;
            // 保存当前执行状态,卸载虚拟线程
            cont.yield();
            // 将虚拟线程重新放入就绪队列,等待再次挂载
            scheduler.execute(this);
        }
    }
}

这段源码是M:N调度的“灵魂”,我们用电梯类比拆解:

  1. 挂载(run方法):员工(虚拟线程)坐上电梯(载体线程),电梯记录当前员工(carrier = 载体线程),然后员工开始“执行任务”(cont.run())。

  2. 卸载(park方法):员工(虚拟线程)遇到“阻塞”(比如中途要去取东西),就主动下电梯(carrier = null),并保存自己的“执行进度”(cont.yield()),然后回到等待队列(scheduler.execute(this)),等待下一部空闲电梯。

  3. 重新挂载:当员工“阻塞结束”(取完东西),调度中心(ForkJoinPool)会再分配一部空闲电梯(载体线程),让员工重新坐上电梯,继续执行剩下的任务(通过cont恢复之前的进度)。

这里要注意一个关键:虚拟线程的阻塞,不会导致载体线程阻塞——这就是M:N调度比1:1调度高效的核心原因!传统线程阻塞时,载体线程(内核线程)也会跟着阻塞,而虚拟线程阻塞时,载体线程会被释放,去处理其他虚拟线程。

第三步:Spring Boot与JDK的联动——完成M:N调度闭环

结合前面的源码,我们用一个完整的流程,串起Spring Boot+JDK的M:N调度闭环(以Web接口为例):

  1. 我们配置spring.threads.virtual.enabled=true,Spring Boot的VirtualThreadsAutoConfiguration自动生效,创建虚拟线程执行器,并给Tomcat设置虚拟线程执行器。

  2. 客户端发送HTTP请求,Tomcat接收请求后,通过虚拟线程执行器创建一个虚拟线程(M中的一个),并将请求处理任务提交到ForkJoinPool(调度中心)。

  3. ForkJoinPool中的载体线程(N中的一个)从就绪队列中取出这个虚拟线程,将其挂载(carrier = 载体线程),执行请求处理逻辑。

  4. 如果请求处理中遇到IO阻塞(比如查询数据库),虚拟线程调用park()方法,卸载自己,释放载体线程;载体线程回到调度中心,继续从队列中取其他虚拟线程执行。

  5. IO阻塞结束后,虚拟线程被重新提交到ForkJoinPool的就绪队列,等待空闲的载体线程,重新挂载后继续执行剩余逻辑,直到请求处理完成。

整个过程,Spring Boot负责“发起请求、创建虚拟线程”,JDK负责“调度载体线程、实现虚拟线程的挂载与卸载”,二者配合,完美实现了M:N调度。

四、关键细节:2个源码里的“隐藏知识点”(面试常考)

结合源码,补充2个高频面试考点,帮大家多拿几分:

知识点1:载体线程的数量怎么确定?

源码中,ForkJoinPool的载体线程数量默认等于CPU核心数(Runtime.getRuntime().availableProcessors()),比如8核CPU默认8个载体线程。

为什么不设置更多?因为载体线程本质是平台线程,过多会增加操作系统的调度开销,反而降低效率——M:N调度的核心就是“用少量载体线程,承载大量虚拟线程”,数量和CPU核心数匹配,能最大化利用CPU资源。

知识点2:虚拟线程为什么不能用synchronized?

看VirtualThread的源码注释(精简):


// 注释翻译:如果虚拟线程持有synchronized锁,会被固定在当前载体线程上,无法卸载
// 原因:synchronized是内核级锁,虚拟线程持有该锁时,JVM无法将其卸载
if (holdsLock()) {
    // 固定虚拟线程到载体线程,无法卸载
    pin();
}

大白话解读:如果虚拟线程用了synchronized,就会被“钉死”在当前载体线程上,即使发生IO阻塞,也无法卸载,载体线程会跟着阻塞——这就违背了M:N调度的初衷,相当于又回到了1:1调度的问题。

解决方案:用ReentrantLock替代synchronized,ReentrantLock是用户级锁,不会导致虚拟线程被固定,不影响卸载和复用。

五、总结:源码层面的M:N调度核心(一句话吃透)

其实Spring Boot虚拟线程的M:N调度,本质就3句话,看完就能记住:

  1. Spring Boot负责“搭桥”:通过自动配置,将JDK虚拟线程接入Tomcat、异步任务等场景,创建虚拟线程执行器。

  2. JDK负责“调度”:ForkJoinPool维护少量载体线程(N),虚拟线程(M)放入就绪队列,载体线程循环执行虚拟线程。

  3. 核心是“可卸载”:虚拟线程阻塞时,从载体线程上卸载,释放载体线程复用;阻塞结束后,重新挂载继续执行,实现高效M:N映射。

看到这里,是不是突然恍然大悟?原来一行配置的背后,是Spring Boot和JDK的完美配合,而M:N调度的核心,就是“虚拟线程的可挂载、可卸载”+“载体线程的循环复用”。

其实源码并没有我们想象的那么晦涩,只要抓住“核心逻辑”,避开无关细节,就能轻松看懂——这也是我们作为后端开发者,提升自身能力的关键:多问一个“为什么”,多钻一层源码,才能真正掌握技术。

如果觉得这篇文章对你有帮助,欢迎点赞、在看、转发,关注我,带你解锁更多Spring Boot源码拆解技巧~

下期预告:虚拟线程面试高频题汇总,结合今天的源码,帮你轻松应对面试官的连环追问!