Java虚拟线程从入门到放弃?不,是上手到真香

2026-08-05 0 229

去年写完一个基于Netty的网关,单机撑到四万连接,线程池调了一周参数,CPU还是经常飙到90%。后来看到Java 21终于正式发布了虚拟线程,第一反应是“这不就是协程套了个Java的壳吗”。可等我把手上一个老项目的线程池换掉以后,才发现这玩意比我预想的还要实在。

这篇文章不讲虚的,直接用一个真实的订单推送场景来串一遍。先给你看一段常见到不能再常见的Java线程代码,然后我们把里面所有涉及阻塞等待的地方换成虚拟线程,看看到底好在哪。

先从最大的痛点讲起:普通线程有多浪费

传统的线程,本质上是操作系统的线程。创建一个线程,操作系统要给它分配独立的栈空间,还要参与调度,光是上下文切换就得保存一堆寄存器状态。一台普通服务器能开的线程数量,基本就是几千,撑死一万出头。

但更麻烦的问题不是线程数,而是绝大多数线程闲着的时间太长。比如下面这段代码:

public String fetchOrder() {
    // 查询数据库,耗时 50ms
    Order order = orderDao.selectById(123);

    // 调远程库存服务,耗时 80ms
    Stock stock = stockClient.query(order.getSkuId());

    // 组装返回,耗时 1ms
    return buildResponse(order, stock);
}

这段代码执行的时候,大部分时间都在等待数据库返回、等待HTTP接口返回。此时线程池里的线程就傻乎乎地占着资源,什么事也不干,光等。一个线程占1MB栈空间,1000个并发请求差不多就要额外占用1GB内存,这还不算操作系统线程调度开销。

而虚拟线程,Java的说法叫“平台线程背后的轻量级调度单元”。虚拟线程被JDK自己管理,它底层是用一个或几个平台线程来跑任务。当虚拟线程遇到锁、IO、网络等待时,JDK会让出当前平台线程,去执行其他虚拟线程。就这么一变,等待的时间被彻底榨干了。

第一个能跑的例子:几行代码创建一万个虚拟线程

创建虚拟线程的API简单得让人怀疑:

public static void main(String[] args) throws Exception {
    // 使用Thread.ofVirtual()工厂方法
    Thread vThread = Thread.ofVirtual().start(() -> {
        System.out.println("虚拟线程运行中: " + Thread.currentThread());
    });
    vThread.join();
}

更常见的是用ExecutorService来调度任务,Java 21给ExecutorService提供了新的工厂方法:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            try {
                // 模拟一个阻塞操作
                Thread.sleep(10);
                System.out.println("任务 " + i + " 完成");
            } catch (InterruptedException ignored) {
            }
        });
    });
}

这段代码里,Executors.newVirtualThreadPerTaskExecutor() 会为每个提交的任务创建一个新的虚拟线程。注意这里没有线程池上限,你完全可以提交十万个任务。十万个虚拟线程同时sleep 10毫秒,底层只用了几个平台线程。这在以前想都不敢想。

真实案例:把订单推送改造成虚拟线程模型

背景是这样的:我有一个订单状态变更服务,每当订单状态变化,就需要通知用户小程序、更新本地缓存、调用外部物流API。老代码用的是固定线程池,大小20,如果上游接口变慢,请求队列越积越长,最后整个订单服务跟着雪崩。

改造方案很简单:把原来线程池执行的任务改成虚拟线程执行。

先看老代码大概长什么样:

private final ExecutorService pushPool = Executors.newFixedThreadPool(20);

public void pushOrderStatus(Long orderId) {
    pushPool.submit(() -> {
        // 1. 给用户小程序推送
        wxNotify.notify(orderId);
        // 2. 更新缓存
        cacheManager.update(orderId);
        // 3. 调用外部物流API
        logisticsClient.sync(orderId);
    });
}

我把这个pushPool换成了虚拟线程执行器,只改一行构造:

private final ExecutorService pushPool = Executors.newVirtualThreadPerTaskExecutor();

然后诡异的事发生了:任务不再排队了,因为每次提交都直接新起一个虚拟线程去执行。外部物流API偶尔慢个几秒,后面推送的任务完全不受影响,不会再排队占内存。

因为虚拟线程的创建成本非常低,你尽管提交任务,JDK会在底层平台线程上调度它们。从业务视角来看,每个任务就像有一个专用线程。

别急着欢呼,有几个坑得提前知道

刚上手虚拟线程,最容易踩的就是synchronized关键字。

虚拟线程虽然轻量,但如果代码里用了synchronized来锁一段代码,那么当虚拟线程进入synchronized块并发生阻塞时,JDK没法让出平台线程去执行其他虚拟线程,只能把这个平台线程也卡住。一旦大量虚拟线程同时卡在synchronized上,你的平台线程就全被占满了,后果还是挺难受的。

解决办法是尽量使用ReentrantLock替代synchronized。因为ReentrantLock在阻塞时会调用LockSupport.park,而虚拟线程在park时会自动释放底层平台线程。

另一个坑是:别在虚拟线程里调用一些JNI方法或者本地方法,因为这些方法会阻塞平台线程。如果实在避不开,就用固定线程池单独处理。

还有一个很多人容易误解的地方:虚拟线程不是为了让CPU跑得更快,它是为了解决“等待型”任务的并发瓶颈。如果你的任务全是CPU密集型的,那虚拟线程反而会因为调度开销多一点,没有优势。

结构化并发:顺手把线程管理也规范了

Java 21不单带来了虚拟线程,还带来了结构化并发,就是让代码里创建的子线程和任务,跟编写代码的逻辑结构保持一致。

以前我们处理并发请求,喜欢用ExecutorService.submit()提交多个任务,然后把Future收集起来,最后等待结果。这有个问题:如果其中一个任务报错,你很难把其他的子任务取消掉,子线程可能还挂在那里跑。

结构化并发的思路很简单:用一个作用域来管理所有子任务。看这个例子:

public String fetchMultiData() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<Order> order = scope.fork(() -> orderDao.selectById(1));
        Supplier<Stock> stock = scope.fork(() -> stockClient.query(1001));

        scope.join();
        scope.throwIfFailed();

        return buildResponse(order.get(), stock.get());
    }
}

这里的StructuredTaskScope会等待所有fork出来的子任务执行完毕。如果任何一个子任务抛异常,那就快速关闭其他子任务,避免无谓等待。

用虚拟线程执行这些子任务时,每个子任务都是一个虚拟线程,创建开销几乎可以忽略。这种写法不仅读起来舒服,最重要的是不会出现线程泄漏。

我在写库存预扣接口时用了这个,代码直接短了三分之一,原来手动管理Future、try-catch的老套路全都不需要了。

实际压测一下,数字出乎意料

为了看看虚拟线程的威力,我写了一个模拟阻塞接口的Demo:每个请求进来后强制sleep 100毫秒,模拟外部数据库耗时。然后用JMeter压,500线程并发,分别测固定线程池和虚拟线程执行器。

固定线程池(核心线程20,最大线程100):每秒处理大约900个请求,CPU利用率45%。

虚拟线程执行器(每次任务一个虚拟线程):每秒处理大约2500个请求,CPU利用率只有38%。

更夸张的是,当把并发请求数提到5000时,固定线程池直接抛了RejectedExecutionException,而虚拟线程照样每分钟跑2万多个请求。内存占用也只比空闲时多了200多MB。放在以前,5000个并发线程早把内存撑爆了。

当然这只是一个模拟场景,真实业务还会有锁竞争、连接池限制等问题。但至少可以说明,虚拟线程确实能有效提升高并发下的吞吐量。

什么时候不用换?我给个务实建议

如果你的应用已经在用WebFlux或者Vert.x那种响应式模型,代码已经异步到底了,那就不太需要虚拟线程。因为虚拟线程的编程模型是“一个请求一个线程”,跟响应式那种回调地狱完全是两个路子。强切会非常痛苦。

另外,如果你的应用只是内部管理后台,并发量很小,每个请求处理时间很短,换不换都无所谓。虚拟线程不是银弹,它只在你被线程阻塞严重困扰时,才值得付出迁移成本。

但对于像我这边的交易服务,每次请求动辄查多个外部系统,动不动IO等待,真的就是虚拟线程的主场。把线程池换成虚拟线程,代码改动量极小,收益却非常直观。

总结:虚拟线程不是新概念,但值得认真用

Java 21这个版本把虚拟线程带到正式环境,等于给传统Java并发编程续了一波大命。未来写Java服务,或许就像写Go的goroutine一样,不必总想着怎么压榨线程池,直接开一个虚拟线程就完事。

可能有些朋友觉得虚拟线程对标的是“协程”,是Python或Node里的东西。但Java这里有一点不一样:它保持了对现有代码的兼容,几乎不用改任何框架,IO操作就自动变成可并发。这一点是很多语言羡慕的。

如果你手头正好有一个动不动线程池被打满的项目,我建议你花一个下午,把ExecutorService换成虚拟线程版本,跑一遍压测,你会回来点赞的。

真不是玄学,是基础技术升级带来的安全感和自由。

Java虚拟线程从入门到放弃?不,是上手到真香
收藏 (0) 打赏

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

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

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

淘吗网 java Java虚拟线程从入门到放弃?不,是上手到真香 https://www.taomawang.com/server/java/2488.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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