ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

虚拟线程Loom对决Go goroutine:高并发模型核心差异与选型

虚拟线程Loom对决Go goroutine:高并发模型核心差异与选型 老实讲看到“Java 21 虚拟线程 (Loom) 终结 Go 并发神话”这个标题时我第一反应是又有人要引战了。但冷静下来想想这确实是过去两年后端圈讨论最凶的选型话题之一。一边是 Go 靠着 goroutine 几乎成了“高并发”的代名词一边是 Java 憋了多年的 Project Loom 终于在 JDK 21 里转正让写惯了传统阻塞代码的 Java 工程师也敢开口谈百万并发。我这两年同时在维护 Java 和 Go 的高并发 IM 网关也分别给团队做过压测和选型评估这篇文章就把实测数据、源码层面的调度模型差异以及踩过的真实坑一起摆出来看看“终结神话”这种说法到底站不站得住脚。1. 一场“关公战秦琼”式的争论为什么偏偏把 Java 和 Go 摆上擂台1.1 Go 当年是怎么让 Java 开发者眼红的我的职业生涯起点是 Java那会儿最痛苦的事情就是处理高并发连接。在 JDK 21 之前想扛住一万个长连接你基本只有三条路线程池加平台线程、手写 NIO、或者上 Netty。第一条路很快会撞墙——Java 平台线程默认栈通常在 1MB 左右加上 OS 线程本身的创建和切换成本五千个平台线程已经能让普通服务器喘不过气十万线程基本是痴人说梦。第二条路 NIO 的反人类程度不用我多说Selector 那套注册、轮询、处理事件的逻辑写出来就是一场代码事故。第三条路 Netty 虽然把 NIO 封装得不错但 EventLoop、ChannelPipeline、背压这些概念新人没一两个月根本消化不了。Go 在 2009 年出现后直接把这个问题从语言层面解决了。goroutine 起步栈只有 2KB按需增长创建几十万个跟玩一样。更关键的是你不需要改任何写法——go handle(conn)一行剩下的阻塞代码随便写runtime 底层帮你把 socket 事件接到 network poller 上。对比当时 Java 的 callback 地狱Go 的体验就是降维打击。所以这几年IM 网关、API 网关、消息推送、各种 proxy 和 agent 工具几乎被 Go 和 Rust 瓜分了地盘Java 在这些场景里被贴上了“笨重”的标签。1.2 Java 21 之后风评为什么开始反转2023 年 9 月 JDK 21 作为 LTS 正式发布虚拟线程通过 JEP 444 转正。这不是一个小补丁而是 OpenJDK 从 2017 年开始立项的 Loom 计划的核心成果。目标很明确让开发者继续用传统的“一连接一线程”的阻塞式写法但线程本身变得极其廉价数量级直接跨过操作系统线程上限。Spring Boot 3.2 跟进得很积极配置里加一个spring.threads.virtual.enabledtrueTomcat 接收请求就默认走虚拟线程存量项目几乎不用改代码。于是舆论开始往另一个方向走既然 Java 也有轻量线程了Go 的并发优势是不是没了两类开发者天天在群里对线一边说“Loom 完爆 goroutineJava 生态秒杀 Go”另一边说“goroutine 是语言原生虚拟线程只是补丁”。我个人的看法是要想搞清楚这个问题得先把两个模型底层到底怎么工作的拆明白否则所有讨论都是立场先行。2. 虚拟线程的调度内核JVM 里的“可拆卸”线程2.1 从 Platform Thread 到 Carrier Thread虚拟线程虽然名字里带“线程”但它本质上是 JVM 自己在用户态调度的一种轻量任务单元。每个虚拟线程内部保存着一个 Continuation可以理解成把整个方法调用栈冻结起来的能力真正把它跑起来的是少量的平台线程也就是 carrier thread。JDK 21 里负责调度的载体是一个专用的 ForkJoinPool默认并行度等于 CPU 核数。也就是说一台 16 核机器上同一时刻最多只有 16 个 carrier 平台线程在真正执行虚拟线程的代码。其余几十万个虚拟线程都处于“挂起”状态等着被调度器铺到某个空闲 carrier 上运行。这个设计可以打个比方平台线程是每个人独占一张办公桌虚拟线程是一堆共享工位的临时工。工位数量有限谁在电脑前写文档就占着工位谁要去等打印机就把工位让给下一个人。操作系统压根不知道这些临时工的存在它只看到那几张工位上始终有人在工作。2.2 阻塞点 unmount 的完整旅程虚拟线程的核心机制是在“阻塞”发生的瞬间完成的用户态切换。流程大概是这样的虚拟线程在某个 carrier 上运行执行普通字节码。遇到阻塞操作比如 socket 读取、Thread.sleep、获取锁、等待数据库响应。JVM 把当前虚拟线程的调用栈状态冻结保存然后从 carrier 上解绑这个动作叫 unmount。carrier 立刻被 ForkJoinPool 回收调度器挂上另一个可运行的虚拟线程。当 IO 事件完成JDK 底层通过 NIO selector 机制感知原虚拟线程被重新标记为可运行等某个 carrier 空闲后再挂载上去继续执行。注意这里的细节JDK 之所以能做到这点是因为现代 JDK 内置的 socket 和通道实现已经把阻塞 IO 都转成了 NIO 的非阻塞事件。你在代码里写inputStream.read()看着是阻塞的但内部到了 JVM 和 JDK 那一层已经变成 selector 上的事件注册了。这就是为什么虚拟线程的阻塞不会真的卡住操作系统的线程。有人会拿 Java 1.0 时代的 Green Thread 来类比。确实思路相似但当年失败是因为底层 IO 没有任何非阻塞机制一个线程调用阻塞的系统调用整个进程都停了。现在 JVM 有了一套完整的非阻塞 IO 栈做地基虚拟线程才能跑得起来。2.3 别混淆虚拟线程与 goroutine 的“轻”不是同一种轻理解了 carrier 机制之后可以跟 Go 的 GMP 调度模型做个对照。Go runtime 里有 Ggoroutine、MOS 线程、P逻辑处理器三个角色P 的数量默认等于 CPU 核数各个 M 去 P 上取 G 执行。G 发生阻塞时M 可以被解绑G 挂起等事件到了由 network poller 唤醒。从宏观上看两者都是 M:N 的用户态线程模型这没错但具体实现和哲学不同。维度Java 虚拟线程Go goroutine调度器JVM 内部 ForkJoinPoolcarrier 数量默认等于 CPU 核数Go runtime 自有 GMP 调度器P 数量默认等于 CPU 核数栈管理Continuation 按需分配无固定初始栈起步约 2KB按需扩容最大 1GB阻塞处理JDK 内部转 NIO 事件阻塞点自动 unmountruntime 挂起 GM 释放net poller 唤醒CPU 密集并行度受 carrier 数量限制并行度受 P 数量限制语法呈现标准 Thread API和平台线程写法一致go func()关键字 channel语言层面库 JVM 机制语言原生 runtime从这张表能看出一条核心差异Go 的并发是语言级公民你写代码时要感知 goroutine 的存在Java 虚拟线程则想让你忘记线程的存在继续像写老代码一样写阻塞逻辑。这两种哲学各有利弊后面我会展开讲。3. 16C32G 服务器上的实测IO 密集场景的资源账本3.1 测试场景与压测方法很多朋友在群里问过类似“16C32G 服务器到底能支持多少并发”这种问题我的回答都是别信嘴炮压一下就有数了。这次我搭了一个最典型的 IO 密集场景长连接 echo 服务每个连接发一条消息服务端读完后模拟一次下游调用休眠 50ms再写回响应。客户端用多台压测机模拟 5000 个并发连接每个连接连续发 1000 次请求记录 P50、P99 延迟和服务端内存占用。三个选手分别是Java 21 平台线程版固定 5000 线程、Java 21 虚拟线程版、Go 1.21 goroutine 版。服务代码都保持最小的逻辑不引入框架干扰。Java 虚拟线程版的核心长这样try (var executor Executors.newVirtualThreadPerTaskExecutor()) { while (true) { Socket conn server.accept(); executor.submit(() - handle(conn)); } }Go 版长这样listener, _ : net.Listen(tcp, :8080) for { conn, err : listener.Accept() if err ! nil { continue } go handle(conn) }3.2 三个选手的实测数据指标5000 并发长连接Java 平台线程Java 虚拟线程Go goroutine服务端 RSS 内存约 5.2GB约 0.9GB含 JVM 基础开销约 0.6GBP50 延迟55ms53ms53msP99 延迟98ms62ms60ms单机吞吐约 6.1w/s约 9.0w/s约 9.3w/s继续扩张连接数5000 已是极限再开线程内存爆50k 连接无压力50k 连接无压力然后我又单独把连接数顶到 5 万个长连接平台线程版直接放弃虚拟线程版 RSS 大约 1.9GBGo 版大约 1.2GBP99 都还在可接受范围内。3.3 数据背后的解读赢的不是吞吐是资源利用率很多人看到吞吐数字会说“你看虚拟线程还是比 Go 差一点”。说实话在 IO 密集低 CPU 场景下两者吞吐差距很小而且不同机器、不同 JDK 版本、不同 sleep 时长都会影响百分比。真正的核心差异在内存和调度稳定性上平台线程版 5.2GB 的内存占用只撑了 5000 连接虚拟线程用不到 1GB 就能扛 5 万连接Go 甚至更省一点。这才是 Loom 的价值所在——它没有让 Java 单个请求变快而是让 Java 重新获得了“用低成本堆海量连接”的能力。以前你要用 Netty 的 reactor 模型才能做到的事现在用普通阻塞代码就做到了代价是极小的额外内存。对于大量带着历史包袱的 Java 团队来说这个意义怎么强调都不过分。4. 虚拟线程的真实代价那些文档没写透的坑4.1 synchronized 与 pinning为什么你的虚拟线程会“卡死” carrier虚拟线程不是银弹最经典的坑就是 synchronized 导致的 pinning 问题。当一个虚拟线程进入 synchronized 代码块并且在代码块内部发生了阻塞比如 sleep 或者 IO当前场景下虚拟线程无法卸载carrier 线程会被连带钉住。如果这种代码出现在一个被频繁调用的工具类里很快 16 个 carrier 全被钉完看起来就是 CPU 不高、线程池池子被占满、请求全部排队诡异的是 Java 线程 dump 又看不出明显死锁。我最初排查时靠的是一个启动参数-Djdk.tracePinnedThreadsfull它会打印哪些虚拟线程发生了 pinning。类似下面的代码就是重灾区synchronized (lock) { Thread.sleep(500); // 在 JDK 21 里carrier 会被钉住 }解决办法是尽量把锁替换成ReentrantLock或者缩小 synchronized 的作用域尽量避免把阻塞操作放进同步块里。好消息是 JDK 24 通过 JEP 491 已经实现了 synchronized 场景下不 pinning但如果你现在生产环境还是 JDK 21 LTS这条坑必须记住。4.2 ThreadLocal 是内存炸弹虚拟线程便宜不代表它的附属物便宜。很多人习惯用 ThreadLocal 存上下文比如 Spring Security 的认证信息、日志框架的 MDC、数据库连接之类的这在平台线程时代没问题因为池化后的线程数量是可控的。但虚拟线程是可以轻松创建几十万的每个虚拟线程都往 ThreadLocal 里放一块稍大的对象内存立刻炸——几万个 4KB 的上下文就是几百 MB几十万就是几个 GB。JDK 里其实一直在推进 ScopedValue作用域值从 JDK 20 开始孵化设计目标是让这种上下文传递更安全、更廉价但 API 还没完全稳定生产环境不建议大面积依赖。我现在的做法是在迁移到虚拟线程的服务里能用方法参数共享的上下文坚决不用 ThreadLocal必须用的就先单独压一遍内存确认单线程附加数据在可控范围内。4.3 别把虚拟线程塞进固定线程池这个坑很多人踩得悄无声息。有些团队赶时髦代码里是Executors.newVirtualThreadPerTaskExecutor()但中间某层还是老的固定线程池业务阻塞任务都往里面丢。结果就是虚拟线程被包在 4 个平台线程里排队性能不升反降。正确的姿势是虚拟线程要么直接Thread.ofVirtual().start(...)要么用Executors.newVirtualThreadPerTaskExecutor()前者线程用完即弃。另外 JDK 21 开始ExecutorService实现了AutoCloseable你可以在 try-with-resources 里等待所有任务结束再关闭行为比老的 shutdown/awaitTermination 干净很多。4.4 jcmd 定位虚拟线程问题排查虚拟线程还有一个实用的工具jcmd。JDK 21 的jcmd pid Thread.dump_to_file -formatjson dump.json会输出包含虚拟线程在内的线程 dump每个虚拟线程的状态、carrier、栈都能看到。通常我用它做两件事一是确认是否存在大量 BLOCKED 状态的虚拟线程二是结合-Djdk.tracePinnedThreads定位是不是 synchronized 钉住了 carrier。这套组合拳比在监控平台里对着指标瞎猜有效得多。5. Go 依然能打的领域CPU 密集、CSP 与工程化便利5.1 CPU 密集场景虚拟线程帮不上忙很多人一提到并发就默认等于性能这是最大的误解。虚拟线程和 goroutine 优化的是“等待”成本不是“计算”成本。如果任务是纯 CPU 密集比如图像处理、视频编码、大规模日志解析16 核机器上的并行上限就是 16虚拟线程数量再多也没用。此时 goroutine 和虚拟线程比的不是并发模型而是单核计算效率、GC 开销、内存分配这些底层指标。Java 有 JIT 的深度优化Go 有极低延迟的调度和 GC 设计二者互有胜负但跟你用没用 Loom 关系不大。5.2 channel 与 selectGo 的并发语言级原语虚拟线程补上了“轻量线程”这块拼图但 Go 的并发还有一个 Java 至今没有的东西channel 和 select。这是 CSP 模型的核心允许你把并发单元之间的协调直接写成语言的语法而不是代码库里的约定。下面这段 Go 代码在 Java 里想等价实现要费不少功夫select { case n : -jobs: process(n) case -ctx.Done(): return case -time.After(3 * time.Second): log.Println(慢消费者触发告警) }Java 里你得用CompletableFuture组合、或者引入像 Quasar 那样的第三方 actor/channel 库。能写吗能。但代码的可读性和 Go 原生版本差了不止一个量级。如果你的团队核心业务大量依赖管道、扇入扇出、超时冒泡这类模式Go 的写法仍然更天然。5.3 部署形态与团队心智负担Go 还有一个被低估的优势工程化极简。CGO_ENABLED0 GOOSlinux go build出来的静态二进制扔进容器里镜像可能只有十几 MB启动时间毫秒级RSS 常年在 50MB 上下运维不需要管 JVM 参数、堆大小、GC 日志、JIT 预热。相比之下哪怕是优化过的 Java 服务镜像轻松上百 MBJVM 基础内存 200~300MB 起步还需要根据流量调 Xmx、调 GC、考虑 Warmup 时间。在边缘节点、大规模交付的场景下这个差距会被放大到让人抓狂。当然反过来说Java 在大型企业应用里的生态深度和成熟度是 Go 短期追不上的。Spring 那一套全家桶、成熟的 ORM、海量的监控与治理体系还有海量的 Java 工程师这些对于业务型团队有时候比并发模型更重要。6. 我的选型框架与真实项目体会6.1 一张选型决策表结合上面的分析我一般用下面这张表来辅助决策判断问题倾向 Java 21 虚拟线程倾向 Go现有代码和中间件Spring 生态成熟历史包袱重新项目无历史包袱核心场景长连接、网关、BFF、IO 密集业务基础设施、代理、Agent、CLI团队背景熟悉 JVM 和调试工具熟悉 Go偏好简单部署内存预算单机内存充裕可接受 JVM 开销容器/边缘节点内存紧张CPU 密集计算无特殊优势无特殊优势运维复杂度能接受 JVM 调优和 GC 排查一条命令跑起来最好特别说明一点数据库层面的并发锁问题不能指望语言解决。你的并发再高一个数据库连接池里几十个连接一个行锁冲突就能把整个系统的延迟打回原形。并发模型只是第一层SQL 优化、连接池配置、缓存设计才是后端系统的真正瓶颈。6.2 两个真实项目的对比我在生产环境分别做过两个有代表性的项目。一个是消息推送网关Java 版原来基于 Netty 的响应式写法背压、EventLoop、ChannelHandler 三层代码新人上手极其痛苦出了线上问题也不好排查。迁到 JDK 21 虚拟线程后整个服务改成了最简单的“每连接一线程”的阻塞写法代码量直接砍掉一半多吞吐持平P99 反而更低。原因很简单没有了 EventLoop 上的任务争抢和回调地狱调度路径清晰了。另一个是日志采集 AgentGo 版。要部署到几十台边缘节点上启动就是几十毫秒内存几十 MB交叉编译一个二进制丢上去就能跑。这个场景如果用 JVM光是镜像分发包和启动预热就会让我想骂人。6.3 我对“终结神话”这五个字的看法我的结论其实很平淡虚拟线程并没有终结 Go 的并发优势“终结”这个词更多是标题党时代的流量密码。它真正终结的是“Java 写高并发就必须上响应式编程”的悲惨时代。从这一刻起Java 和 Go 在 IO 密集型并发服务上回到了同一起跑线剩下的差异来自生态、工程化、语言特性和团队偏好而不是“谁的数据结构更高级”。如果你让我给一句操作性建议新项目如果核心是高吞吐 IO、且团队没有 Java 历史包袱Go 依然是好选择如果团队在 Spring 生态里深耕多年业务又属于典型的企业级请求响应模型JDK 21 虚拟线程足够让你用最小的改造成本把并发能力拉高没必要为了赶时髦整体迁到 Go。两个技术都会继续存在真正受益的是我们这些有选择权的开发者。最后说点个人土办法无论选哪边先把监控做全再做压测再谈上线。我自己踩过的最大的坑从来不是选错语言而是没压测就急着上了生产被数据库连接池和隐藏的同步块教做人。选型是战略细节才是胜负手。
返回列表