导读:本期聚焦于南京网站建设创作的《如何利用 Thread.ofVirtual() 在 JDK 21 中开启虚拟线程以重构传统的一请求一线程阻塞架构》,敬请观看详情。线程池配到几百还是扛不住高并发?问题可能不在参数,而在底层的线程模型本身。JDK 21 正式转正的虚拟线程让每个请求都能拥有自己的线程,写法保持同步阻塞风格,代价却接近异步回调。本文围绕 Thread.ofVirtual() 这一入口 API,讲解虚拟线程与平台线程的区别、创建方式的几种写法、ThreadLocal 的使用陷阱,以及如何把传统的一请求一线程架构平滑迁移到虚拟线程上,包括线程池替换、阻塞点行为分析和常见坑点排查,附完整可运行的代码示例,帮助你低成本完成架构升级。

传统 Web 服务大多构建在“一请求一线程”模型上:每个 HTTP 请求占用一个工作线程,线程在等待数据库、RPC 或下游 HTTP 响应时被挂起,线程资源被白白占用。为了支撑更多并发,我们不断调大线程池、引入异步框架,代码复杂度随之飙升。JDK 21 中虚拟线程正式转正,配合 Thread.ofVirtual() 这个入口 API,可以让每个请求独占一个“线程”,同时保持最朴素的同步阻塞写法,是重构这类架构的低成本方案。

如何利用 Thread.ofVirtual() 在 JDK 21 中开启虚拟线程以重构传统的一请求一线程阻塞架构

一、为什么平台线程模型撑不住高并发

平台线程的本质是一个对操作系统线程的轻量封装,创建和销毁都要经过内核,调度也完全依赖操作系统。一个平台线程默认预留约 1MB 的栈空间,加上内核态的资源开销,单机最多创建几千个就会触碰内存和调度瓶颈。所以 Tomcat 默认 200 个工作线程、Dubbo 默认 200 个处理线程,并非随意设定,而是平台线程成本决定的合理上限。

问题在于,Web 请求的大部分时间并不是在计算,而是在等待。一次典型的下单请求,可能 80% 的时间耗在等待数据库返回、等待第三方支付接口响应上。这段时间里线程处于 BLOCKED 或 WAITING 状态,什么都做不了,却依然占着一个昂贵的平台线程名额。并发量上来后,线程池耗尽,请求开始排队,响应时间雪崩式增长。

过去解决这个问题的主流方案是异步化,比如 CompletableFuture、Reactor、Vert.x。这类方案确实能提升吞吐,但代价是回调地狱、调试困难、异常栈断裂,业务代码的可读性大幅下降。虚拟线程的思路则完全不同:不改代码风格,只改线程的实现方式。

二、Thread.ofVirtual() 的基本用法与底层原理

JDK 21 提供了三种创建虚拟线程的方式,最直接的就是 Thread.ofVirtual() 构建器。下面是一段完整的示例代码:

public class VirtualThreadDemo {

    public static void main(String[] args) throws Exception {
        // 方式一:使用构建器显式创建并启动
        Thread vt = Thread.ofVirtual()
                .name("order-worker-", 0)   // 指定线程名前缀,从 0 开始编号
                .start(() -> {
                    System.out.println(Thread.currentThread()
                            + " 处理订单任务");
                });

        // 方式二:使用 unstarted,稍后再启动
        Thread unstarted = Thread.ofVirtual()
                .name("lazy-task")
                .unstarted(() -> System.out.println("延迟启动的任务"));
        unstarted.start();

        // 方式三:工厂方式,配合 ExecutorService 使用
        try (var executor = Executors.newThreadPerTaskExecutor(
                Thread.ofVirtual().name("pool-", 0).factory())) {
            executor.submit(() -> {
                Thread.sleep(1000);  // 阻塞时虚拟线程会自动让出载体线程
                return "任务完成";
            });
        }

        vt.join();
        unstarted.join();
    }
}

虚拟线程的关键差异在于调度方式。平台线程由操作系统内核调度,而虚拟线程由 JVM 内部的 ForkJoinPool 调度,默认并行度等于 CPU 核心数。虚拟线程的栈帧存储在堆上,按需增长和收缩,一个虚拟线程初始占用只有几百字节,创建百万级虚拟线程也毫无压力。

更精妙的是“挂载与卸载”机制。当虚拟线程执行到阻塞操作(如 Thread.sleep、网络 IO、Lock.lockInterruptibly)时,JDK 会自动把它的执行状态保存到堆上,释放底层的载体线程去运行其他虚拟线程。等到阻塞结束,再找机会恢复执行。整个过程对业务代码完全透明,你写的是同步代码,得到的是异步的吞吐能力。

三、重构一请求一线程架构的落地步骤

重构的核心思路是:把原来从平台线程池借线程的逻辑,改成每个请求创建一个虚拟线程。假设原来有一段典型的阻塞式业务代码:

public class OrderService {

    private final ExecutorService pool =
            Executors.newFixedThreadPool(200);  // 传统固定线程池

    public void handleOrder(OrderRequest req) {
        pool.submit(() -> {
            try {
                // 三次阻塞调用:数据库、远程接口、消息队列
                saveToDb(req);
                callPaymentApi(req);
                sendMqMessage(req);
            } catch (Exception e) {
                log.error("订单处理失败", e);
            }
        });
    }
}

改造非常简单,只需把线程池换成虚拟线程执行器,业务代码一行都不用动:

public class OrderService {

    // 每个任务分配一个虚拟线程,替代固定线程池
    private final ExecutorService pool =
            Executors.newThreadPerTaskExecutor(
                Thread.ofVirtual().name("order-vt-", 0).factory());

    public void handleOrder(OrderRequest req) {
        pool.submit(() -> {
            try {
                saveToDb(req);        // 阻塞在 JDBC 上时会自动让出载体线程
                callPaymentApi(req);  // 使用 HttpClient 时同样友好
                sendMqMessage(req);
            } catch (Exception e) {
                log.error("订单处理失败", e);
            }
        });
    }
}

如果是 Spring Boot 3.2 及以上版本,配置更加省事,直接在配置文件中打开开关:spring.threads.virtual.enabled=true,Tomcat 的请求处理线程就会全部切换为虚拟线程,无需修改任何 Java 代码。对于自研网关或 Netty 之外的同步框架,则可以在入口处用 Thread.ofVirtual().start() 直接为每个连接派发一个虚拟线程。

迁移时要注意驱动版本的配套。JDBC 驱动需要较新版本才能在阻塞时正确挂起虚拟线程,MySQL 建议使用 8.0.33 以上,PostgreSQL 建议使用 42.6.0 以上。老版本驱动内部用 synchronized 包裹 IO 操作时,会导致虚拟线程固定在载体线程上无法卸载,这个问题下面单独说。

四、必须警惕的坑点与排查手段

第一个坑是 pinning(固定)。当虚拟线程在阻塞操作期间无法从载体线程卸载时,就称为被固定。典型触发场景有两个:一是在 synchronized 块内执行阻塞 IO,二是调用本地方法。固定本身不会导致错误,但会造成吞吐退化到接近平台线程的水平。JDK 21 中可以通过 JVM 参数 -Djdk.tracePinnedThreads=full 打印固定事件的堆栈,定位问题代码。如果热点路径确实存在 synchronized 内阻塞的情况,改用 ReentrantLock 即可解决。

public class CacheHolder {

    private final ReentrantLock lock = new ReentrantLock();
    private final Map<String, String> cache = new ConcurrentHashMap<>();

    public String get(String key) {
        // 用 ReentrantLock 替代 synchronized,避免虚拟线程 pinning
        lock.lock();
        try {
            return cache.computeIfAbsent(key, this::loadFromRemote);
        } finally {
            lock.unlock();
        }
    }

    private String loadFromRemote(String key) {
        // 远程加载,阻塞时虚拟线程可正常卸载
        return httpClient.send(key);
    }
}

第二个坑是 ThreadLocal 滥用。虚拟线程数量动辄百万,如果每个线程都在 ThreadLocal 里塞入几 MB 的缓存对象,堆内存会瞬间爆炸。JDK 21 专门提供了 ScopedValue 作为不可变上下文的替代方案,配合 StructuredTaskScope 使用效果更佳。对于必须使用的场景,务必控制对象大小并及时清理。

第三个坑是连接池容量。虚拟线程把线程瓶颈移除了,压力会转移到下游资源上。原来 200 个线程天然限制了数据库连接的并发获取,换成虚拟线程后,可能有上万个虚拟线程同时争抢连接池。因此迁移时要同步评估数据库连接池、Redis 连接池的最大连接数,避免把下游打挂。

五、总结

虚拟线程不是银弹,它解决的是“阻塞式代码在等待期间浪费线程”这一个问题,对纯计算密集型任务没有收益。但对于绝大多数 IO 密集的 Web 服务来说,Thread.ofVirtual() 提供了一条改造成本极低的路径:业务代码保持同步风格,无需引入响应式框架,即可获得接近异步的吞吐能力。建议的迁移顺序是:先用开关在测试环境启用并压测,用 tracePinnedThreads 排查固定问题,再逐步调整下游连接池参数,最后全量上线。这样既控制了风险,也能让团队在没有学习成本的前提下享受 JDK 21 带来的并发红利。

虚拟线程Thread.ofVirtualJDK 21修改时间:2026-09-03 23:05:20

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260903/49863.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。