JVM 系列的理论三篇已经把内存与类加载、垃圾回收以及JIT 与调优的底层机制讲透了。但落到工程实践上,还有一个绕不开的核心问题:线上到底该钉在哪个 JDK 版本?从 8 升到 17、21 乃至 25,除了语言特性的更迭,运行时机制和升级成本究竟变了什么?
本文是 JVM 系列的第 4 篇(共 6 篇)。 全系列:
- 入门篇 · 内存架构与类加载
- 核心篇 · 垃圾回收全解
- 进阶篇 · JIT、内存模型与调优
- JDK 版本差异精讲(8 → 25)(本篇)
- Grafana JVM 监控分析
- Arthas 诊断
一句话定位:前三篇的底层机制最终都钉在某一版 JDK 的默认值上,本篇以 LTS 为主线串联从 8 到 25 的运行时变迁、废弃禁区与生产选型路径。
本篇不以长篇累牍的 JEP 清单铺开,而是以 LTS(Long-Term Support) 为主线,把真正影响代码写法、默认行为与业务迁移成本的关键差异讲清楚,并给出可落地的生产选型建议。
TL;DR
- 生产跟 LTS:演进主线是 8 / 11 / 17 / 21 / 25;自 9 起每 6 个月发布一版,自 21 起 LTS 约两年一档;非 LTS 适合验证尝鲜,不适合作为长期基线。
- 运行时三连:① 8:PermGen → Metaspace;② 9+:默认 GC 切换为 G1(CMS 于 14 移除);③ 21:虚拟线程正式引入 + 分代 ZGC。
- 语言主线:8 引入 Lambda/Stream → 11 带来
var/HttpClient/模块化落地 → 17 沉淀 Records/Sealed/Pattern Matching/Text Blocks → 21 主打虚拟线程 + patternswitch→ 25 使得 Scoped Values 成为正式特性。 - 虚拟线程要点:极度轻量,挂载在少量 carrier(载体)平台线程 上;遇到阻塞 I/O 时可 unmount;在
synchronized或 native 方法内可能发生 pinning —— 绝非无脑比线程池快。 - 升级高频坑:Java EE 模块移除、内部 API 强封装限制反射、
-XX:+PrintGCDetails参数失效、若仍开启 CMS 会导致进程直接无法启动。
Table of contents
Open Table of contents
1. 先搞清:什么是 LTS
JDK 9 之后,Oracle 与 OpenJDK 社区采用了每 6 个月发一版的节奏。在这些版本中,只有少数被标记为 LTS(长期支持版):厂商会为其提供更长周期的安全更新与漏洞补丁,且整个技术生态(包括 Spring、各类中间件、云原生镜像、监控 Agent)也会优先去对齐这些版本。
| 角色 | 版本 | 含义 |
|---|---|---|
| LTS | 8、11、17、21、25… | 生产基线;支持周期以年计 |
| 非 LTS(Feature Release) | 9–10、12–16、18–20、22–24… | 功能试验田;通常只维护到下一版发布 |
一句话本质:非 LTS 是功能快递与短期试验田,而 LTS 是能够长期承载生产流量的稳固基站。当团队缺乏专人持续跟进版本演进时,钉 LTS 几乎总是更优解。
自 21 起 LTS 约两年一档;图中箭头标注相邻 LTS 的大致间隔。
绿 = 语言与标准库;蓝 = 运行时 / 性能;粉 = 移除或强约束 —— 升级时粉条最容易踩坑。
| 关键变化 | 哪版 | 一句话 |
|---|---|---|
| PermGen → Metaspace | 8 | 类元数据出堆并使用本地内存,有效缓解 PermGen space OOM |
| 默认 GC | 9+ | Parallel → G1;CMS 在 14 被彻底删除 |
| 模块系统 JPMS | 9 / 11 LTS | JDK 自身实现模块化;应用层可不写 module-info,但内部 API 渐难反射 |
| Lambda / Stream | 8 | 行为参数化,配合声明式集合数据流水线 |
| Records / Sealed / Pattern Matching | 16–17 | 引入透明数据载体、限制继承白名单以及 instanceof 模式绑定 |
| 虚拟线程 | 21 | 由 JVM 调度的轻量线程,成为解决 I/O 密集型高并发的现代答案 |
| Scoped Values | 25 正式 | 虚拟线程时代替代部分 ThreadLocal 职能的作用域共享机制 |
2. 运行时关键差异:Metaspace、G1、日志
语言特性看得见、摸得着,但默认 GC 的更替、方法区底层实现的切换、以及日志开关规则的变化,却经常在服务升级后才以线上事故的形式暴露出来。
2.1 PermGen → Metaspace(JDK 8)
| 永久代(≤7) | 元空间(8+) | |
|---|---|---|
| 存什么 | 类元信息、方法代码、常量池等 | 同类信息(字符串常量池 JDK 7 已先挪进堆) |
| 内存位置 | JVM 堆内一块固定且极难扩容的区域 | 本地内存(Native),默认可按需弹性伸缩 |
| 典型故障 | java.lang.OutOfMemoryError: PermGen space | Metaspace(常因反射、CGLIB 或热部署导致动态类过多) |
| 调参 | -XX:PermSize / MaxPermSize | -XX:MetaspaceSize / MaxMetaspaceSize |
演进逻辑:永久代的大小在启动时极难准确预估,一旦加载的类过多就会直接触发 OOM。为了解决这个痛点,JDK 8 引入了使用本地内存的元空间,默认具备更强的弹性扩容能力。需要强调的是,这并不意味着「永远不会 OOM」——如果框架动态生成类的行为失控,照样会把 Metaspace 打满。正因如此,虽然对象实例依然在堆上分配,但类元数据已经被整体移到了元空间,这正是入门篇中方法区实现变迁的核心所在。
2.2 默认 GC:Parallel → G1,CMS 退场
- JDK 8:服务端的常见默认垃圾收集器是 Parallel GC(以吞吐量优先,但 STW 停顿相对明显)。
- JDK 9+:G1 成为默认选择。它引入了基于 Region 的内存划分,并允许设定软性的停顿时间目标(
-XX:MaxGCPauseMillis),因此更加适合分配了中大堆的在线服务(底层机制详见核心篇)。 - CMS 退场:作为并发老年代回收的开拓者,CMS 带来了难以根治的内存碎片与极高的代码维护成本。最终,它在 JDK 9 被废弃,于 JDK 14 被彻底移除。如果升级后应用的启动脚本里还残留着
-XX:+UseConcMarkSweepGC参数,JVM 进程会直接报错起不来。 - 低延迟选型:ZGC 与 Shenandoah 主攻极低延迟场景。JDK 21 引入了分代 ZGC,JDK 25 则带来了分代 Shenandoah。只有当业务使用大堆且对稳定低停顿有严苛要求时,才需要专门评估这两款收集器。
2.3 GC 日志:-XX:+PrintGCDetails → -Xlog:gc*
随着统一日志框架的引入,曾经熟悉的旧旗标(如 -XX:+PrintGCDetails、-XX:+PrintGCDateStamps)在较新版本中逐步失效或被替代。新的配置规范变更为 -Xlog:gc*(可按需叠加 gc+heap、gc+age 等标签)。需要注意的是,统一日志并非只针对 GC,它是整套 JVM 状态暴露的基础。
3. 语言与 API:从 8 到 17
3.1 JDK 8:Lambda、Stream、接口默认方法
- Lambda:允许将「一段行为」作为参数传递给方法,通常与函数式接口配合使用。其底层依赖
invokedynamic指令与方法句柄(Method Handle),由 LambdaMetafactory 在运行期动态生成调用位点。虽然在语义上接近匿名内部类,但在实现层面绝非简单的语法糖。 - Stream:提供了声明式的数据处理流水线(
filter/map/collect),且中间操作是惰性的,只有执行终端操作时才会触发真正的计算。需要注意的是,并行流并非总是更快,它要求任务规模足够大,且必须避免共享可变状态带来的并发问题。 - 接口
default/static方法:使得底层库能够在不破坏已有实现的前提下平滑演化接口。到了 JDK 9,接口还支持定义private方法,供默认方法内部复用逻辑。 java.time:用以全面替代设计易错的Date与Calendar体系,并在处理时区时推荐使用ZoneId。CompletableFuture:作为异步任务编排的入口(细节详见 JUC 篇)。
顺带一提集合类的底层实现演进:自 JDK 8 起,当 HashMap 中的哈希冲突导致链表过长(默认树化阈值 8,且容量 ≥ 64)时,结构将转为红黑树,从而把最坏查找时间复杂度从 O(n) 降至 O(log n);同时扩容时的插入方式由头插改为尾插,有效降低了并发场景下形成死循环的风险(尽管 HashMap 本身仍非线程安全,并发场景下必须使用 ConcurrentHashMap)。
3.2 JDK 11:var、HttpClient、模块化落地
var(自 10 起):提供局部变量类型推断。这要求在声明处必须能推断出具体类型,本质上仍是强类型而非动态类型。它不能用于字段或方法参数的声明,仅作为局部变量的语法简化最为稳妥。- HttpClient:标准库内置支持 HTTP/1.1 与 HTTP/2 的异步 HTTP 客户端。
java Xxx.java单文件启动:极大地方便了小工具与脚本场景的直接运行。
模块系统(JPMS,9 引入,11 作为 LTS 广泛落地)
JDK 11 最深远的影响在于模块化。首先,JDK 自身完成了模块化拆分,强化了内部代码的封装,有效遏制了过去「随意反射进入 sun.* 包」的乱象。对于应用层而言,虽然可以继续沿用传统的 classpath(即不写 module-info),但在升级后会越来越频繁地遇到非法反射访问的警告甚至报错。
在具体实践中,核心关键词包括 module-info.java、requires / exports,以及模块路径与类路径的严格区分。这也带来了最典型的升级痛点:诸如 JAXB 等 Java EE 与 CORBA 相关模块已从 JDK 中彻底移除,若业务仍需使用必须显式引入外部依赖;同时,像 sun.misc.BASE64Encoder 这样的内部 API 也不再允许直接依赖。
3.3 JDK 17:Records、Sealed、Pattern Matching、Text Blocks
JDK 17 将 12 至 16 预览期间沉淀的一批高质量语言特性固化为正式能力,它也是 Spring Boot 3 等现代生态对齐的基础版本(要求 JDK 17+)。
// Records:不可变数据载体,自动生成 equals/hashCode/toString/accessor
record Point(int x, int y) {}
// Pattern Matching for instanceof(16 正式,17 生态标配)
if (obj instanceof String s) {
System.out.println(s.toLowerCase());
}
// Sealed:限制谁可以继承,配合 switch 做穷尽检查
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
- Record:其核心语义是「透明的数据载体」,默认被修饰为 final 且组件不可变。它非常适合用作 DTO,但不适合充当包含复杂可变状态的业务实体。
- Sealed:通过将继承树收拢进白名单,使得后续的模式匹配能够进行严密的穷尽性检查。
switch表达式:支持返回值与箭头分支语法,极大地减少了漏写break的问题。到了 JDK 21,它进一步升级为 pattern switch(支持按类型或 record 解构进行复杂匹配)。- Text Blocks(15 正式):引入
"""..."""多行字符串语法,让拼接 SQL、JSON 或 HTML 变得更加清晰易读。
在运行时侧,G1 的默认地位此时已十分稳固。与此同时,强封装 JDK 内部 API 的策略默认拒绝了大量非法反射。过去那些依靠 --add-opens 参数勉强运行的陈旧依赖库,到了 JDK 17 往往会彻底暴露兼容性问题,这正是许多老项目升级时遇到的首要阻力。
4. 并发分水岭:虚拟线程(JDK 21)
JDK 21 是近年来对服务端架构影响最深远的 LTS —— 核心关键词是 Virtual Threads(虚拟线程,Project Loom)。
| 平台线程(Platform Thread) | 虚拟线程(Virtual Thread) | |
|---|---|---|
| 调度 | 操作系统(OS)调度 | JVM 调度 |
| 成本 | 栈通常占用 MB 级,几千到上万就吃紧 | 极轻量,可支撑百万级并发任务 |
| 模型 | 依赖线程池进行精心限流与复用 | 使得「一个请求一个线程」的模型重新成立 |
| 擅长 | CPU 密集型任务仍需靠它扛住 | 阻塞 I/O 密集型(吞吐型并发) |
机制三词
- Carrier(载体线程):海量的虚拟线程实际上是挂载在少量的平台线程之上运行的。
- Mount / Unmount:当虚拟线程遇到阻塞 I/O 时,它可以从载体线程上卸载(unmount),让出载体线程去执行其他就绪的虚拟线程;待 I/O 完成后,再重新挂载(mount)继续执行。
- Pinning(钉住):如果在虚拟线程内部持有了
synchronized监视器,或者进入了某些 native 栈帧,虚拟线程可能无法被卸载,从而将载体线程死死钉住(pinning),导致系统吞吐量回落。在 JDK 21 至 23 版本上,应对策略是优先使用ReentrantLock等 JUC 锁,并避免在虚拟线程中堆积繁重的 CPU 计算或大内存的线程局部状态;同时可通过添加-Djdk.tracePinnedThreads参数或 JFR 的VirtualThreadPinned事件来监控此类现象。需要强调的是,JDK 24(JEP 491) 已经对该机制进行了改进,使得在synchronized块内发生的阻塞不再引发 pinning。
// JDK 21:按任务创建虚拟线程 —— 适合大量阻塞调用
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> httpClient.send(request, BodyHandlers.ofString())));
}
虚拟线程让开发者能够以同步的写法获得接近异步回调架构的极高伸缩性。它的初衷并不是要彻底取代 Netty 这类极致优化的底层网络栈,而是为了消除业务层面上「手写复杂异步回调链」的心智负担。需要强调的是,CPU 密集型任务依然需要严格控制并行度,不要幻想「开一百万个虚拟线程去算矩阵乘法会更快」。此外,它也不会一刀切地取代线程池:对于 I/O 密集型、高并发任务,可以使用虚拟线程来简化并发模型;但如果业务需要严格限制资源访问速率,或者线程需要绑定极其昂贵的资源对象时,传统的平台线程池依然是合适的选择。
21 同期值得记住的演进
- Generational ZGC:年轻对象引入分代回收机制,让这条追求低延迟的路线更敢于在通用业务服务中落地。
- Sequenced Collections:统一了「具有明确顺序」的集合抽象,补齐了诸如
getFirst、reversed等一致性 API。 - Record Patterns 与 pattern switch:使得数据结构的解构和模式匹配能力终于补齐,极大增强了数据流向处理的表达力。
5. JDK 25:当前最新 LTS
JDK 25 于 2025-09-16 正式 GA,接力 JDK 21 成为最新的 LTS 版本。相对于 21,它更加聚焦于 运行时效率提升、AOT 预热易用性、可观测性增强,同时完成了若干长期酝酿 API 的正式收官。
| 能力 | 含义 |
|---|---|
| Scoped Values(正式) | 提供作用域内的只读共享机制,在虚拟线程海量并发场景下,比滥用无上限的 ThreadLocal 更安全且语义更清晰。 |
| Compact Object Headers | 压缩对象头,直接降低堆内存占用 —— 这正呼应了入门篇中探讨的对象头结构知识。 |
| 分代 Shenandoah | 与分代 ZGC 并列,为低延迟 GC 阵营提供了又一强有力的可选项。 |
| AOT / Leyden 易用性 | 大幅改善应用的冷启动速度与预热表现(这也对照了进阶篇中详述的 JIT 预热痛点)。 |
| Preview / Incubator 特性 | Structured Concurrency、Primitive Patterns、Vector API 等尚未稳定,核心业务路径上切勿盲目押注。 |
定位:新项目首推 JDK 21(这也是目前生态最为成熟的「现代 Java」基线);如果项目已经在 21 上运行良好且三方依赖均已支持,再去进一步评估 JDK 25 的升级收益。永远不要把 Preview 阶段的特性当作稳定契约写进核心代码中。
6. 升级路径与选型
稳字当头:先离开失控的旧基线,再按生态成熟度跃进。
| 现状 | 建议 |
|---|---|
| 仍在 8 | 尽快规划迁出;根据 Spring 等底层框架的约束,优先将目标定为 17 或 21。 |
| 停在 11 | 规划向 17/21 升级;重点排查和处理模块封装限制与 Java EE 依赖缺失。 |
| 已在 17 且稳定 | 可暂时防守;新服务直接采用 21,存量系统则择机跳跃升级。 |
| 新项目 | 默认定在 21;如果团队有意愿跟进最新 LTS 且依赖库支持良好,则评估 25。 |
| 低延迟交易类 | 在 JDK 21+ 环境下重点评估分代 ZGC;如果上了 25,可对比考察分代 Shenandoah 与对象头压缩带来的额外收益。 |
跨版本升级的最小闭环
- 依赖扫描:使用
jdeps、构建插件的 bytecode 基线检查工具,并确认第三方组件的官方支持矩阵。 - 编译到目标
--release:开启全部警告,首先消灭所有的非法访问与已移除 API。 - 同负载压测:在测试环境中严格对比 P99 延迟、GC 停顿时间、CPU 利用率以及 RSS(常驻内存)。由于新版 GC 日志格式变了,监控看板的抓取规则也要同步修改。
- 灰度发布:先在非核心实例上试刀,观察无异常后再按错误预算逐步放量。
升级路上的头号拦路虎依然是:非法反射调用 + 依赖了已移除的 API + 启动脚本里写死了过时的 GC 或日志参数。
一般而言,通用在线服务使用 G1 通常就够用了;只有当应用配置了极大的堆内存且对稳定低停顿有严苛要求时,才需要考虑换上 ZGC(在 JDK 21+ 优先选用分代 ZGC)。正如在核心篇中所强调的,垃圾回收从来没有绝对的更快,只有在停顿、吞吐量和内存占用这「铁三角」之间的权衡与取舍。
7. 和本系列前几篇怎么对上号
| 理论篇知识点 | 版本演进里对应的变化 |
|---|---|
| 方法区 / 对象头 | Metaspace(JDK 8);Compact Object Headers(JDK 25) |
| G1 / ZGC / 分代 | 9+ G1 成为默认;21 引入分代 ZGC;25 引入分代 Shenandoah |
| JIT 预热 | Project Leyden 与 AOT 相关能力在持续改善应用的冷启动与预热阶段 |
| 线程与栈成本 | 21 虚拟线程登场;25 Scoped Values 正式确立 |
和前几篇对上号:理论三篇讲透的是底层机制,而版本演进则是这些机制在时间轴上的具体落点。只有把这两者叠加在一起,线上的选型与调参才不会沦为拍脑袋的「玄学」。
当底层机制与版本基线都梳理清楚后,要想真正在线上扛住高并发流量,就必须把 JVM 的运行状态拉出来展示在看板上。接下来,请继续阅读 Grafana JVM 监控分析 与 Arthas 诊断。
延伸阅读
- Oracle. Java SE Support Roadmap —— LTS 支持周期。
- Oracle. Significant Changes in the JDK —— 官方迁移视角的版本差异。
- OpenJDK. JDK 21 · JDK 25 · JEP Index —— 以 JEP / GA 为准,Preview 勿当稳定契约。