做后端这些年,被并发问题折磨的次数,我自己都数不过来。每次一聊到高并发,总会冒出一句绕不开的话:“Java的线程太重了,换个语言吧。”然后就有人指向Go,说goroutine多么轻量、并发多么优雅。这话听多了,心里总不是滋味。可没想到,Java 21的虚拟线程(Loom项目)落地之后,这个争论被彻底翻了个面。好像一夜之间,Java也能轻松创建几十万个“轻量线程”了,有人甚至喊出“Java 21终结Go并发神话”的口号。
作为一个从JDK 8一路用上来的老Java开发,同时也写了不少Go服务的人,我对这类“谁赢了”的标题一向保持警惕。技术选型不是打嘴仗,也不是看谁的粉丝声音大,而是要看具体的业务场景、团队技术栈、生态成熟度。这篇文章就想把Java 21虚拟线程和Go goroutine这两件事掰开揉碎,讲清楚Loom到底做了什么、虚拟线程和goroutine在原理上有什么异同、在16C32G这种常见服务器配置下能扛多少并发、压测时有哪些值得注意的细节,以及什么样的项目适合切到虚拟线程,什么场景继续用Go更稳妥。
这篇文章适合正在评估并发模型选型的后端工程师,也适合那些对“高并发IM”“API网关”“微服务架构”有实际需求、但还在Java和Go之间摇摆的团队。我不会只讲概念,会把测试数据、JMeter压测参数设置、迁移路径和踩坑经验一并写出来,给出一份可以直接参考的实操笔记。
1. 虚拟线程的前世今生:Java并发模型的演进与困局
1.1 从Thread到Loom:为什么Java并发一直被吐槽
Java从诞生起就提供了java.lang.Thread,每个线程直接映射到操作系统线程(OS Thread)。线程的创建、上下文切换、阻塞唤醒,全都由操作系统内核调度。这种设计在Web应用刚兴起的年代完全够用,一台服务器同时跑几百个线程就很了不起了。
但互联网业务膨胀之后,问题就暴露了。高并发场景下,一个请求从进入到返回,往往大量时间花在等待上:等数据库返回、等RPC响应、等Redis结果。等待期间线程被阻塞,占着内核线程资源不做实事。为了支撑更多并发请求,传统方案是引入线程池,限制线程数量,比如Tomcat默认200线程,但线程池的数目一旦设置不当,要么排队严重,要么直接把内存打爆。操作系统线程默认栈大小通常在512KB到1MB,2000个线程就是2GB虚拟内存,这还不算状态切换开销。线程数量上不去,并发能力就锁死在那里了。
后来Java生态里出现了一堆应对方案:Netty的Reactor模型、CompletableFuture异步编排、响应式编程(WebFlux)。思路都是把“等待期间不占线程”落实到代码层,让一个线程轮询大量连接。这套方案确实有效,但副作用也明显:代码被拆成回调、链式调用、事件驱动,业务逻辑被碎片化,排错、写单元测试都难受。我见过不少团队为了让一个HTTP调用性能达标,把同步代码改成异步链路,结果上线后问题定位难度直线上升,一个数据查询要顺着五六层回调往下翻。成本实在不小。
Loom项目的诞生,就是冲着这个结构性矛盾去的。它的目标是让Java开发者回到“一个请求一个线程”的直观编程模式,同时消除线程数量和内存占用带来的瓶颈。换句话说,既保留同步代码的可读性,又获得接近异步模型的高并发能力。虚拟线程(Virtual Thread)就是最终的答案。
1.2 Loom项目到底解决了什么:调度器、挂载与卸载
虚拟线程并不是“更快的线程”,而是一种由JVM自行管理、不再一对一映射到操作系统线程的轻量级用户态线程。它底层的载体依然是平台线程(Platform Thread,也就是传统的OS线程),但虚拟线程可以随时从载体线程上“卸载”(Unmount)和“重新挂载”(Mount)。
当一个虚拟线程执行到阻塞操作(例如读取Socket、等待锁、调用Thread.sleep()时,JVM会自动将这个虚拟线程从当前载体线程上摘下来,让载体线程继续执行其他虚拟线程。当阻塞条件满足时,虚拟线程再被调度到某个空闲的载体线程上接着跑。整个过程对开发者透明,你写的还是同步代码,但线程不再被白白占用。
如果用生活化类比的话:平台线程就是酒店的房间,虚拟线程就是入住的客人。传统模式下,客人住进一个房间就不能挪了,他睡觉的时候房间也空着。虚拟线程模式下,客人睡觉时可以把床铺腾出来给别的客人用,睡醒了再安排新房间继续住。房间数量始终有限,但能接待的客人数量不再受房间数量限制。
这种设计带来的直接效果是,创建几十万个虚拟线程变成了轻松的事情。虚拟线程的实例本身只占几百字节到一两KB的堆内存,不再像OS线程那样动辄消耗MB级别的栈空间。阻塞期间不占载体线程,让系统的整体吞吐量不再受“OS线程上限”掐脖子。这里要特别强调:虚拟线程不会让单条请求变得更块,它解决的是吞吐量和资源利用率的问题。单个请求该多少毫秒还是多少毫秒,但同样的硬件配置下,系统可以同时处理的请求数能上一个量级。
2. 原理深挖:虚拟线程与goroutine到底有什么异同
2.1 虚拟线程的调度模型:从Thread到Carrier Thread
在JDK 21中,虚拟线程默认使用ForkJoinPool作为调度器,每个载体线程对应一个ForkJoinPool的工作线程。调度器通过java.util.concurrent包下的ForkJoinPool来调度虚拟线程的执行。开发者可以通过系统参数jdk.virtualThreadScheduler.parallelism控制载体线程数量,默认值等于CPU核数。
看一下核心API就明白了:
// 方式一:直接创建并启动虚拟线程 Thread.ofVirtual() .name("my-virtual-thread") .start(() -> { System.out.println("Hello from virtual thread: " + Thread.currentThread()); }); // 方式二:使用虚拟线程执行器 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> { // 业务逻辑 }); }注意Executors.newVirtualThreadPerTaskExecutor()这个方法,它为每一个提交的任务都创建一个新的虚拟线程。如果你在业务代码里写循环提交几千个任务,它就是几千个虚拟线程,完全不用顾虑线程池大小和排队。这点跟传统的newFixedThreadPool(n)有本质区别——传统线程池在任务过多时会堆积到队列里,虚拟线程执行器则天然“无界”。
关键点在于阻塞行为的变化。synchronized修饰的代码块处于monitor等待时,虚拟线程会暂时“钉住”(Pinning)载体线程,导致载体线程无法被其他虚拟线程使用。这是我后面要重点提到的坑。如果用了synchronized又频繁发生锁竞争,虚拟线程的提升空间会大打折扣。优先推荐使用java.util.concurrent.locks.ReentrantLock,因为它不会导致钉住现象。
2.2 Go的G-M-P模型:调度器如何驱动轻量级goroutine
Go语言从一开始就选择了协程路线,goroutine的最小堆栈仅有2KB(Go 1.4之前是4KB,后来调整为2KB起步,动态增长),由Go运行时(Runtime)负责调度。Go的调度模型常被称作G-M-P模型:
- G代表goroutine,也就是要被调度的任务;
- M代表Machine,可以理解为操作系统线程;
- P代表Processor,是运行所需资源,每个P都绑定一个本地运行队列。
每个M必须持有一个P才能真正执行G。当goroutine发生阻塞时,Go运行时会按需创建新的M,或者将阻塞的M与P解绑,然后把P转交给其他空闲的M,保证P始终在干活。这种设计让Go在高并发网络服务中表现出色,单个进程支撑数十万活跃连接是很常见的。
和虚拟线程相比,两者思路殊途同归:把并发单元从内核线程的束缚中解放出来,由运行时/虚拟机在用户态完成调度。Go的调度器更激进,在运行时设计之初就内置了网络轮询器(Net Poller),文件描述符、Socket事件都由它统一驱动;JDK 21的虚拟线程同样会在java.net的阻塞式IO、Socket读写、HTTPClient调用上自动释放载体线程。两者在IO密集场景下的表现非常接近,真正的差异在CPU密集型任务、锁竞争、内存模型和生态集成这些更细微的层面。
2.3 一张表说清虚拟线程和goroutine的核心差异
| 对比维度 | Java 21 虚拟线程 | Go goroutine |
|---|---|---|
| 并发单元 | 虚拟线程(JVM管理) | goroutine(Go Runtime管理) |
| 初始栈大小 | 约几十KB起步(可动态调整) | 约2KB起步(动态增长) |
| 调度器 | ForkJoinPool | G-M-P模型 |
| 阻塞点处理 | 自动卸载/重新挂载 | 自动切换,配合Net Poller |
| 嵌套阻塞 | 支持 | 不支持(局部依赖类似支持) |
| 锁机制 | synchronized与ReentrantLock | channel与sync.Mutex |
| 内存占用(10万并发) | 每虚拟线程几百字节至几KB | 每goroutine约几KB |
| 生态集成 | 绝大多数Java库无需改造 | 使用标准库/net包 |
| 隔离性 | 由JVM统一管理 | 由Runtime统一管理 |
要明确的是,这张表只描述“轻量级并发单元”这一层面的差异。真正选型时还要考虑业务语言栈、中间件兼容性、招人难度、运维体系等外部因素,单纯比较某个维度没有意义。
3. 性能实测:16C32G高并发场景下,两者真实水平如何
3.1 测试场景设计与JMeter参数设置
“16C32G服务器支持多少并发”这个问题没有标准答案,取决于业务形态。我以自己的一个压测场景为例:一个模拟IM消息推送接口,每个请求处理大致包括解析参数、查询Redis用户关系、组装消息、写入消息队列,整体逻辑偏IO密集,单请求平均处理时间约80-120ms。
我用两台16C32G的云主机做对照测试,一台部署Java 21应用(启用虚拟线程),一台部署Go 1.22应用。压测工具选JMeter 5.6.3,配置了常见的并发测试参数。需要说明的是,JMeter的“线程数”实际是并发用户数,用虚拟用户数模拟真实用户操作,与服务器端的线程/协程数不是一回事。
JMeter关键参数参考如下:
| JMeter参数 | 本次设置 | 说明 |
|---|---|---|
| Number of Threads (users) | 5000 | 模拟5000个并发用户 |
| Ramp-Up Period (seconds) | 30 | 30秒内逐步加压,避免瞬间雪崩 |
| Loop Count | 20 | 每个用户循环执行20次请求 |
| HTTP Request Timeout | 10000ms | 超过则视为超时 |
| 监听器 | Summary Report + Aggregate Report | 记录吞吐量、平均响应时间、错误率 |
这里有个很典型的经验:第一次压测时我用3000并发直接在JMeter里乱打,结果还没等到服务器有问题,压测机自己的线程就先撑不住了,CPU飙到90%,响应时间数据全变形。后来改成Ramp-Up 30秒,把并发用户数从5000逐渐拉起,数据才恢复正常。在压测前先确认JMeter本身的机器性能充足,至少不能比被测服务器差太多。
3.2 实测数据与结果分析
我跑了三轮测试,取中间态数据,这里列出一个代表轮次的结果。请求场景为IM消息推送接口,样本量为10万次左右,每轮持续10分钟。
| 指标 | Java 21 (虚拟线程) | Go 1.22 (goroutine) |
|---|---|---|
| 总样本数 | 100,000 | 100,000 |
| 平均响应时间 | 112ms | 106ms |
| P95响应时间 | 168ms | 155ms |
| P99响应时间 | 245ms | 232ms |
| 吞吐量 (TPS) | 10,200 | 10,800 |
| 错误率 | 0.00% | 0.00% |
这个结果在IM推送类的IO密集场景下,两者几乎没有明显差距。Go略微领先一点点,大约在5%-8%之间,但这是因为Go标准库的网络栈更原生地贴合goroutine调度。Java虚拟线程首次全面落地就能追到这种水平,已经很能说明问题了。
再压一组CPU密集场景,例如计算MD5哈希加JSON序列化,Java虚拟线程的吞吐量却明显低于Go goroutine,大约只有Go的60%。原因在于CPU密集任务没有阻塞点,虚拟线程的调度优势发挥不出来,反而因为JVM的ForkJoinPool调度开销和GC停顿,削弱了纯计算性能。可见“虚拟线程=万能并发特效药”这个想法是不对的。
换句话总结:IO密集、阻塞频繁的服务,Java虚拟线程可以打平甚至逼近Go;CPU密集、无阻塞的纯计算服务,Go依然有自己的优势。
3.3 高并发IM这类业务到底能在16C32G上扛多少并发
这个问题终于可以正面回答了。以刚才的压测数据为基准做换算。16C32G的服务器跑Java 21虚拟线程,在IM消息推送这种IO密集型场景下,实测TP99约245ms,吞吐1万TPS,同时在线能力取决于长连接数而非单次请求TPS。如果做WebSocket长连接推送,虚拟线程的模型意味着每个连接可以分配一个虚拟线程,理论支撑50万-80万长连接是可以预期的,但前提是绑定的平台线程数和内存足够。实际操作中,我会蹲在JVM参数上做调整,堆内存至少8-12GB,GC使用ZGC或G1,虚拟线程载体线程数设置到Runtime.getRuntime().availableProcessors()附近。拿16C32G来说,默认载体线程数是32左右,够了。
如果换成Go实现同样的长连接服务,支撑50万以上长连接也是常见水平。两者在连接数量级上没有本质差异,真正的差异出现在连接少、单连接消息量大时,Go的Net Poller优势会明显一些;连接多、消息频率中等时,虚拟线程的模型更贴近Java团队的日常开发习惯。
4. 选型判断:什么场景该上虚拟线程,什么场景继续用Go
4.1 虚拟线程最舒服的落地场景
从我的实践来看,下面几类项目最适合优先考虑虚拟线程:
- 现有Java微服务改造。Netty、Reactor、WebFlux那套异步链路的代码,如果团队消化起来吃力,可以直接改成同步风格,再开启虚拟线程。最典型的例子是Spring Boot 3.2之后的版本,在
application.properties里设置spring.threads.virtual.enabled=true,Tomcat就会用虚拟线程处理请求,代码完全不用改。这在存量系统升级时的收益非常大。 - 高并发IM、消息推送、聊天室。长连接、轻逻辑、高阻塞,天然适配虚拟线程“一个连接一个线程”的模型。
- 网关、API聚合层。同时调用多个下游服务,大量时间在等待,虚拟线程可以轻松做到并行等待。
- 数据库IO密集的Web应用。传统JDBC阻塞调用,换成虚拟线程后无需再纠结连接池大小,连接池配置可以适当放宽。
关键在于,这些场景都有一个共性:任务数量远大于CPU核心数,每个任务的大部分时间都在等待外部资源。虚拟线程让“等待”几乎不占资源,所以吞吐量能成倍增长。
4.2 继续用Go的场景
以下情况,我不建议盲目切换到虚拟线程:
- CPU密集计算服务。图像处理、音频转码、数据清洗这类场景,Go的调度开销更低,性能更稳定。
- 高频交易、低延迟系统。Go既然做到了从语言到运行时的一体化设计,延迟控制能力强,Java虚拟线程的ForkJoinPool调度、GC停顿都会引入额外延迟抖动。
- 短平快的云原生工具。比如CLI工具、Operator、Agent进程,Go编译成单个静态二进制文件,部署方便,根本不需要JVM。
- 技术栈已经深度绑定Go的团队。为了一项特性全部推翻重写,迁移成本太高,收益又不明确。虚拟线程的价值是让Java团队不必为了并发转移,而不是诱导Go团队回迁。
技术选型永远是团队现状与业务目标共同作用的结果。Loom项目确实抹平了Java与Go在“语言级轻量并发”上的差距,但“抹平差距”不等于“取代Go”。这两门语言在后续的生态、运维、招人成本上,各有各的取舍。
4.3 迁移前必须知道的成本清单
虚拟线程本身API简单,但迁移改造是另外一回事:
- 容器化与JDK版本:JDK 21是LTS版本,但很多公司的容器镜像还停留在JDK 8或JDK 11。升级JDK不只是改一个版本号,要检查字节码兼容性、依赖库的反射使用、JVM参数调整,这是个系统工程。
- 中间件兼容性:数据库连接池、Redis客户端、消息队列客户端,大部分主流库已经适配虚拟线程,但某些老旧的、依赖
ThreadLocal的库会在虚拟线程环境下出问题。 - 动态代理与字节码增强:Spring AOP、CGLIB、MyBatis这些常见框架在设计时默认线程模型为“线程不可变”,在虚拟线程下可能出现意外行为。目前Spring 6.1已经对虚拟线程做了适配,但如果你用的是Spring 5.x,那还得三思。
- 监控与Metrics:线程数这个指标在虚拟线程下不再有传统意义,监控体系要调整,要重点观测载体线程的使用率、虚拟线程的创建速率和挂起时间。
想明白这些成本,再决定是否迁移,会比单纯看技术特性更稳妥。
5. 实操转型:把现有Java项目改成虚拟线程的完整路径
5.1 步骤一:升级JDK并开启虚拟线程支持
这一步没有技术难度,但操作影响面大。以Spring Boot项目为例,先升级pom.xml中的Java版本:
<properties> <java.version>21</java.version> <spring-boot.version>3.2.5</spring-boot.version> </properties>启动类不变,然后在application.yml中开启虚拟线程:
spring: threads: virtual: enabled: true从Spring Boot 3.2开始,这一步会让内置的Tomcat、Jetty容器默认用虚拟线程处理HTTP请求。如果你的项目不是Spring Boot,而是一个原始Servlet容器,可以自己在启动时创建虚拟线程执行器:
var executor = Executors.newVirtualThreadPerTaskExecutor();然后把这个执行器注入到业务代码里,替代原来的ExecutorService。
5.2 步骤二:处理几个代码层面的“定时炸弹”
改造过程并不只是改配置。下面这几类问题在虚拟线程环境中会显现出来,需要优先处理:
**第一类:ThreadLocal的不当使用。**虚拟线程数量巨大,而且会复用,ThreadLocal里如果存放了连接、用户信息等对象,可能造成内存泄漏或者数据串线。建议使用ScopedValue(JDK 21提供)替代一部分ThreadLocal场景,或者在每次请求结束后显式清理ThreadLocal。框架层面的安全上下文、TraceId传递等,要仔细测试。
**第二类:synchronized导致的钉住问题。**虚拟线程进入synchronized块时,如果发生阻塞等待锁,会钉住当前载体线程。虽然JDK 21已经对synchronized做了很多改进,但锁竞争激烈时依然会造成载体线程不足。改造时优先把synchronized换成ReentrantLock,或者在锁里面只放极短的临界区代码,减少竞争概率。
**第三类:线程池滥用。**虚拟线程出现后,很多地方就不需要自定义线程池了。凡是执行短小的IO任务、又不想排队等待的,直接使用虚拟线程执行器。但这里要区分开:阻塞队列、需要控制背压的系统,依然需要线程池来限制流量,虚拟线程无界创建的特性反而容易把下游拖垮。比如消息消费、批量数据拉取,如果无脑用虚拟线程并发拉取上万次,数据库很可能会被打挂。
5.3 步骤三:压测验证与调优
迁移完成后,立刻跑一轮与原有模型一致的JMeter压测,对比改造前后的TPS和响应时间。需要注意几点:
- 如果发现吞吐量反而不升,先检查锁竞争。使用
jcmd Thread.dump_to_file抓取线程转储,重点看虚拟线程的“pinned”状态。 - 如果发现载体线程利用率长期100%,且虚拟线程大量等待,说明有操作没有放开阻塞点。检查是否在代码里用了
Object.wait、Thread.sleep、SocketInputStream.read等阻塞调用,确保JDK版本支持虚拟线程的自动卸载。 - GC参数建议使用ZGC,低延迟模式下,虚拟线程的调度停顿更平滑。
5.4 步骤四:逐步替换异步框架
Spring WebFlux、RxJava、CompletableFuture这类异步代码,在新项目中可以逐步用同步代码替换。我的个人建议是:先替换链路最深、最难读的模块,比如用户请求入口、聚合服务;存量异步模块保持原样,待单元测试和压测充分覆盖后再动。
实际改造中,我见过团队把WebFlux整个翻成Spring MVC加虚拟线程,代码量直接砍掉三分之一,接口可维护性大幅提升。但那是幸运的案例。如果异步链路已经稳定运行、性能达标,没有明显的维护痛苦,就不必为了“先进”而强行翻写。技术是服务于业务的,不是服务于KPI的。
5.5 避坑清单:我踩过的几个真实问题
**问题1:连接池设置没跟着调。**HikariCP默认最大连接数只有10,改造虚拟线程后并发量上去了,数据库连接反而成了瓶颈,大量虚拟线程在等连接。我当时的处理是把最大连接数提高到50,同时加上连接超时告警。不同数据库类型要针对性评估,不要一调了之。
**问题2:线程转储文件过大。**虚拟线程可以同时存在几万个,jstack生成的线程转储有几万行,常规排查工具直接卡死。我用jcmd Thread.dump_to_file -format=json转出结构化线程转储,再用脚本分析虚拟线程的阻塞点和状态分布,效果好了很多。
**问题3:日志系统没适配。**Logback的异步Appender在线程模型上按传统线程池设计,虚拟线程场景下,我看过它自己排队排到内存溢出。后来换成Log4j2并开启虚拟线程支持,问题才稳定下来。如果日志框架本来就扛得住压力,那可以不换,但日志格式里线程名一定要带虚拟线程标识,不然排查起来找不到对应请求。
6. 常见问题与排查技巧实录
6.1 虚拟线程的典型生产问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 并发吞吐量不升反降 | synchronized钉住载体线程、锁竞争激烈 | 抓线程转储,查看pinned线程数量;替换成ReentrantLock |
| 请求偶尔超时,CPU不高 | 虚拟线程阻塞等待连接池/下游 | 检查数据库连接池和HTTP客户端最大连接数 |
| 内存缓慢增长 | ThreadLocal泄漏 | 使用jcmd或MAT分析堆转储,定位ThreadLocal持有对象 |
| 大量线程处于WAITING | 线程数过多、调度压力大 | 优化阻塞逻辑,减少无界并发任务 |
| 偶发“OutOfMemoryError: unable to create native thread” | 平台线程数量被耗光,通常是本地库或IO操作未自动卸载 | 检查是否有JNI、本地方法或synchronized |
6.2 JMeter压测时最容易被忽视的参数细节
很多人在使用JMeter做接口并发测试时,只是无脑填线程数然后启动,这样的结果可参考性很低。我建议重点关注这几个参数:
Ramp-Up Period:线程数从0增长到目标值所需的时间。直接跑5000线程时,所有连接瞬间建立,会人为放大TCP握手与连接建立的负担。Ramp-Up设为30-60秒更贴近真实用户的分散进入。
Duration与Loop Count的配合:如果只跑几秒钟,取样数据太少,P99根本不准。通常我会让测试持续5-10分钟,循环次数根据目标总请求量计算。
HTTP Keep-Alive开关:如果需要模拟真实用户的长连接行为,开启Keep-Alive;如果模拟短连接请求,则关闭。虚拟线程在高并发短连接场景下的建连开销跑满时,表现会低于长连接场景,这两套数据要分开看。
参数化请求:热搜词里“jmeter 并发十个参数不同的post请求”就是典型的参数化问题。在JMeter中给每个线程分配不同的参数,可以通过“CSV Data Set Config”读文件,或者用${__Random()}函数生成随机值。做IM推送压测时,我用了CSV文件保存10000个用户ID,每个线程循环取不同的用户,避免所有请求打在同一把锁或者同一个Redis key上,这样测出来的才是真实并发容量。
监听器的选择:聚合报告(Aggregate Report)比图形结果(Graph Results)更适合分析。图形结果在高压测试下绘制大量数据点,会消耗压测机自身的CPU,反过来干扰测试数据。
6.3 16C32G实测中的参数参考
如果你也准备在自己环境中跑一轮,这里给一组可以直接套用的参数方向(不是绝对标准,只是起点):
| 参数项 | 参考值 | 备注 |
|---|---|---|
| Java堆内存 | 12G | 与JVM其他内存隔离,避免堆挤占栈空间 |
| GC | ZGC | 低延迟,配合虚拟线程更稳 |
| 载体线程数 | 32 | 对应16核CPU,按需调整 |
| Tomcat max-threads | 不再关键 | 虚拟线程模式下可由框架自动处理 |
| HikariCP maximumPoolSize | 50 | 依据数据库QPS上限调整 |
| JMeter线程数 | 5000 | 按Ramp-Up逐步压测 |
需要强调,具体数值必须通过实际压测反馈调整。先跑小并发,比如500并发,观察性能曲线;再逐步翻倍到1000、2000、5000,直到响应时间出现明显拐点。这才是测定“16C32G服务器支持多少并发”的正确方法,直接抄别人写的数值意义不大。
7. 最后的经验之谈
回头再看那个“终结神话”的标题,我的结论是:Java 21虚拟线程没有终结Go的并发神话,但它确实终结了“Java的线程模型落后于Go”的刻板印象。这两者现在更像是太极拳对上了咏春拳——发力方式有差别,但都能把人打疼。虚拟线程让Java团队可以在不换语言的前提下拿回高并发主动权,Go则继续在自己擅长的低延迟、高密度服务领域深耕。
我自己在实际项目中的体会是:虚拟线程最不真实的地方,就是它太像普通线程了。正因为像,所以总容易低估它背后的调度逻辑,总容易忘了它也有锁、有钉住、有ThreadLocal的坑。用好了,它能救回好几套苟延残喘的老系统;用不好,它只是把传统的并发问题换了一张新皮。
最后再分享一个小技巧:如果你刚开始尝试虚拟线程,不要在核心线上业务直接全量铺开,挑一个流量不大、逻辑清晰、有完整监控的服务先跑两周。成功后你会看到线程转储里密密麻麻的虚拟线程,也会更加理解——在这个领域,造轮子远比喊口号有价值。