Java 9到16不是过渡版本,而是Java 17的坚实底座
前几天有人在技术群里问Java 8 和 Java 17 都成了事实上的标准那夹在中间的 Java 9 到 16 是不是白出了说实话这个问题我遇到过不止一次。每次和团队聊版本升级都会有人把 9 到 16 看成“过渡版本”甚至“失败版本”。但我个人一直觉得把这一段版本史仅仅定义为过渡是低估了 Java 社区在那四年里做的事情。Java 9 到 16 不仅不是毫无存在感反而把模块化、现代语法、容器适配、性能工具一个接一个地磨了出来最后汇成了 Java 17 这个让所有人都比较满意的 LTS。这篇文章不是一份 Release Notes 的逐条翻译而是我从实际项目迁移和团队升级过程中总结出的版本演进地图。适合正在 Java 8 上挣扎、计划跳到 17 但不知道中间发生什么事的团队也适合已经在 17 上工作、想回头补课的同学。你可以把它当一份“9 到 16 为什么存在”的答疑手册也可以直接拿来当迁移前的特性清单。先说结论中间版本当然有用而且它们的价值比你想象的要大得多。1. 为什么 Java 9 到 16 经常被忽略先看版本节奏的底层逻辑1.1 从发布周期变革说起LTS 与中间版本的分工Java 8 是 2014 年发布的距离 Java 9 隔了将近三年。这三年里社区一直在争论平台是不是太慢了于是后来启动了“六年一大版本、一年两小版本”的快速迭代计划。Java 9 因为模块系统等大工程延期到 2017 年 9 月才发布但从 Java 10 开始节奏就变成了每半年一次。Java 11 是 2018 年 9 月发布的 LTSJava 17 是 2021 年 9 月发布的另一个 LTS。也就是说在 9 到 16 这个区间里只有 11 是 LTS其他都是“特性版本”或者说非 LTS 版本。非 LTS 版本和 LTS 版本的核心区别是维护期限。LTS 版本会提供多年的安全更新和问题修复而非 LTS 版本的生命周期非常短通常只维护到下一个版本发布。对绝大多数企业来说安全补丁比新语法重要得多所以大家自然会盯着 8 和 17中间版本就容易被当成“试验品”。但我认为这种“试验”本身就是设计的一部分。半年一个版本的目的就是让新特性尽快暴露在真实场景里通过预览、孵化、反馈再被下一个 LTS 吸收。如果你把 Java 17 比作一道正式菜那 9 到 16 就是一次又一次的后厨试菜少了这些环节端上桌的菜不可能那么稳定。1.2 非 LTS 版本“短命”但特性不会消失很多人说 9 到 16 没用理由是生产环境不会用它们体验过也留不住。这种看法忽略了一个关键点特性版本的“短命”指的是某个版本号不再更新而不是它引入的特性会跟着消失。相反绝大多数特性都会被后续版本继承并转正。text block 在 Java 13 作为预览功能出现Java 15 转正式record 在 Java 14 预览Java 16 转正式 sealed class 在 Java 15 预览Java 17 转正式。你看Java 17 里那些被大家称赞的现代语法其实都是在 9 到 16 中间反复打磨过的。所以问题的问法应该改一改不是“9 到 16 有没有用”而是“这些特性是什么时候达到可以放心使用的状态”。预览特性在中间版本里存在的意义就是让开发者提前尝试、提意见、找问题。哪怕你没有在生产环境用过 Java 13text block 在 Java 15 能直接上线背后也离不开那些在 13 上踩过坑的人。可以说中间版本是特性“转正”之前的必经之路。1.3 一个容易误解的点跳过 9-16不代表没用到它们还有一种更隐蔽的误解团队从 Java 8 直接跳到 Java 17以为完全绕开了 9 到 16。但实际上无论是运行时、字节码还是生态层面这个过程都绕不开。Java 17 的 JVM 必须支持 10 到 16 引入的类文件版本、模块系统、方法句柄变化你的代码编译成 61.0 字节码在 17 上运行本质上已经站在这几年所有版本更新的基础之上了。很多第三方库在面向 17 的版本里内部实现会用到不属于 Java 8 的 API 或语法。给你一个最直观的例子Spring Boot 3.0 要求 Java 17 作为基线它内部为了减少反射调用、提升启动速度大量使用了一些新版本 JVM 才有的能力。你写的业务代码可能没有直接写 record但你依赖的框架已经用了。所以“我从 8 跳到 17 没看过中间版本”只是说明你没直接写那些新语法并不代表迁移过程不受那些变化的影响。了解 9 到 16很大程度上是在为 17 上的日常排障做知识储备。2. Java 9 到 11模块化、局部变量类型推断与更务实的语法改进2.1 Java 9 的模块系统又一次结构性升级Java 9 最标志性的改动是模块系统这也是 Java 9 延期到 2017 年的主要原因。模块系统不是普通语法糖它直接影响类加载、封装和依赖关系。以前我们把 JAR 包扔到 classpath 上类之间只有 public、private 这种访问级别没有组件边界项目一大互相依赖很难控制。加了 module-info.java 之后模块可以显式声明对外开放哪些包、依赖哪些模块例如module com.example.store { requires java.sql; requires com.example.common; exports com.example.store.api; opens com.example.store.model; }这里 exports 是允许其他模块在编译期使用这些 public 类型opens 是允许其他模块在运行时通过反射访问这些包。很多老框架比如 Hibernate、Spring都会用反射读取实体类的私有字段。如果迁移到模块化环境光有 exports 还不够必须显式 opens否则会抛 InaccessibleObjectException。这个坑在 Java 9 刚出的时候让无数团队夜不能寐。在实际业务项目里我建议不要一上来就把整个应用模块化那是一个非常庞大的重构工程。通常的做法是保留 classpath 运行模式只在底层框架、平台库或新写的独立模块里使用 module-info.java。对已有业务系统来说Java 9 带来的更多是约束变化比如以前可以随便反射进 JDK 内部类现在要用 --add-opens 显式打开。这个改动直到今天都值得列为升级 17 的必查项。Java 9 还顺手带来了两个小功能一个是 jshell一个是集合工厂方法。jshell 类似一个 Java REPL敲一行执行一行适合快速验证语法和算法List.of(1,2,3)、Map.of(k1, v1)返回不可变集合比 Arrays.asList 更安全。写 demo 和测试夹具的时候这两样东西能省很多事。2.2 Java 10 的局部变量类型推断var带来的便利与约束Java 10 最出圈的改动是 var它让局部变量不用再写冗长的类型。但 var 不是随便用的“动态类型”它有很多边界。var 只能用于局部变量和增强 for 循环里的局部变量不能用于类字段、方法参数、返回类型。它的类型是在编译期推断出来的静态类型不是运行时动态变化的所以不会影响 JVM 性能。一个常见的用法是这样的var ids new ArrayListLong(); // 推断为 ArrayListLong var path Path.of(/tmp/app.log); // 推断为 Path for (var item : items) { ... }我见过不少团队在代码规范里完全禁用 var理由是“可读性下降”。我的看法是不要一刀切。当类型信息对读代码的人显而易见时用 var 可以让代码更像业务描述但如果方法返回类型复杂、变量名又不足以说明含义最好还是写清楚类型。比如var result service.query(...)换成ListOrder result ...就要清楚得多。还有一个技术细节var list new ArrayListString();之后如果想把实现改成 LinkedList因为 list 被推断成具体类型而不是 List 接口改动的范围会变大。依赖接口编程的场景显式声明接口类型更合适。2.3 Java 11 的 HttpClient 与 ZGC 等不仅是一个 LTSJava 11 是 LTS按理说应该和 8、17 并列讨论但很多团队从 8 往 17 迁移时默认跳过它。这里我多说一句11 其实是一个非常理想的中间落脚点。它带来的最大实用增量是标准 HttpClient。以前写 HTTP 客户端要用第三方库或者面对难用的 HttpURLConnection现在可以直接用标准 API支持 HTTP/2、WebSocket 和异步请求。示例代码非常简洁HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://api.example.com/orders)) .header(Accept, application/json) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.statusCode());同步用 send异步用 sendAsync返回 CompletableFuture对接响应式场景也很方便。日常调第三方接口比 Java 8 时代舒服太多。Java 11 还带来了 ZGC 的雏形和 Epsilon GC虽然当时二者都是实验特性但为后续版本中 ZGC 转正打下了基础。另外Java 11 支持直接运行单个源码文件也就是java Hello.java不再需要先 javac 编译再 java 运行。这个能力对写算法题、写脚本、做运维工具特别有用。String 也新增了 isBlank、repeat、lines 等方法处理文本时很顺手。如果你的团队在 8 上实在跨不过去可以考虑先升到 11把 JVM 行为和标准库底子换掉再慢慢拥抱 17 的语法。3. Java 12 到 16文本块、Record、Sealed、switch 表达式等现代语法的孕育期3.1 Java 12-13switch 表达式与文本块预览Java 12 最大的动作是让 switch 表达式以预览形式出现。传统 switch 的痛点在于每个分支都要 break一不留意就穿透新写法用箭头配合一个值表达式可以直接返回结果。举个直观例子int days switch (month) { case 1, 3, 5, 7, 8, 10, 12 - 31; case 4, 6, 9, 11 - 30; case 2 - 28; default - 0; };这种写法在 Java 14 转正式但很多人第一次见到是在 Java 17 的学习资料里。Java 13 继续引入文本块预览解决多行字符串的烂转义问题。写 SQL、模板、JSON 的时候特别爽String query SELECT id, name, status FROM orders WHERE create_time ? ORDER BY id ;预览特性有一个大原则不要在生产环境大量使用。因为“预览”意味着 API 可能调整你必须用--enable-preview参数才能编译和运行并且每个版本的预览细节都有变化。比如在 Java 12 写的 switch 表达式升到 Java 14 后还要检查一遍语义。所以很多团队不在中间版本的生产环境碰预览功能这是完全正确的。但不能因此说预览特性“没用”它们的价值是在正式转正之前被充分验证最终稳定地进入 LTS。3.2 Java 14-15Record、instanceof 模式匹配、ZGC 等特性转正Java 14 把 switch 表达式转正同时带出新一批预览特性其中最有影响的是 record 和 instanceof 模式匹配。record 是数据载体类的宣言式简化。过去写一个 DTO 要 IDE 生成一堆 getter、setter、toString、equals、hashCode现在一行搞定public record User(Long id, String name, String email) {}编译器自动提供构造函数、访问器id()、name()、email()、equals、hashCode、toString。record 天然不可变字段都是 final非常适合作为不可变数据快照、API 返回对象、值对象。它也有自己的边界不能继承别的类但可以实现接口不能被继承。如果需要在构造时做参数校验可以在紧凑构造函数里完成public record Price(BigDecimal amount, String currency) { public Price { Objects.requireNonNull(amount); if (amount.signum() 0) { throw new IllegalArgumentException(amount must be 0); } } }instanceof 模式匹配同样省掉了强转if (obj instanceof User user) { System.out.println(user.name()); }Java 15 把文本块转正ZGC 也在 15 转正并支持类数据共享。Java 15 还出现了 sealed class 的预览这是 Java 17 正式 sealed class 的前身。sealed class 通过 permits 列表显式限制哪些类可以 extends 或 implements比 final 更精细public sealed interface Shape permits Circle, Square, Rectangle { }这样配合模式匹配可以穷尽所有子类型分支避免漏掉特殊情况。这套设计从 15 预览到 17 才定稿你在 Java 17 里用得顺手背后是有整整两个版本在替你做铺垫的。3.3 Java 16为 Java 17“最终形态”做最后铺垫Java 16 更像一个收尾版本。它把 record 和 instanceof 模式匹配转正意味着你不再需要加--enable-preview就能使用。它还带来了一个我非常喜欢的 APIStream.toList()。以前收集成 List 要写collect(Collectors.toList())现在直接.toList()即可。它返回的是不可修改 List比 Collectors.toList() 更“安全”但如果你拿到后想 add会抛 UnsupportedOperationException。这个行为差异是很多同事第一次踩坑的地方使用前一定要看清楚。ListOrder paidOrders orders.stream() .filter(o - PAID.equals(o.status())) .toList();Java 16 还让 jpackage 工具支持打包为安装包方便桌面应用分发同时 Vector API、外部函数访问等新特性继续孵化。可以说Java 17 里的很多东西都在 16 完成了最后定型record、模式匹配都从预览变为正式sealed 类的预览在 16 还保留着到 17 转正时又微调了细节。没有 1617 的语法不会显得这么干净。为了更清楚看清这段历史我整理了一张表列出几个重要特性在中间版本的状态。这张表很适合放进项目迁移文档里当参考资料版本关键特性状态变化Java 12switch 表达式预览14 转正Java 13文本块预览15 转正Java 14record、instanceof 模式匹配预览16 转正Java 15sealed 类、ZGCsealed 预览17 转正ZGC 转正Java 16record、instanceof 模式匹配、Stream.toList正式引入4. 这些版本里的“隐藏彩蛋”实际工作中最值得用的几个能力4.1 容器友好的默认配置与 JVM 参数改进Java 9 到 10 有一个常被忽略但非常重要的改进对容器资源的感知。Java 8 早期版本在 Docker 里不能自动识别 cgroup 的 CPU 和内存限制JVM 默认按宿主机物理内存设置堆大小应用跑到容器里经常直接被 OOM Killer 杀掉。从 Java 9 开始JVM 开始读取 cgroup 信息Java 10 正式支持容器 CPU 限制Java 11 支持更细粒度的资源感知。这批改动直接影响了云原生时代 Java 应用的部署方式。如果你还在用 Java 8 排查 Docker OOM 问题应该记得要手动加-XX:UseCGroupMemoryLimitForHeap之类的参数。Java 9 以后默认就能知道自己在容器里不再需要那些神神叨叨的调参。现代 JDK 更推荐写百分比比如在容器里设置-XX:MaxRAMPercentage75.0CPU 核数也能被正确识别线程池和 GC 线程不会按宿主机的超大核数疯狂扩张。这类改动没有话题性但比很多语法糖更影响生产稳定性。4.2 JDK Flight Recorder、CDS 归档等性能工具Java 11 把 JFRFlight Recorder开源并下沉到标准 JDK 里。JFR 是 JVM 内置的性能事件记录器可以记录方法采样、锁竞争、GC、IO、异常等大量事件而且性能开销很低。过去只能靠商业授权或第三方工具现在任何 JDK 都能直接用。线上问题不好复现时开一段 JFR把 .jfr 文件导出来分析比凭感觉猜快得多。命令非常简单java -XX:StartFlightRecordingfilenameapp.jfr,duration60s,settingsprofile -jar app.jar也可以对运行中的进程动态开启jcmd pid JFR.start duration60s filenameapp.jfr另一个容易被忽略的是类数据共享CDS。JDK 12 开始有默认 CDSJDK 10 支持 AppCDS可以把常用类的元数据放到归档文件里减少启动时间和内存占用。对启动速度要求高或者在 Serverless 场景下运行 Java 时这很重要。Java 15 还支持动态 CDS 归档。这些工具不是某个具体 API但它们是 9 到 16 送给运维人员的“隐藏款能力”生产环境里非常实用。4.3 API 层面的小改进Stream.toList、Optional.orElseThrow 等除了大特性9 到 16 每个版本都在悄悄补充标准库。Java 9 新增 List.of、Map.of、Set.ofJava 10 新增 List.copyOf、Optional.orElseThrow() 无参版本Java 11 增加 String.isBlank、lines、strip、repeatJava 16 又带来 Stream.toList。这些 API 单个看都不大合起来却大幅减少了样板代码。举一个实际的小例子。以前读一个多行配置文本并统计非空行数实现起来有点啰嗦现在一行 Stream 就能搞定long count a\n\n b\nc.lines() .filter(line - !line.isBlank()) .count();再比如 Optional 在 Java 8 里经常要写orElseThrow(() - new RuntimeException(xxx))Java 10 之后直接orElseThrow()没有值默认抛 NoSuchElementException做内部字段校验完全够用。这些小改进也是为什么很多人从 8 升到 17 后会觉得代码变短了不一定是某一个新语法而是这些 API 一点点累积出来的结果。5. 普通团队应该如何理性对待 9-16升级策略与避坑经验5.1 中间版本能不能用于生产什么样的情况可以理论上非 LTS 版本也可以用于生产前提是你有能力和节奏在每个新版本发布后立刻跟上。因为非 LTS 版本的安全修复只会维护到下一个版本发布比如 Java 16 的公开更新在 Java 17 发布后就会停止。如果团队能在版本发布后两周内完成升级验证并且系统允许频繁发版那么用中间版本完全没问题。很多内部工具、脚本服务、DevOps 平台就是这么做的可以提前用上新 API不用等三年。但对于核心交易、长时间不重启、对安全合规有严格要求的系统我不建议直接上非 LTS。更稳妥的做法是建一个“中间版本验证分支”用非关键服务或压测环境跑一个版本周期提前收集依赖兼容性、性能、日志格式等问题等 LTS 版本发布后这些坑已经被自己踩过一遍正式升级会顺利很多。这套打法是我在几个团队实践过的比一上来就在生产环境上 Java 16 要稳得多。5.2 从 Java 8 直接跳到 17 的路径中如何“补课”既然大多数团队最终会从 8 直接到 17那中间的 9 到 16 到底要不要逐个学我的建议是不需要按版本一个个学但必须系统梳理“从 8 到 17 发生了什么”。具体做三件事用--release 8编译现有代码在 Java 17 上运行测试先把运行时出现的 InaccessibleObjectException、NoClassDefFoundError 等列出来。用jdeps分析应用和依赖的模块关系命令是jdeps --print-module-deps --ignore-missing-deps target/classes。用jdeprscan扫描依赖中已弃用或移除的 API这一步能提前发现很多问题。比如sun.misc.BASE64Encoder在 Java 9 后被移除SecurityManager在 17 被弃用CMS 垃圾收集器被移除。如果不提前扫描可能要线上跑几天才会被触发。语法层面直接学 17 的 record、文本块、switch 表达式、instanceof 模式匹配、sealed class 即可。注意编译参数不要写-source 8 -target 8要写--release 8否则即使目标版本是 8编译器也会允许使用当前 JDK 的 API导致运行时 NoSuchMethodError。这条是 Java 9 引入--release后才有的坑也是我自己踩过的最深的一个。5.3 常见兼容性问题与排查实录这里记录几个团队升级时最常遇到的典型问题方便你对照排查。反射访问被模块系统拦截使用setAccessible(true)访问 JDK 内部或未导出包时Java 9 以后会抛InaccessibleObjectException。常见于字节码框架CGLIB、ASM、序列化框架。解决方式优先是升级依赖版本如果暂时无法升级可以加--add-opensjava.base/java.langALL-UNNAMED临时绕行但长期必须换新版框架。自定义类加载器和 URLClassLoaderJava 9 开始应用类加载器不再是 URLClassLoader 的子类。老代码如果强制转成 URLClassLoader 拿 classpath URL会直接失败。建议改成ClassLoader.getResourceAsStream()。GC 参数被删除Java 8 常用的 CMS即-XX:UseConcMarkSweepGC从 9 开始废弃到 14 被移除17 完全不能用。保留这个参数会导致 JVM 启动失败。容器场景下可以直接换 G1 或 ZGC。Lombok 等编译期处理器JDK 14 之后内部编译 API 变化老版本 Lombok 可能编译失败。升级前一定要匹配好版本最好先在一个分支里试编译。这些问题的共性是它们跟你的业务代码无关但跟 JVM 内部变化和第三方库版本强相关。所以升级前一定要把第三方依赖全部升级到支持目标 JDK 的版本并且准备一套自动化回归测试。不要相信“代码能编译就一定能跑”Java 9 以后很多问题发生在运行时反射和模块边界上。如果你看完这些还是觉得中间版本没存在感那至少记住一点9 到 16 一直在替未来的 LTS 版本“试错”Java 17 正是这些试错积累下来的稳定输出。最后回到标题的问题Java 9 到 16 真的没用吗我的答案很简单它们不是没有用而是从“新特性预览场”变成了“LTS 的底座”。我自己保留了一个习惯每个非 LTS 版本发布后都会在一个小工具项目里用一遍里面的新 API把踩到的坑记录下来。等到 Java 17 发布时这些记录就成了迁移的排查手册。如果你现在还在 Java 8不需要强迫自己追每个中间版本但强烈建议至少装一个 JDK 17打开 Release Notes搜索一下 record、文本块、Stream.toList写几个 demo 跑一跑。你会发现Java 早就不是你以为的那个 Java 了。