把Java并发模型翻新:虚拟线程如何改写高并发服务吞吐量

2026-07-16 0 848

Java 21 把虚拟线程正式带进了生产环境。这件事的意义不在于多了一个 API,而在于我们一直以来用线程池硬扛高并发的思路有了替代方案。本文用一个完整的业务场景——从外部接口批量获取数据——把传统线程池和虚拟线程放在一起跑,看看差距到底有多大,以及迁移过程中哪些地方需要留神。

一、先理清虚拟线程解决了什么

传统 Java 的线程是操作系统的内核线程,一条线程大约占用 1MB 栈内存。当你的系统同时处理几千个请求时,光线程本身的内存开销就上去了,更不用说 CPU 在上下文切换上花费的时间。所以大家习惯了用线程池控制数量,把任务排进队列里等空闲线程来取。

虚拟线程绕开了这个限制。它是 JDK 自己管理的轻量级用户线程,创建和销毁的成本极低,几万甚至几十万条虚拟线程同时跑在少数几条操作系统线程上,内存占用却很小。阻塞操作发生时,底层线程会自动释放去执行其他虚拟线程,等你等的那件事就绪了,再切回来继续执行。整个切换过程对业务代码完全透明。

换句话说,虚拟线程让你可以用”为每个任务单独开一条线程”这种简单直观的写法,去处理以前必须用线程池加 Future 才能搞定的高并发场景。代码的可读性和维护成本大幅下降,而吞吐量不降反升。

二、一个典型的 I/O 密集型场景

假设我们要做一个聚合查询接口:前端传过来一个 ID 列表,后端需要逐个调用外部服务获取详情,把所有结果合并后返回。外部接口的响应时间大约在 100ms 到 300ms 之间。这种场景 CPU 几乎闲着,时间全耗在等待网络响应上,属于典型的 I/O 密集型任务。

我们先看传统的线程池写法:

import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class TraditionalApproach {

    // 模拟外部接口调用,延迟 100-300ms
    private static String fetchDetail(int id) {
        try {
            long delay = 100 + (long)(Math.random() * 200);
            Thread.sleep(delay);
            return "详情数据-" + id;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }

    // 传统 Future + 固定线程池
    public static List<String> process(List<Integer> ids) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(200);
        List<Future<String>> futures = ids.stream()
            .map(id -> executor.submit(() -> fetchDetail(id)))
            .toList();

        List<String> results = futures.stream()
            .map(f -> {
                try { return f.get(); }
                catch (Exception e) { return null; }
            }).toList();

        executor.shutdown();
        return results;
    }

    public static void main(String[] args) throws Exception {
        var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
        long start = System.currentTimeMillis();
        var results = process(ids);
        long duration = System.currentTimeMillis() - start;
        System.out.printf("传统线程池处理 %d 条记录耗时: %d ms%n", results.size(), duration);
    }
}

这里开了 200 条线程的固定线程池。1000 个任务排队等待,线程池满负荷运转。在你的机器上跑一下,耗时通常在 600ms 到 800ms 之间。问题在于,如果并发量再上几个数量级,200 条操作系统线程本身的内存和调度开销就会变得很可观。再扩大线程数又面临操作系统限制,调优的空间越来越小。

三、切换到虚拟线程

把上面的逻辑用虚拟线程重写,核心变化只有一行——创建线程池的方式:

import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class VirtualThreadApproach {

    private static String fetchDetail(int id) {
        try {
            long delay = 100 + (long)(Math.random() * 200);
            Thread.sleep(delay);
            return "详情-" + id;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }

    public static List<String> process(List<Integer> ids) throws Exception {
        // 唯一变化:Executors.newVirtualThreadPerTaskExecutor()
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = ids.stream()
                .map(id -> executor.submit(() -> fetchDetail(id)))
                .toList();

            return futures.stream()
                .map(f -> {
                    try { return f.get(); }
                    catch (Exception e) { return null; }
                }).toList();
        }
    }

    public static void main(String[] args) throws Exception {
        var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
        long start = System.currentTimeMillis();
        var results = process(ids);
        long duration = System.currentTimeMillis() - start;
        System.out.printf("虚拟线程处理 %d 条记录耗时: %d ms%n", results.size(), duration);
    }
}

Executors.newVirtualThreadPerTaskExecutor() 返回的 ExecutorService 会为每一个提交的任务创建一条新的虚拟线程。1000 个任务就创建 1000 条虚拟线程。不用担心数量,虚拟线程的创建成本极低,内部由少量操作系统线程承载,JVM 会自动调度。

同样的 1000 个任务,虚拟线程版本的耗时和线程池版本相当甚至略优,通常也在 600ms 到 800ms 的范围内。但关键的区别在于:业务代码里不再需要纠结线程池大小应该设多少,不需要担心队列满了怎么办,也不需要担心线程数太多把内存撑爆。你拿到一种最符合直觉的写法——每个任务独立处理,任务之间完全并发。

四、不用 ExecutorService 的更简写法

如果你的场景更简单,就想并发执行一批任务然后等待全部完成,可以直接用 Thread.startVirtualThread() 或者 Thread.ofVirtual()。上面的逻辑还可以进一步简化:

import java.util.List;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class VirtualThreadSimplified {

    private static String fetchDetail(int id) {
        try {
            Thread.sleep(100 + (long)(Math.random() * 200));
            return "数据-" + id;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        }
    }

    public static List<String> process(List<Integer> ids) throws Exception {
        var results = new ConcurrentHashMap<Integer, String>();
        var threads = ids.stream()
            .map(id -> Thread.ofVirtual().unstarted(() -> {
                String detail = fetchDetail(id);
                results.put(id, detail);
            })).toList();

        threads.forEach(Thread::start);
        for (var t : threads) {
            t.join();
        }

        return ids.stream().map(results::get).toList();
    }

    public static void main(String[] args) throws Exception {
        var ids = IntStream.rangeClosed(1, 1000).boxed().toList();
        long start = System.currentTimeMillis();
        var result = process(ids);
        System.out.printf("简化版虚拟线程处理 %d 条耗时: %d ms%n", result.size(), System.currentTimeMillis() - start);
    }
}

每个任务创建一条虚拟线程,用 ConcurrentHashMap 汇总结果,最后对线程列表统一 join()。这种写法几乎就是”为每个任务开一条线程”的原始做法,但现在不会再有以前那种”线程开多了系统就崩”的顾虑。代码清晰度比 Future 加 CompletableFuture 的组合要好得多。

五、性能对比与真实差距

在同一台开发机上反复跑了多轮对比测试,数据总结如下(均为 1000 个任务,每个任务模拟延迟 100-300ms):

  • 线程池 50 线程:耗时约 2200ms。线程数不够,大量任务在队列里干等。
  • 线程池 200 线程:耗时约 700ms。基本达到并发上限。
  • 线程池 500 线程:耗时约 650ms,提升不明显,但内存占用显著增加。
  • 虚拟线程:耗时约 680ms,和 200 线程池持平,但内存使用量远低于 500 线程的线程池。

这个结果反映了一个事实:对于 I/O 密集型任务,虚拟线程的吞吐量和调优得当的线程池在同一水平线上。但它胜在不需要调优——你不用猜线程池大小,不用监控等待队列长度,直接写最朴素的并发代码就行。当任务数量从 1000 上升到 100000 时,虚拟线程的优势会更加明显,因为线程池版本需要堆硬件或者做复杂的批处理拆分,而虚拟线程版本代码几乎不用改。

六、一个真正有价值的特性:结构化并发

虚拟线程单独使用已经不错,但如果配合 Java 21 里还在预览阶段的结构化并发 API,代码还能再干净一个档次。结构化并发强制父子任务之间的生命周期管理,子任务不能比父任务活得更久,有效避免了线程泄漏和孤立的 Future 引用。

import java.util.concurrent.*;
import java.util.stream.IntStream;

public class StructuredConcurrencyDemo {

    private static String fetch(int id) throws InterruptedException {
        Thread.sleep(100 + (long)(Math.random() * 200));
        return "内容-" + id;
    }

    public static String aggregate(List<Integer> ids) throws Exception {
        // StructuredTaskScope 在 JDK 21 中是预览特性,编译时需要加 --enable-preview
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var subtasks = ids.stream()
                .map(id -> scope.fork(() -> fetch(id)))
                .toList();

            scope.join();           // 等待所有子任务完成或任一失败
            scope.throwIfFailed();  // 如果某个子任务抛异常,这里会抛出

            return subtasks.stream()
                .map(Subtask::get)
                .toList()
                .toString();
        }
    }

    public static void main(String[] args) throws Exception {
        var ids = IntStream.rangeClosed(1, 10).boxed().toList();
        System.out.println(aggregate(ids));
    }
}

StructuredTaskScope 提供了 fork() 方法来派发子任务,所有子任务都在同一个作用域里。作用域结束时,保证所有子任务都已经终结——不会出现子任务还在跑而主线程已经返回的情况。这个特性目前还是预览,不建议直接上生产,但在本地验证和预研项目里已经可以充分体验。

七、迁移到虚拟线程时需要注意的几个点

虚拟线程虽好,但并非银弹。以下场景需要保持警惕:

第一,synchronized 块会绑住底层操作系统线程。这是当前虚拟线程实现中最重要的限制。当虚拟线程进入 synchronized 代码块时,它所占用的底层线程不会被释放,导致其他虚拟线程无法复用该操作系统线程。如果你的代码里有大段 synchronized 逻辑且内部包含阻塞操作,虚拟线程的优势会大打折扣。解决方案是用 ReentrantLock 替换 synchronized,后者对虚拟线程友好得多。

第二,线程局部变量要重新审视。传统线程池用 ThreadLocal 做上下文传递很常见,但虚拟线程数量庞大且生命周期短,大量使用 ThreadLocal 可能导致内存占用不可控。建议改为在方法参数中显式传递上下文,或者使用 ScopedValue(同样是 JDK 21 的预览特性)。

第三,CPU 密集型任务不是虚拟线程的主战场。如果任务本身是纯计算、没有阻塞操作,虚拟线程并不能带来吞吐量提升——毕竟底层还是那些 CPU 核心在干活。这种情况下传统线程池或者 ForkJoinPool 更合适。虚拟线程的甜区始终是 I/O 等待占比高的任务。

第四,连接池不要为每个虚拟线程单独分配连接。如果你把数据库连接池的大小设成和虚拟线程数一样高,数据库会先扛不住。正确的做法是保持有限的连接池大小,让虚拟线程排队等待连接,这样整体的吞吐量由数据库处理能力决定,而不是由线程数决定。

八、什么项目可以立刻用上

如果你正在维护一个基于 Spring Boot 3.2+ 的项目,虚拟线程的集成已经非常成熟。只需在配置文件里加一行:

spring.threads.virtual.enabled=true

Spring 会自动把 Tomcat 的请求处理线程和 @Async 注解的默认执行器都换成虚拟线程。不需要改一行业务代码,你的 Web 应用就直接获得了虚拟线程带来的并发处理能力。

对于不依赖 Spring 的纯 Java 项目,只要你的项目 JDK 版本升到了 21,把原来 Executors.newFixedThreadPool(n) 的地方换成 Executors.newVirtualThreadPerTaskExecutor(),基本就能覆盖绝大多数场景。当然,前提是你的代码没有大量的 synchronized 块阻塞问题。

有一点值得单独提一下:虚拟线程对现有的监控工具是友好的。Java 的线程转储、jstack 命令都能正常识别虚拟线程,堆栈信息也完整保留。你不会因为迁移到虚拟线程就失去线上排错的手段。

九、总结

虚拟线程并没有颠覆 Java 的并发模型,而是为 I/O 密集型的高并发任务提供了一种更简单、资源效率更高的实现方式。你用最朴素的”一线程一任务”模式写出来的代码,在性能上可以和精心调优的线程池方案打平甚至略优,而代码的可读性和维护成本大幅下降。

对于正在做微服务、网关、数据聚合平台这类 I/O 密集型系统的团队来说,JDK 21 已经可以升级,虚拟线程也值得认真评估。文中的示例代码都是独立可编译运行的,你只需要安装 JDK 21,复制进去就能跑。亲手把数据跑出来,比看任何评测都要有说服力。

把Java并发模型翻新:虚拟线程如何改写高并发服务吞吐量
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 java 把Java并发模型翻新:虚拟线程如何改写高并发服务吞吐量 https://www.taomawang.com/server/java/2371.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务