从Java 9到Java 25:核心新特性与升级实战解析
2026/9/18 21:28:51 网站建设 项目流程

我大概是工作第四年才真正被 Java 版本迭代给“追”着跑。以前大家聊 Java,默认都是 Java 8,谁要是用上 Java 11 都算激进。从 Java 9 开始,Oracle 把发版节奏从“五年憋大招”改成“半年一更”,Java 9、10、11……一路冲到现在的 Java 25。版本多了,面试题也跟着换血,什么虚拟线程、record、sealed class,已经成了 JDK 新特性面试题的常客。

这篇内容不是想跟你念一遍官方 Release Notes,而是想从实际开发的角度,把 Java 9 到 Java 25 之间真正值得用的核心新特性串一遍。你如果是一个正在准备 Java 面试的候选人,或者刚接手一个老项目准备升级 JDK,又或者纯粹想知道这些新版本除了版本号变快之外到底改了啥,这篇文章应该能帮你在半小时内建立起一条清晰的知识线。

1. 版本节奏与 LTS 策略:从 Java 8 一步跨到 21 才是常态

1.1 “半年一版”背后的逻辑,以及为什么你不用每个版本都追

Java 9 之前,JCP 制定新特性的周期是以“年”为单位的,Java 8 到 Java 9 中间隔着三年多。到了 Java 9,整个社区的反馈是:发布太慢,新特性难产,很多好点子因为赶不上统一发布窗口被无限推迟。所以 Oracle 改成了基于时间的发布模型(Time-Based Release),每六个月出一个 feature release,每个版本都有一个确切的发布日期,到了点就发,不因为某个特性没写完而延期。做不到就砍掉,放到下一个版本再说。

这也解释了为什么你能看到 Java 9 的模块化、Java 10 的 var、Java 11 的 HTTP Client 这些特性是一个版本一个版本“挤”出来的。半年一版对普通开发者的直接影响是:你不需要每个版本都学一遍,因为大部分版本之间的增量并不大。你真正该关注的是 LTS(长期支持)版本,也就是 Oracle 承诺提供多年稳定支持的那几个:Java 8、11、17、21,按节奏下一个就是 25。中间的 9、10、12、13、14、15、16、18、19、20、22、23、24,更像是在为 LTS 版本做功能预演和打磨。

用我自己的话说,Java 9 到 25 这段演进里,真正会产生“换代感”的里程碑只有三个:Java 9 的模块化、Java 17 的密封类与模式匹配雏形、Java 21 的虚拟线程和 record 模式全面转正。把这几个节点吃透,面试官问“JDK 新特性”的时候你就不会慌。

1.2 LTS 怎么选:不要站队,按项目状态决定

我在公司里被问过无数次“项目该用哪个 JDK”。我的建议分几种情况:如果是老项目,Java 8 跑得好好的,业务没有扩容压力,也没有新并发需求,那升不升都行,先把代码里用到的第三方库是否兼容 JDK 17/21 排查完再说。如果是新项目,直接上 Java 17,因为它是 2011 年以后语法和 API 最“现代化”的 LTS,record、switch 表达式、文本块这些都能用,IDE 和构建工具支持也最成熟。

如果团队愿意折腾一点,或者你正在做高并发、低延迟相关的系统,Java 21 是更好的选择。虚拟线程和分代 ZGC 这两个大件,已经在 21 正式转正,这是 Java 并发模型和 GC 策略十年来最大的一次更新。Java 25 按三年一个 LTS 的节奏也应该成为长期支持版本,但它的很多核心特性此时还在预览和孵化阶段,我的态度是观望优先,不急着上线。

2. 语法层面的变化:这些新写法能让代码量直接少三分之一

2.1 var:局部变量类型推断,用了就回不去

Java 10 最出圈的语法就是var。它的作用说白了就是让你声明局部变量的时候不用写类型,编译器会根据右侧表达式自动推断。比如Map<String, List<String>> map = new HashMap<>();可以直接写成var map = new HashMap<String, List<String>>();,或者配合钻石操作符进一步简化。

var不是 JavaScript 里的动态类型,它依然是强类型,编译时就已经确定具体类型,类型写错照样报编译错误。真正舒服的地方在于泛型嵌套层级很深的时候,比如List<Map<String, List<Integer>>>这种类型从前要写两行,现在一行就能搞定。

有几个典型场景我不建议用var:第一个是方法返回值的变量,如果方法的返回类型是接口,你用var推断出的具体类型可能跟同事预期不一致;第二个是用三元表达式或者 lambda 推断出奇怪类型的时候,可读性会变差;第三个是匿名内部类,var推断出来的是匿名类,不是它的父类,这时候赋值给变量会有点反直觉。

2.2 switch 表达式与箭头语法:放弃 break,拥抱 yield

从 Java 14 正式转正的 switch 表达式,是我最推荐的第一个“升版本动力”。以前写switch是一堆casebreak,漏一个 break 就会 fall-through,而且整个 switch 只是“语句”不是“表达式”,你没法直接把它赋值给变量。新语法长这样:

int days = switch (month) { case 1, 3, 5, 7, 8, 10, 12 -> 31; case 4, 6, 9, 11 -> 30; case 2 -> 28; default -> 0; };

每个分支用箭头->表示,不会 fall-through,多个 case 可以用逗号并列。如果分支逻辑复杂,需要用块语句,块内部用yield返回结果。这个特性最大的意义是:switch 终于可以当作一个有返回值的表达式用,代码表达能力和可读性都上来了。

在 Java 21 之前,switch 还能配合模式匹配,比如case String s ->这种形式,可以在 case 里直接绑定变量并做类型判断,这个下面单聊。

2.3 文本块:多行字符串终于不用拼转义了

Java 13 引入文本块预览,Java 15 转正。以前写 SQL、HTML、JSON,要么用+拼一堆换行,要么写成一长串带\n的字符串,肉眼根本没法校对。文本块用三个双引号包裹,可以直接保留原文格式:

String sql = """ SELECT id, name FROM user WHERE status = 1 """;

有几个细节要注意:开头的"""后面必须换行,结尾的"""位置决定公共缩进基准,编译器会自动把每行缩进中公共的部分去掉。如果字符串内容里有""",需要转义成\""",这个很少遇到但遇到了会比较坑。

我常用文本块配合.formatted()方法做模板填充,比 String.format 直观得多,尤其适合动态拼接 SQL、生成 JSON 报文这类场景。

2.4 record:数据类的正规军,没 Lombok 也能体面

Java 14 预览、Java 16 正式转正的record,基本就是为 DTO、VO 这一类“只承载数据,没有业务行为”的类量身定做的。你只需要写一行声明,编译器会自动生成构造器、equalshashCodetoString以及每个字段的读取方法:

public record UserInfo(String name, int age) {}

这个 record 跟手写一个标准 POJO 是等价的,但是代码量少了八成。它跟 Lombok 的@Data最大的区别是:record 是一个语义化的类型,天然不可变,所有字段默认final,而且没有 setter。如果你需要”只读数据”的语义,record 比 Lombok 更严谨。

record 还有一些高级玩法,比如紧凑构造器可以做参数校验:

public record UserInfo(String name, int age) { public UserInfo { if (age < 0) { throw new IllegalArgumentException("age must be positive"); } } }

要注意 record 不能继承其他类,也不能被继承,它不是万能的,只有当你确定这个类型就是“值对象”时才适合用。

2.5 sealed class 与 instanceof 模式匹配:面向对象的新姿势

Java 17 正式转正sealed类,它解决的问题是:继承这个事,以前要么开放给所有人,要么就 final 彻底锁死,缺一个中间状态。sealed 类允许你声明一个类的可继承范围,超出范围直接编译报错:

public sealed interface Shape permits Circle, Square, Triangle {}

配合 Java 16 转正的instanceof模式匹配,整个分支逻辑会变得非常干净。过去写if (obj instanceof String),还得在方法体内强转一次,现在可以在判断的同时直接绑定变量:

if (obj instanceof String s) { System.out.println(s.length()); }

到了 Java 21,record 模式和 switch 模式匹配也全部转正,你可以这么写:

Object obj = ...; String result = switch (obj) { case null -> "null"; case String s -> "String: " + s; case Point(int x, int y) -> "Point: " + x + "," + y; default -> "unknown"; };

case null也被正式支持了,不用再担心 switch 对 null 抛出 NPE。这套组合拳下来,很多本来要写策略模式或者多态分发的代码,其实用模式匹配就能做得非常紧凑。

3. 核心库与并发模型:虚拟线程才是 Java 21 的王炸

3.1 集合和 Stream 的小改进:从 of() 到 toList()

Java 9 给集合接口加了一组of工厂方法,创建不可变集合终于不用再绕Collections.unmodifiableList了。List.of(1, 2, 3)Set.of("a", "b")Map.of("k", "v")都很好用。注意这个方法创建的集合不能增删改,传 null 会抛 NPE,比较适合定义常量。

Stream API 在 Java 9 加了takeWhiledropWhileiterate重载和ofNullable,Java 16 又加了toList()。以前要把 Stream 收集成 List 必须写.collect(Collectors.toList()),现在直接.toList()就行。区别在于这个新的toList()返回的是一个不可变 List,而 Collectors 返回的是可变 List,如果需要后续增删,自己斟酌。

3.2 HTTP Client:终于不用再堆第三方 HttpUtil 了

Java 11 把 HTTP Client 正式转正到java.net.http,这是标准库对网络请求的一次大补课。以前写 JDK 原生 HTTP 请求要面对难用的HttpURLConnection,所以大家才都去用 Apache HttpClient 或者 OkHttp。现在官方这个支持 HTTP/2、支持异步、支持 WebSocket,基本的 GET/POST 请求写起来很顺手:

HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/user/1")) .header("Accept", "application/json") .GET() .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

异步版本用sendAsync,配合CompletableFuture可以做并发请求编排。这套 API 能不能完全替代 OkHttp?我的经验是,简单请求和内部服务调用可以考虑直接上它,减少一个第三方依赖;但如果你的项目重度使用拦截器、连接池调优、自定义序列化,那 OkHttp 的优势还在。

3.3 虚拟线程:并发编程的“廉价线程”时代

虚拟线程是 Java 19 预览、Java 21 正式转正的特性,也是这几年 Java 并发方向最重磅的更新。它的核心思想是:不再让线程一对一绑定操作系统线程,而是让 JVM 自己管理成千上万个轻量级线程对象,操作系统线程只作为载体,在阻塞时被自动释放去执行其他虚拟线程。

以前我们处理高并发 IO,套路是线程池加异步回调,甚至上个响应式框架。虚拟线程的出现让“每任务一线程”这种最直观的模型重新变得可行。代码不用重构,只要把new Thread(...)换成Thread.startVirtualThread(...),或者用Executors.newVirtualThreadPerTaskExecutor()创建执行器:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = tasks.stream() .map(task -> executor.submit(() -> doWork(task))) .toList(); }

但虚拟线程有几个坑得提前知道。第一,虚拟线程不能池化,池化虚拟线程等于脱裤子放屁,它的设计初衷就是“用完即弃”。第二,如果代码里用了synchronized块,虚拟线程在阻塞时会钉住(pin)底层平台线程,从而降低并发效果,遇到这种情况建议换ReentrantLock。第三,虚拟线程只适合 IO 密集任务,CPU 密集任务用它没意义。

3.4 结构化并发与作用域值:并发代码的整洁之道

Java 21 另一个重量级孵化特性是结构化并发(Structured Concurrency),对应包java.util.concurrent下的StructuredTaskScope。它的目标是让并发任务的生命周期和代码块的生命周期保持一致。用一个示例讲:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { Future<String> user = scope.fork(() -> fetchUser()); Future<String> order = scope.fork(() -> fetchOrder()); scope.join(); return user.resultNow() + order.resultNow(); }

这个写法的好处是,如果任何一个子任务失败,整个 scope 会立即取消剩余任务,不会像裸用CompletableFuture那样出现“主线程干完了还在等后台脏线程”的问题。作用域值(ScopedValue)则是ThreadLocal的改进版,它让线程局部变量在并发和虚拟线程场景下更安全,这个特性目前还在孵化状态,普通项目可以先不碰。

4. 性能、GC 与 JVM 底层:变快不是玄学

4.1 分代 ZGC:大堆降到毫秒级暂停的真相

Java 11 引入了实验性的 ZGC,Java 15 转正,Java 21 又推出了分代 ZGC。ZGC 的核心目标是支持大堆(从几十 G 到几 T),同时把 GC 暂停时间控制在 10ms 甚至 1ms 以内。它的做法是并发标记、并发整理,尽量把工作分摊到多个 GC 线程,和业务线程同时跑。

以前用 G1 应对几十 G 堆,一次 Mixed GC 可能有几十上百毫秒暂停,这对低延迟服务是噩梦。换上 ZGC 之后,哪怕堆很大,停顿也能保持在很低水位。启动参数很简单,JVM 参数加上-XX:+UseZGC就行,JDK 21 上分代 ZGC 可以直接用-XX:+UseZGC -XX:+ZGenerational

不过 ZGC 不是银弹。它牺牲了一些吞吐量来换低延迟,如果你的服务是批处理型、CPU 密集型,G1 依然是稳妥选择。另外 ZGC 在 JDK 21 里内部也还在继续打磨,生产环境用之前一定要先用你自己的真实压测场景跑一遍,别光看官方 benchmark。

4.2 CDS 与 AppCDS:启动时间能省一大截

类数据共享(CDS)是从 Java 5 就有的技术,但直到 Java 10、12、13 才逐步默认化和增强。它的原理是 JVM 启动时把核心类库的元数据、已加载类信息做一份归档,下次启动直接映射到内存,减少类加载和校验开销。对单体服务来说,启动时间能从十几秒降到几秒。

AppCDS 更进一步,允许把你的应用 jar 里的类也打进归档。配合 Java 13 的动态归档,你不用手动指定 dump 类列表,跑一次常见启动路径就能自动生成。Spring Boot 项目经常用的spring-boot-maven-plugin在 JDK 21 之后也能配合 CDS 做优化。这个优化对普通 web 应用提升有限,但对函数计算、短生命周期任务这种启动敏感型场景非常有价值。

4.3 Foreign Function & Memory API:JNI 的现代化替身

Java 22 正式转正的java.lang.foreign外部函数和内存 API,解决了 JNI 和ByteBuffer被诟病多年的问题:JNI 写起来繁琐、性能损耗大,ByteBuffer管理和内存释放又别扭。新 API 允许你直接调用本地方法、操作堆外内存,语法更安全,性能也更接近原生。

Linker linker = Linker.nativeLinker(); SymbolLookup stdlib = linker.defaultLookup(); MethodHandle strlen = linker.downcallHandle( stdlib.find("strlen").orElseThrow(), FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS) );

它和 Panama 项目相关,目标就是把 JNI 从“难写到不想写”变成“正常 Java 代码里的一块”。一般业务开发暂时用不上,但如果你在做嵌入式、图形库绑定、高性能存储引擎,这个方向非常值得关注。

4.4 Valhalla 与紧凑对象头:未来 Java 对象的两种“瘦身”

Java 25 这个时间点最值得关注的长期方向,是 Valhalla 项目。它要解决的核心问题是:Java 对象在内存里的开销太大,每个对象都有对象头(mark word、class pointer,压缩后也有 12 字节甚至更多),一个频繁使用的 Integer 对象可能比一个原始 int 多占好几倍内存。Valhalla 的路线是引入“值对象”和“原始类型”,让自定义类型可以像 int 一样扁平分布在数组里,而不是到处是引用和指针。

Java 24 引入了紧凑对象头(Compact Object Headers)作为实验性特性,目的就是把对象头进一步压缩。这些改动如果真正落地,对大数据量、缓存高命中的系统是巨大的收益。不过这些目前大部分还在预览和孵化阶段,普通开发者知道有这么回事就行,不用深入研究。

5. 升级 JDK 之前必须解决的兼容性与构建问题

5.1 编译字节码版本:Java 8 的老代码真的能直接跑在 21 上吗?

升级 JDK 最大的疑问就是老代码能不能跑。Java 官方强调二进制兼容性,意思是,只要你的代码在 Java 8 编译出的 class 文件字节码版本是 52.0,理论上可以被更高版本的 JDK 加载执行。我在实践中也验证过很多老项目,ClassNotFoundException 或 NoSuchMethodError 大多不是字节码兼容性问题,而是用了第三方库里的旧 API。

但有个反方向要特别注意:你用 JDK 21 的javac编译代码时,默认生成的字节码版本就是 65.0,老版本的 JVM 跑不了。如果想发布的 jar 能兼容旧运行环境,编译时需要加--release参数。比如用 JDK 21 编译出 Java 17 字节码:

javac --release 17 Main.java

这个参数比-source-target更安全,因为它会同时限制编译期 API 的可用范围,防止你不小心用到新版本才有 API 却声明兼容低版本。

5.2 module-path 和 classpath:Java 9 模块化带来的第一个大坑

Java 9 最大的架构变化是模块化(JPMS),以前整个 JDK 是一个巨大的rt.jar,现在被拆成了很多java.*jdk.*模块。如果你的老项目用的是 classpath 方式,其实还好,影响不大。真正中招的是某些框架或者工具,它们用了--add-opens--add-exports来绕过模块封装。

比如 Spring 框架早期在 Java 17 上跑,就要额外加一堆 JVM 参数放开反射访问权限,因为 Java 17 开始强封装 JDK 内部 API。新项目如果在启动时报IllegalAccessErrorInaccessibleObjectException,第一反应就去看官方文档对 JDK 17/21 的兼容说明,通常都会有对应的--add-opens参数。

我的经验是,简单项目可以直接用 classpath,不用强行模块化。只有当你确定要把一个大型系统拆成可复用、有清晰边界的组件时,modularity 才值得投入。

5.3 Maven 和 Gradle 的版本配置:别让构建工具拖后腿

项目升级到 JDK 17 或 21,第一件事不是改代码,而是把构建工具升级到支持新字节码版本的版本。Maven 3.8 以上、Gradle 7.3 以上是对 Java 17 支持比较舒服的底线。Gradle 对 JDK 版本支持更敏感,老版本 Gradle 跑在 JDK 21 上会直接报Unsupported class file major version

pom.xml 里推荐直接这样配置:

<properties> <maven.compiler.release>21</maven.compiler.release> </properties>

release而不是source+target,原因前面说过,它可以同时限制 API 权限,不会有“编译过了但运行时 NoSuchMethodError”这种幺蛾子。

另外,JDK 9 之后,maven-compiler-plugin的默认版本如果太老也可能出问题,建议顺手升级到 3.11.0 以上。

5.4 第三方库兼容清单:动手前先做一次体检

老项目升级到 Java 17 或 21,最容易卡住的是第三方库,尤其是字节码增强和反射重灾区:Lombok、CGLIB、Mockito、ByteBuddy、各种 Agent。Lombok 老版本在 JDK 16 之后经常直接启动失败,报java.lang.ExceptionInInitializerError,解决办法是升级到 1.18.30 以上。Mockito 需要 4.x 以后。CGLIB 基本被 ByteBuddy 替代。

我升级前一般会做一个五分钟的体检:把项目所有依赖导出来,对照官方兼容矩阵查一遍;再把测试跑起来,重点看代理类生成、序列化、反射相关的用例。别迷信“看起来能编译”,别把测试阶段的问题留到线上。

6. 面试高频考点与学习路线参考

6.1 面试官爱问的 Java 8 到 21 变化,无非这几类

现在 Java 面试题里,“JDK 8 新特性”已经是常规操作了,卷一点的岗位会追问 JDK 11、17、21 的新东西。面试官真正想听的并不是你背了多少版本号,而是你能不能说出某个新特性解决了什么实际问题。

比如问record和 Lombok 的区别,答案点在于:record 是语言级不可变类型,语义更严谨,序列化和结构化使用更自然;Lombok 是编译期注解处理器,灵活但屏蔽了真实代码。比如问switch表达式和旧switch的区别,重点说 fall-through、箭头语法和返回值能力。再比如问虚拟线程和线程池的区别,重点说虚拟线程不绑定 OS 线程、不能被池化、适合高并发 IO 场景。

我把常见考点整理了一下:

考点核心回答思路
Java 8 和 Java 17 的主要变化加上记录、sealed、模式匹配、switch 表达式、HTTP Client,去掉PermGen 改为 Metaspace
为什么说 Java 21 是分水岭虚拟线程转正、record 模式转正、分代 ZGC 可用
var 能用在哪些位置局部变量,不能用于字段、方法参数、返回类型
sealed class 解决了什么问题限制继承范围,让穷尽式分支更安全
CDS 优化原理类加载结果归档,启动时映射,减少类加载开销
ZGC 与 G1 怎么选追求低延迟大堆用 ZGC,吞吐量敏感用 G1,默认还是 G1

6.2 虚拟线程和线程池面试题怎么答

虚拟线程相关题目在 2024 年之后几乎成了必问项。面试官常问“虚拟线程和平台线程有什么区别”“虚拟线程能替代线程池吗”,你要抓住三个要点:

第一,平台线程一对一映射到 OS 线程,数量受系统资源限制,线程切换成本高。虚拟线程由 JVM 调度,映射到少量 OS 线程上,数量可以非常大,创建销毁成本低。第二,虚拟线程适合 IO 密集型任务,因为阻塞时 JVM 可以腾出载体线程执行别的虚拟线程;不适合 CPU 密集型。第三,不要池化虚拟线程,每任务直接创建一个即可,这和平台线程的池化思路完全相反。

可以再补一个亮点:遇到 synchronized 时虚拟线程可能 pin 住载体线程,所以高并发代码里优先用java.util.concurrent.locks.ReentrantLock。这个细节说出来,面试官基本就能确定你是真做过功课的。

6.3 一条可以直接参考的 Java 学习路线

如果你刚入门 Java,我的建议是先学 Java 基础语法和面向对象,把 JDK 8 的 lambda、Stream、Optional 这些高频 API 用好。有一定基础后,按三条线扩展:并发线学 JUC、线程池、虚拟线程;JVM 线学内存结构、GC 策略、调优参数;框架线学 Spring Boot 生态和微服务。

新特性不用按版本一个个刷,跟着 LTS 版本走就好:先补 Java 17 的语法,再补 Java 21 的虚拟线程和结构化并发,最后了解 24、25 的 Valhalla 方向。我自己带人的时候还会要求他们每种新特性都写一个能跑的最小 demo,光看文章不如亲手敲一遍,敲完你能真正理解怎么写才优雅、怎么写会踩坑。

7. 最后说点我自己的体会和一个小建议

我在实际项目里踩过几次坑之后,逐渐形成了自己的一套升级策略。每个新 LTS 发布后,我不会立刻改线上版本,但会在个人项目和新的小模块里先试用。Java 17 我用了大概一年才敢把核心服务升上去,Java 21 的虚拟线程我也是先在低峰期的业务接口里观察了几周,确认没有内存和线程异常才铺开。

如果你现在还在 Java 8 上犹豫,我建议可以先把编译和目标版本切到 17,代码先不改,跑一遍测试看看兼容性。往往你会发现比想象中顺利,因为大部分问题都在构建工具和 IDE 层面,真正写到业务代码里的新特性反而可以慢慢加。等你在实际开发里用过recordswitch表达式之后,再回头看 Java 8 的样板代码,大概率就不想回去了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询