十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JDK 17新特性实战:record、密封类与模式匹配详解

JDK 17新特性实战:record、密封类与模式匹配详解 1. 从Java 8直接跳到JDK 17这次语法变化到底值不值得折腾我最近刚把一个跑了五年的老项目从Java 8升级到JDK 17刚拿到需求时心里也犯嘀咕不就是换个JDK版本嘛结果一翻JEP列表才发现这次语法层面真的塞进了不少硬货。如果你也还在写if (obj instanceof String)然后强转、还在用十几个字段拼equals()、还在为多行SQL字符串疯狂加转义符那JDK 17这套新增特性是真的值得花时间弄明白。JDK 17是2021年9月发布的长期支持版本LTS和Java 8、11一样会持续很多年。但和8、11不同的是它在语法上补了很多这些年业界早就习惯的东西record数据类、sealed密封类、instanceof模式匹配、文本块还有预览阶段的switch模式匹配。这套组合拳打下来Java的写法才算是跟上了现代语言的第一梯队。这篇文章不打算泛泛列特性清单而是把每个语法的设计意图、使用场景、坑点和实操写清楚。我会带你从头搭建JDK 17环境再把这些新特性逐个拆开最后用一个完整的案例把它们组合起来跑一遍。如果你是刚接触JDK 17语法、或者正筹备升级项目这篇应该能帮你少走不少弯路。2. 升级JDK 17前的准备安装、配置和编译器开关2.1 JDK 17安装包选择与下载渠道安装JDK 17其实没什么玄学但下载渠道要注意一下。目前主流的发行版是Oracle JDK 17和OpenJDK 17。Oracle JDK 17用的是“Oracle No-Fee Terms and Conditions License”NFTC个人和商用都不收费这一点和Java 8时期的收费策略完全不同。如果你公司没有特殊合规要求直接下载Oracle JDK 17就行如果更偏好社区生态AdoptiumEclipse Temurin的OpenJDK 17构建也很稳。下载时注意两个细节一是选对操作系统Windows下载.msi或.zipLinux/macOS下载.tar.gz二是别下成jre的包现在JDK下载页面默认就是完整的JDK但有些镜像站会把JRE单独拆出来开发机器上一定要装JDK。解压或安装完成后最终会得到一个类似jdk-17.x.x的目录这个路径后面要配到环境变量里。2.2 环境变量配置JAVA_HOME和PATHWindows装完msi其实会自动配置大部分环境变量但我还是建议手动确认一遍因为在IDEA、Maven、Gradle里经常需要显式指定JAVA_HOME。右键“此电脑”→“属性”→“高级系统设置”→“环境变量”新建一个系统变量变量名: JAVA_HOME 变量值: C:\Program Files\Java\jdk-17.0.8然后在Path里新增一行%JAVA_HOME%\bin。Linux和macOS则是在~/.bashrc或~/.zshrc里追加export JAVA_HOME/usr/lib/jvm/jdk-17 export PATH$JAVA_HOME/bin:$PATH配完最重要的一步是验证。打开新终端窗口执行java -version javac -version如果输出里能看到openjdk version 17.x.x或java version 17.x.x就说明安装成功。如果还是显示旧版本优先检查是不是系统里同时存在多个JDK导致PATH指向乱了尤其是Windows上常见的C:\Program Files\Common Files\Oracle\Java\javapath会拦截Java命令记得把它从PATH里挪掉。2.3 预览特性开关--enable-preview是什么JDK 17里有个特别容易踩的坑部分语法虽然已经出现在JEP里但还属于预览状态编译和运行的时候必须显式开启开关否则直接报错。最典型的就是后面要讲的JEP 406switch模式匹配。编译预览特性的标准命令是javac --release 17 --enable-preview MyClass.java运行时同样要加java --enable-preview MyClass如果你用Maven或Gradle需要在插件配置里加上compilerArgs和jvmArgs。这个开关的作用是告诉编译器“我确认自己用的是预览API出兼容性问题我认了”。没有它编译器会提示error: pattern matching in switch is a preview feature and is disabled by default很多人拿到JDK 17后写了预览代码发现编译不过其实不是代码写错了而是忘了这个开关。3. JDK 17语法新增特性逐个拆解3.1 instanceof模式匹配终于不用先判断再强转了先看一段Java 8时代最常见的代码if (obj instanceof String) { String s (String) obj; System.out.println(s.length()); }这种“先判断类型再强制转换”的写法写多了真的烦类型名出现两次稍有改动就容易漏。JDK 16正式落地的JEP 394把这个问题解决了模式匹配让变量在类型检查通过后直接绑定使用if (obj instanceof String s) { System.out.println(s.length()); }这里s直接就是String类型不需要再写第二次。这个特性的官方说法是“流作用域”flow scoping简单理解就是一旦instanceof判断成功模式变量s就在接下来的代码块里编译期可见且可用类型自动收窄。有几个细节值得注意。第一和||会影响作用域if (obj instanceof String s s.length() 3) { // s 在这里可用 } if (obj instanceof String s || s.length() 3) { // 编译错误 // 因为 || 右侧无法保证 obj 一定是 String }第二else分支里模式变量不可用这是合逻辑的——类型都判断失败了变量自然没有绑定。第三instanceof匹配null时依然返回false不会自动把null包装进去这点和旧版行为一致别指望它能替代null判断。这个特性的实际价值让我印象最深的是重写equals()方法的时候。老写法先是if (this obj) return true;再判断obj instanceof MyClass再强转再逐个字段比较。新写法一行if (obj instanceof MyClass other)就把类型判断和变量绑定都做了代码量直接减半。如果你的项目里有大量DTO、VO的equals()重写逻辑升级后改一遍肉眼可见清爽。3.2 Record数据类的终级简化方案Java里写一个纯粹用来装数据的类有多痛苦十几个字段、手写getter/setter、手写equals/hashCode/toString、再写个构造函数。虽然有IDE自动生成但维护起来依旧头疼加一个字段得同步改好几处。Lombok缓解了一部分但Lombok在编译期做字节码增强出了问题排起来相当难受。JDK 17里record已经是正式特性JEP 395专门解决数据载体的定义问题。最简单的定义长这样public record UserProfile(Long id, String name, String email) { }别小看这一行编译器会自动生成全参构造器、id()/name()/email()三个访问器注意不是getId()这种命名、equals()、hashCode()、toString()。你自己一行业务代码都不用写。record有一些限制要记牢它隐式继承java.lang.Record所以不能extends别的类它的字段是private final没有setter它不能声明实例字段但可以声明静态字段和静态方法它可以实现接口。更实用的是紧凑构造器compact constructor用来做参数校验public record UserProfile(Long id, String name, String email) { public UserProfile { if (id null || id 0) { throw new IllegalArgumentException(id must be positive); } if (name null || name.isBlank()) { throw new IllegalArgumentException(name must not be blank); } } }这个写法里没有参数列表直接写校验逻辑等价于在构造器入口统一校验编译器会自动完成字段赋值。除了校验你还可以在紧凑构造器里对参数做归一化处理比如把邮箱统一转小写再赋值。JDK 16开始还支持局部record也就是在方法内部定义record。这个特性在处理方法内部临时数据结构时非常实用比如流式计算中间结果时再也不用为了一个临时对象单独建一个顶层类了public ListString process(ListRawData list) { record TempPair(String key, int count) {} return list.stream() .map(d - new TempPair(d.getKey(), d.getValue())) .filter(p - p.count() 0) .map(TempPair::key) .toList(); }我用record替换老项目里纯POJO的过程基本是从内到外的先把方法内部临时类改成局部record再把DTO、VO这些改成标准record最后连三层架构里的出参入参对象也优化了一轮。比较快的一个模板是如果某个类只有字段、getter、equals/hashCode/toString没有任何业务逻辑那就放心改成record。3.3 Sealed密封类把继承限制关进“笼子”里面向对象设计里常说“组合优于继承”但实际业务建模时继承有时候还是最合适的表达方式。问题是Java的继承默认是“开放”的——只要你写一个public类任何类都能继承它。这种无限制的继承会让代码的可维护性变得很差你不知道这个类被谁继承了也不知道它的子类有多少种做穷尽判断的时候特别容易漏。sealed类JEP 409就是为了解决这个问题。它让你明白地声明这个类或接口只有这几个子类其他的不允许再继承。基础语法public sealed interface Shape permits Circle, Rectangle, Triangle { double area(); }注意这里permits后面列出的每个类/接口都必须直接继承或实现这个密封类并且需要带上final、sealed或non-sealed修饰符之一public final class Circle implements Shape { private final double radius; public Circle(double radius) { this.radius radius; } Override public double area() { return Math.PI * radius * radius; } } public non-sealed class Rectangle implements Shape { // 非密封允许其他类继续继承这个类 } public sealed class Triangle implements Shape permits RightTriangle { // 密封只允许 RightTriangle 继承 }用non-sealed标记的子类相当于重新开放了继承这在你希望某个分支继续扩展时很有用。用final标记的子类则彻底封死是叶子节点。关于包和模块有个很关键的约束如果密封类和它的子类在同一个模块module里子类可以放在不同的包但如果没有模块描述文件比如传统classpath项目那么密封类和所有允许的子类必须在同一个包。这个坑我踩过刚开始把子类放到另一个包编译直接报sealed class is not permitted to extend之类的错误。sealed类最大的价值不是限制继承本身而是配合模式匹配做穷尽分支。后面第三节会给你看一个完整的订单状态建模案例那里才能真正感受到这个特性的威力。3.4 文本块写SQL和JSON终于不用在线拼接了JDK 15正式引入文本块JEP 378并在JDK 17中保持正式。老Java里写多行字符串要么用\n手动拼接要么像这样String json {\n \name\: \张三\,\n \age\: 30\n };看着眼睛都疼。文本块的语法是用三个双引号开头和结束String json { name: 张三, age: 30 } ;这里有两个点要重点理解也是新手最容易踩坑的地方。第一开头的之后的换行符会被自动忽略。也就是说后面必须直接回车内容从下一行开始第一行的换行不算内容。第二末尾放在哪一行决定了文本块的缩进基准。编译器会以结束分隔符的缩进为基准把每行开头的公共缩进去掉。比如上面例子中结束的和内容行都缩进了8个空格那最终字符串里的每行前导8个空格会被去掉。如果要在文本块里保留空格可以用\s转义如果要在文本块中间强制换行可以用\n如果要避免末尾自动换行可以写直接跟在最后一行内容后面。举个例子String sql SELECT id, name, email FROM users WHERE status active ORDER BY created_at DESC ;这个SQL拿去给MyBatis或者JDBC用不要太舒服。以前在XML里写动态SQL还要考虑缩进美观现在直接把文本块塞进去就好数据库客户端复制出来也能直接执行。我在项目中用得最多的是生成复杂邮件模板、JSON报文、以及多行SQL。文本块和formatted方法配合还能优雅地做模板替换String invitation 尊敬的 %s 您的验证码为%s 有效期 5 分钟。 .formatted(name, code);这样就可以告别String.format里面一长串的%s %s %d错位看花眼的问题了。3.5 switch表达式与模式匹配预览写条件分发的新姿势先说明一点switch作为表达式有返回值在JDK 14已经是正式特性了但JDK 17里最让人期待的是JEP 406——switch模式匹配目前还处于预览阶段。它的核心是让switch的 case 标签不仅能匹配常量还能匹配类型、匹配null甚至可以加守卫条件。先看JDK 14以后写法的变化。旧的switch是语句容易穿透和忘记break新语法支持箭头和返回值String kind switch (shape) { case Circle c - 圆形; case Rectangle r - 矩形; case Triangle t - 三角形; default - 未知; };这里case Circle c就是模式匹配如果shape是Circle类型就自动绑定到变量c然后在右侧分支里可以直接用c做计算。因为这个特性还是预览状态所以编译运行都要加--enable-preview。switch模式匹配还能处理nullObject obj null; String result switch (obj) { case null - null; case String s - 字符串: s; case Integer i - 整数: i; default - 其他; };JDK 17之前的switch对null直接抛NPE现在可以作为一个显式的分支参与匹配。配合守卫条件when还能做更精细的判断String result switch (obj) { case String s when s.length() 10 - 长字符串; case String s - 短字符串; case Integer i when i 0 - 负数; default - 其他; };这个写法最爽的地方在于表达力强以前要写一长串if-else if-else的嵌套判断现在一张switch表格列清楚每个分支对应一个case可读性直接起飞。不过因为它还是预览特性不建议在核心生产代码里铺开用等正式版本落地再全面切换也不迟。3.6 严格浮点语义不那么“语法糖”的低调改变JDK 17还有一个JEP 306——恢复始终严格的浮点语义Strict Floating-Point Semantics。这个改动对绝大多数业务开发来说是无感的因为它主要是把Java 1.2时代为了性能引入的“默认不严格浮点运算”反转回来让所有浮点运算默认就是严格行为。如果你不涉及高频数值计算或者极其依赖跨平台精确一致的浮点结果基本不需要关心。但这个改动提醒了我们每个JDK版本的演进除了看得见的语法糖还有一些底层行为的修正升级前看看Release Notes总没错。4. 综合实操用record、密封类和模式匹配重构一个订单状态机4.1 业务场景与目标为了让你理解这几个特性怎么配合我准备了一个相对完整的例子。假设你正在做一个订单系统订单有三种状态待支付、已支付、已取消。需求是根据不同订单状态和不同的入参计算订单展示文案并生成不同结构的通知消息。传统Java 8写法会定义一个抽象类Order几个子类分别代表不同状态然后在逻辑层用一堆if (order instanceof WaitingOrder)加强传来分发。现在我们用JDK 17重写一遍。4.2 定义数据载体和密封继承结构首先用sealed接口定义订单类型层级public sealed interface Order permits WaitingOrder, PaidOrder, CancelledOrder { String orderId(); BigDecimal amount(); }然后是三个实现类其中两个用record因为它们本质上就是数据载体一个用普通类因为它有额外的业务计算逻辑稍后塞一个工厂方法public record WaitingOrder( String orderId, BigDecimal amount, LocalDateTime createdAt) implements Order { } public record PaidOrder( String orderId, BigDecimal amount, LocalDateTime paidAt, String paymentMethod) implements Order { } public final class CancelledOrder implements Order { private final String orderId; private final BigDecimal amount; private final String reason; public CancelledOrder(String orderId, BigDecimal amount, String reason) { this.orderId orderId; this.amount amount; this.reason reason; } Override public String orderId() { return orderId; } Override public BigDecimal amount() { return amount; } public String reason() { return reason; } }注意CancelledOrder用final标记没有转成record是因为它后续可能扩展更多方法先保留传统类的形态。三个类都满足sealed interface的约束必须实现接口、必须有final/sealed/non-sealed修饰。4.3 用instanceof模式匹配和switch表达式做逻辑分发现在写核心的业务方法——根据订单类型生成展示文案。先看用instanceof模式匹配的写法public static String describeOrder(Order order) { if (order instanceof WaitingOrder w) { return 订单 w.orderId() 待支付金额 w.amount(); } else if (order instanceof PaidOrder p) { return 订单 p.orderId() 已支付支付方式 p.paymentMethod(); } else if (order instanceof CancelledOrder c) { return 订单 c.orderId() 已取消原因 c.reason(); } throw new IllegalStateException(unknown order: order); }代码已经很清爽了。但如果用了预览版的switch模式匹配会更好看public static String describeOrder(Order order) { return switch (order) { case WaitingOrder w - 订单 w.orderId() 待支付金额 w.amount(); case PaidOrder p - 订单 p.orderId() 已支付支付方式 p.paymentMethod(); case CancelledOrder c - 订单 c.orderId() 已取消原因 c.reason(); // 不需要 default因为 sealed 已经限定了只有这三种类型 }; }注意到没有因为Order是sealed接口编译器能在编译期判断所有情况下我已经穷尽了所有可能的实现类所以连default分支都可以省略。如果你以后新增了一个RefundedOrder子类忘了在这里加case编译器会直接给你一个编译错误而不是等到运行期才炸。这个编译期穷尽性检查是我觉得密封类模式匹配组合最值钱的地方。4.4 用文本块生成通知消息模板最后生成本例中“给用户发的通知消息”。这个需求用文本块再合适不过public static String buildNotification(Order order) { return switch (order) { case WaitingOrder w - 尊敬的用户 您的订单 %s 已创建待支付金额 %s 元。 请及时完成支付。 .formatted(w.orderId(), w.amount().toPlainString()); case PaidOrder p - 尊敬的用户 您的订单 %s 已支付成功金额 %s 元。 我们会尽快为您发货。 .formatted(p.orderId(), p.amount().toPlainString()); case CancelledOrder c - 尊敬的用户 您的订单 %s 已取消退款将原路返回。 取消原因%s .formatted(c.orderId(), c.reason()); }; }注意文本块里我用了%s占位配合formatted()做格式化既保留了模板的阅读性又避免了手写拼接。4.5 完整编译运行步骤把上面几个类放到com.example.order包下写一个带main方法的启动类public class OrderMain { public static void main(String[] args) { Order waiting new WaitingOrder(A001, new BigDecimal(199.00), LocalDateTime.now()); Order paid new PaidOrder(A002, new BigDecimal(299.00), LocalDateTime.now(), 微信支付); Order cancelled new CancelledOrder(A003, new BigDecimal(99.00), 用户主动取消); System.out.println(describeOrder(waiting)); System.out.println(describeOrder(paid)); System.out.println(describeOrder(cancelled)); System.out.println(buildNotification(waiting)); System.out.println(buildNotification(paid)); System.out.println(buildNotification(cancelled)); } }由于switch模式匹配在这个版本还是预览编译运行要带开关javac --release 17 --enable-preview -d out com/example/order/*.java java --enable-preview -cp out com.example.order.OrderMain输出结果会是订单 A001 待支付金额199.00 订单 A002 已支付支付方式微信支付 订单 A003 已取消原因用户主动取消 ...整个业务逻辑没有强转、没有if嵌套、没有手写构造器和equals代码量相比Java 8大概少了三分之一。而且因为用上了sealed和模式匹配以后扩展新订单状态的时候编译器会主动提醒你哪里漏改这对中年人来说真的是“保命功能”。5. 常见问题与排查技巧实录5.1 编译和运行时报错速查表问题现象出错的根因解决办法class file has wrong version 61.0, should be 52.0编译器是Java 8试图读取Java 17的class文件确认javac -version指向JDK 17或Maven/Gradle编译插件版本是否太旧preview features are not enabled用了switch模式匹配但没开启预览开关编译和运行都加上--enable-preview两者缺一不可sealed class must have subclassessealed类/接口没有列出允许的子类型用permits明确列出直接子类或者把类定义改为non-sealedclass is not allowed to extend sealed class子类不符合sealed约束检查子类是否带final/sealed/non-sealed修饰符以及是否在同一个包/模块中cannot find symbol指向record的访问器记成了getName()但record访问器是name()record的访问器方法名和组件名完全一致不加get前缀文本块输出比预期多出很多空格结束缩进位置不对把内容行和结束分隔符保持相同的缩进编译器会按结束分隔符的缩进截断公共前导空白5.2 环境里的JDK版本“幽灵”问题如果你电脑上之前装过Java 8或11配好JDK 17后发现java -version还是老版本大概率是PATH顺序问题。Windows里Oracle安装器会往PATH最前面塞一个C:\Program Files\Common Files\Oracle\Java\javapath导致你配置的%JAVA_HOME%\bin排在后面被跳过。处理方法很直接删掉这个javapath条目或者把它挪到%JAVA_HOME%\bin后面。Linux上则常见两个JDK共存的情况用which javac和readlink -f $(which javac)查一下实际指向哪个路径。如果项目里Maven直接用系统javac可能还需要mvn -v里显示的Java版本是17才生效否则要去改JAVA_HOME环境变量。5.3 record与Lombok的共存话题很多老项目里大量使用Data、Builder升级到JDK 17后用record替代了一部分。但注意record和Lombok在某些场景下不要混用。record本身已经生成了equals/hashCode/toString你再加Data会造成重复代码生成甚至 IDE 会报警告。我曾经见过有人用了record还在上面标Getter编译出来访问器方法名打架的问题。更合理的分工是纯数据类用record需要复杂构建链的继续用Lombok的Builder两者共存于项目没问题但不要叠加在同一个类上。5.4 老依赖与新JDK的不兼容问题JDK 17移除了Applet API、Java EE模块等一堆老东西这导致一些用了十来年的旧库可能直接跑不起来。最常见的是javax.xml.bindJAXB在JDK 11以后就不再内置了做报表、对接老接口时需要用到。解决办法是在pom里显式加依赖dependency groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId version2.3.1/version /dependency另外CGLIB在旧版本上可能遇到IllegalAccessError这是JDK 17反射访问边界收紧引起的问题一般升级到最新版CGLIB或直接换JDK动态代理都能解决。我的建议是升级前把项目跑一遍测试重点翻翻第三方库版本是否满足JDK 17要求Spring Boot 3.0以上版本已经是以17为基线了库的兼容性会比老框架好很多。5.5 IDE和构建工具版本别拖后腿如果你用的IntelliJ IDEA是2020年之前的老版本就算本机装了JDK 17编辑器里也可能不认record和sealed出现红色波浪线。这不是代码错了是IDE的语言级别设置太旧。检查一下Project Structure → Project → SDK 选择17Language Level选17 - Sealed types, always-strict floating-point semantics。Maven的话maven-compiler-plugin建议升级到3.8.1以上pom.xml里显性指定properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties编译插件版本过老时即使source/target写了17也会被忽略或报“无效的目标发行版”。6. 从JDK 17开始Java语法进入新阶段我个人的体会是JDK 17这波语法更新真正把Java从“老旧稳重”往“现代化”方向推了一大步。之前Java 8普及了好多年大家都习惯了lambda和Stream但每天写DTO、写类型判断、写if-else分发的痛点一直没变。record解放了数据类的重复劳动instanceof模式匹配和sealed类让面向对象设计在编译期有了更精确的表达文本块让多行文本处理回归直觉。这些特性单独看都不算石破天惊但组合起来你会发现自己写业务代码的速度和代码可读性都有了实实在在的提升。当然我也能理解有些人会说“新语法要学、老项目改了有风险”。我的建议是别急着把整个系统翻新可以先在新模块或新接口上试用record、文本块这些非预览特性感受一下温泉水的温度。模式匹配和密封类这类构建在“代码即约束”思路上的特性等团队熟悉了之后再做推广收益会比从零硬推高得多。最后分享一个我实际操作中的小技巧准备升级项目的头一周先别把JDK版本切到17去跑全量构建而是写一个几天的转换计划——先把代码里所有POJO替换成record再替换手写字符串拼接为文本块最后再加--enable-preview尝试用新的switch模式匹配写出一个“理想版”的核心业务方法。这样分阶段走就算某一步出了问题定位起来也比一次性大改造容易得多。这个顺序我实测下来踩的坑最少也是我目前每次给团队做JDK升级时都会固定的路线。
返回列表