资讯详情

JDK 17核心新特性解析:密封类、switch模式匹配与迁移清单

📅 2026/10/20 16:55:30 | 华诺云谱 👁 阅读
JDK 17核心新特性解析:密封类、switch模式匹配与迁移清单
JDK 17 是 2021 年 9 月发布的长期支持版本不少团队从 JDK 8 直接跨到 17第一反应往往是语法层面没什么大变化啊record、文本块不是 16 就已经有了吗表面上看确实如此JDK 17 的语法新特性数量不多但质量很高——sealed class 正式转正switch 的模式匹配第一次以预览身份亮相浮点语义又被硬生生掰回严格模式。这三件事单独看都算平滑演进组合起来其实回答了同一个问题Java 在类型系统上想把“约束”这件事做到什么程度。这篇文章就围绕这三个核心点展开外加一份升级前必须了解的“硬变化”清单。如果你是正准备从 8/11 跳到 17 的开发者或者已经在用 17 但只把它当成“性能更好的 11”那这篇内容应该能帮你把语言层面的账算清楚。1. JDK 17 到底带来了什么先看版本定位和语法新特性全貌1.1 为什么 JDK 17 值得认真看很多人会问JDK 21 都出来了为什么还盯着 17 不放答案很实际17 是自 11 之后第一个被人真正当“长期支持版本”用的 LTS。Oracle 当时把 LTS 的节奏变成两年一个19、20 都不是 LTS21 虽然也是但生态层面的适配、依赖库的最低版本要求、云厂商的默认镜像都是先在 17 上完成沉淀的。主流的后端开发框架和中间件新版本基本都会要求 Java 17 起步这不是情怀是兼容性表格里白纸黑字写着的要求。站在“语法新特性”这个角度17 看起来克制是因为 Java 的语言演进策略从“不断加糖”变成了“把预览功能打磨成熟”。8 到 9 那种模块系统带来的剧痛在 17 上不会再出现。JDK 17 的 JEP 列表里真正跟源码写法相关的并不多但每一个都值得花时间理解因为它们直接改变你建模和写分支逻辑的方式。1.2 语法层到底有哪些“新”东西先把 JDK 17 里跟语言规范相关的 JEP 列一下避免被各种二手文章带偏JEP内容性质JEP 409密封类Sealed Classes正式转正JEP 406switch 模式匹配Pattern Matching for switch预览特性JEP 306恢复始终严格的浮点语义语言规范修正严格来说JEP 306 不是“新增语法”它改变的是浮点表达式求值的语义定义属于语言层的修正。这三条之外JDK 17 里还有一堆运行时和内部结构的改动比如强封装 JDK 内部 API、移除实验性 AOT/JIT 编译器等它们不改变你手写的语法但会影响你在运行时能不能反射到某些内部类。所以本文的第五章我会单独讲这些“同版本不同赛道”的变化它们在升级时踩坑率很高。现在先把主角请出来sealed class。2. 密封类sealed被很多人忽略却最该先学的语法特性2.1 从一个业务域模型例子开始想象你在写一个支付结果类型它有两种或三种可能成功、失败、处理中。用枚举能表达结果类型但放不下每个结果带的数据比如成功时的交易流水号、失败时的错误码、处理中时的预计等待时间。没有密封类之前常规做法是建一个抽象类下面放几个子类。为了不让别人乱继承有些人会把这些子类设成 final。但这有个问题父类本身没有关闭扩展通道任何第三方只要继承了 PaymentResult你的领域模型就被污染了。类型系统只保证了表面的“抽象”没有保证“就这些子类”。密封类解决的就是这个问题。它允许你声明一个类或接口并且明确列出能继承/实现我的只有这几个。其他任何类想继承编译器直接拒绝。public sealed interface PaymentResult permits Success, Failure, Pending { // 可以定义公共方法 String summary(); } public record Success(String transactionId, long amount) implements PaymentResult { public String summary() { return success: transactionId; } } public record Failure(String errorCode, String message) implements PaymentResult { public String summary() { return failure: errorCode; } } public record Pending(int estimatedSeconds) implements PaymentResult { public String summary() { return pending: estimatedSeconds s; } }这段代码的意思是PaymentResult只接受Success、Failure、Pending三种实现形式。看到这段代码的人不需要去全局搜索implements PaymentResult就能确定整个系统里所有支付结果的可能形态。2.2 sealed / permits / non-sealed 的规则拆解用 sealed 修饰的类或接口后面必须跟permits子句列出直接子类型。有几个细节容易被忽略直接子类型必须在同一个模块中。没有模块化的话必须在同一个包中。这保证了父类能“看到”所有子类。每个直接子类型必须显式声明自己是 final、sealed 或 non-sealed。final 表示这棵树下死了sealed 表示它也继续限制自己的子类non-sealed 表示在这个分支上重新开放。permits中列出的必须是直接子类不是间接子类。如果漏掉某个实际子类编译报错。public sealed class Shape permits Circle, Square, Triangle { } public final class Circle extends Shape { } public sealed class Square extends Shape permits ColoredSquare, TransparentSquare { } public non-sealed class Triangle extends Shape { }这里Square还能继续往下分所以它是 sealed 的Triangle自己不想约束了就用 non-sealed 把限制解除。non-sealed 这个名字刚看有点绕其实含义就是“虽然我爹 sealed但我的后代不受限”用于处理那些“子类本身就是一个新类型体系的根”的场景。这里有一个小坑如果某个子类既不是 final也不是 sealed也不是 non-sealed编译期会给你一句很明确的报错提示它必须显式选择一种扩展状态。这个设计逼着你每次写子类时都思考这个分支要不要继续开放下去。在长期维护的项目里这种“逼迫”是好事。2.3 编译器为你做的穷尽性检查密封类最大的价值是在编译期就完成对可能类型的穷尽性检查。看下面这段处理逻辑public String describe(PaymentResult result) { if (result instanceof Success s) { return ok: s.transactionId(); } else if (result instanceof Failure f) { return fail: f.errorCode(); } else if (result instanceof Pending p) { return wait: p.estimatedSeconds(); } throw new IllegalStateException(unknown result); }如果没有 sealed你怎么知道还存不存在第四种实现只能靠全项目搜索搜不到也不敢删掉最后的兜底异常。一旦用了 sealed如果这三行分支把三种类型都覆盖了编译器将不再要求必须有 else。如果未来有一天你想增加第四种Cancelled所有处理PaymentResult的分支代码会在编译期直接报错提示你没有覆盖这个新分支。这种“改一处编译器帮你列出所有需要同步修改的位置”的能力比任何代码生成器和文档都可靠。它把运行时才能暴露的问题提前到了编译阶段。2.4 与 record 组合的惯用法sealed 接口和 record 是天然搭对的。record 自带构造方法和访问器sealed 负责封闭类型集合组合起来就是一套非常干净的代数数据类型写法。在实际项目中最典型的场景就是“状态机”public sealed interface OrderState permits CreatedState, PaidState, ShippedState, CompletedState, CancelledState { } public record CreatedState(long orderId, String customerId) implements OrderState { } public record PaidState(long orderId, String paymentRef) implements OrderState { } public record ShippedState(long orderId, String trackingNo) implements OrderState { } public record CompletedState(long orderId, long finishedAt) implements OrderState { } public record CancelledState(long orderId, String reason) implements OrderState { }每个状态自己携带所需的数据状态之间的迁移可以写成返回OrderState的方法编译器保证所有转换结果都在这个封闭集合里。我在这类状态机模型上试过之后最直接的感受就是以前为了“防止别人乱 new 状态”写的防御性逻辑全都可以删了类型系统替我把门看住了。2.5 工程里的注意点模块、反射、可扩展性sealed 类不是银弹有几个工程层面的事得提前知道。第一模块化环境和非模块化环境的规则不同。如果你的项目还没有module-info.java所有相关类必须在同一个包内。跨包直接子类是编译不过的。这点在大型多包项目里容易踩特别是当你试图把一个父类放在一个包、把几个子类放在另一个包时。第二反射层面可以通过getPermittedSubclasses()拿到允许的子类数组。序列化框架、ORM 映射框架如果要做类型判断可以基于这个结果做校验不必再靠硬编码集合。这也意味着某些字节码增强类库如果不认 sealed就会在增强阶段报错升级到 17 后需要留意。第三不要滥用 non-sealed。我见过有人把一个 sealed 父类的所有子类都标成 non-sealed表面用了新语法实际等于放弃了约束。如果某个分支确实需要无限扩展那它本来就不应该在 sealed 层级里。相对更合理的做法是把需要开放的部分下沉为 non-sealed 的一个子树让 sealed 只约束上层的主干。3. 模式匹配的 switch预告片比正片更值得研究3.1 老写法三件套有多别扭在没有模式匹配之前想按类型走不同分支写法没法优雅。最常见的是先instanceof再强转JDK 16 之后可以把instanceof的绑定变量直接用起来稍微好了一点但本质还是 if-else 链。如果类型多了比如七八种代码就变成一大坨缩进相同的if (x instanceof A) { ... } else if (x instanceof B) { ... }看的人容易眼疲劳。老式 switch 的问题更明显只能匹配整数、枚举、字符串这些“值”匹配不了类型。而且 switch 语句的break忘写导致穿透是低级 bug 的重灾区。JDK 14 引入箭头语法的 switch 表达式之后穿透问题解决了但能匹配的类型依然受限。JDK 17 把模式匹配带进了 switch预览版本解决的就是这个问题让 switch 不只能匹配值还能匹配类型和结构。3.2 新语法逐个拆箭头、类型模式、null、守卫JDK 17 的预览语法长这样static String formatterPatternSwitch(Object obj) { return switch (obj) { case null - unknown; case Integer i - String.format(int %d, i); case Long l - String.format(long %d, l); case Double d - String.format(double %.2f, d); case String s - String.format(String %s, s); default - obj.toString(); }; }几个关键点case null老代码里 switch 遇到 null 会直接抛 NPE你必须在外面判空。现在可以在 switch 内部把 null 当作一个普通分支处理。注意case null必须写在其他 case 之前否则编译器不满意。case Integer i只要 obj 是 Integer 类型变量 i 就绑定在这个对象上进入分支后直接用不需要再强转。default分支仍然是兜底用于处理没被覆盖的类型。还有一个常用能力是“守卫模式”用来在一个类型模式上再追加条件static int process(Integer n) { return switch (n) { case Integer i i 0 - i 1; case Integer i i 0 - 0; case Integer i i 0 - -i; default - -1; }; }case Integer i i 0的意思是只有满足类型匹配并且 i 大于 0 才进入这个分支。这个后面的部分就是守卫guard。它在写业务规则时非常有用可以省掉在分支内再写 if 判断的层级嵌套。当 switch 的选择器类型是 sealed 类时编译器还能做穷尽性检查如果你把所有子类型都覆盖了可以不写 default。这是 sealed 和模式匹配联动的一个核心价值。在 JDK 17 上这些都是预览特性需要加--enable-preview才能编译运行。到后来的 JDK 21 才正式转正但语法上基本就是这样学 17 的预览版本不会白学。3.3 预览特性落地需要做什么如果你在 JDK 17 上想试这些语法不能直接javac Test.java完事。编译器会提示要用预览特性。命令行方式javac --release 17 --enable-preview Test.java java --enable-preview Test注意编译和运行都要带上同一个参数。用 Maven 的话在pom.xml里加properties maven.compiler.release17/maven.compiler.release maven.compiler.enablePreviewtrue/maven.compiler.enablePreview /properties用 Gradle 则是在build.gradle里配置java { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 options.compilerArgs [--enable-preview] } tasks.withType(JavaExec) { jvmArgs([--enable-preview]) }很多人在这里会漏掉运行时的 JVM 参数导致编译通过、运行时报UnsupportedClassVersionError或者提示预览类需要 enable-preview。遇到这种错误先检查运行配置别怀疑代码。3.4 常见编译错误与解答预览特性因为用的人还不够多网上能查到的报错信息反而碎片化。我把自己踩过的几个容易困惑的编译错误写下来“falling through from a pattern case” 这个报错意思是模式匹配的 case 不允许穿透到下一个 case。箭头语法本身不穿透但如果你用的是带冒号的旧式 case 加上模式匹配编译器会拒绝。实际用的时候统一换成箭头语法别混用。“the switch statement does not cover all possible input values” 出现在选择器是密封类型但分支没覆盖全时。检查一下是不是漏了某个子类型或者某个 case 写了父类型但没写具体子类型。绑定变量的作用域范围是当前 case不会泄漏到其他 case。不同 case 里声明同名变量是合法的因为它们互不可见。这个预览特性里最值得研究的是与 sealed 类的联动。如果你已经用了密封类建模再配合模式匹配 switch等于把原本需要手写大量防御判断的分支逻辑交给编译器代码干净很多。4. JEP 306浮点语义的“回归”多数人不知道的语言层变化4.1 什么是“非严格浮点”怎么来的这个话题得从 Java 1.2 讲起。早期 x87 指令集和 SSE2 指令集在浮点运算上的行为不一样x87 的 80 位扩展精度会让中间计算结果多出几位有效数字。为了让 Java 在不同硬件上的浮点结果一致当时引入了strictfp关键字只有显式声明strictfp的代码才保证遵循严格的 IEEE 754 语义默认情况下 JVM 可以利用扩展精度做优化。问题在于绝大多数普通开发者根本不会写strictfp但默认不严格的状态又带来了跨平台的微小差异。等到硬件基本都转向 SSE2实现严格浮点的成本已经很低继续保留这套复杂机制已经没有意义。4.2 JDK 17 把默认变成严格JEP 306 做的事简单说就是把严格浮点语义变成默认所有浮点表达式都按照 IEEE 754 标准来计算。之前“默认非严格、需要时用 strictfp 拉回来”的模式被废弃了。这意味着什么在 JDK 17 上同样一段浮点计算不管跑在 x86 还是 ARM 上可预期的结果一致性比 JDK 8/11 更强。当然跨平台完全一致从来都还要考虑编译器优化、数学库的精度等但至少语言层不再允许利用额外精度做等价变换。4.3 我们现在还需要写 strictfp 吗不需要了。strictfp 这个关键字在 JDK 17 里还保留着但它的修饰已经没有实际效果纯粹为了兼容旧代码。我见过一些老项目里到处标着 strictfp升级到 17 后这些代码的行为和之前默认模式是不一样的但和之前 strictfp 模式下的行为一致所以不会出问题。反过来如果之前依赖默认非严格的微小误差升级后会感觉到变化。对绝大多数业务代码来说这点浮点语义变化根本感觉不到。但如果你在写数值计算、几何算法、加密协议相关的代码建议在升级后跑一遍回归测试确认运算结果没有超出容忍范围。我在一个图形变换的模拟项目里遇到过浮点结果在小数点后第六位发生变化排查了半天才想到是 JEP 306 的锅。4.4 为什么它被列在语法新特性里严格说这不是语法新特性而是语义修正。但它出现在语言规范层面影响的是表达式求值规则所以很多技术文章会把它和 sealed、switch 模式匹配放在一起讲。理解这一点的好处是遇到浮点精度不一致的问题时你能第一时间想到是 JDK 语言语义变了而不是自己的代码被改了。5. 升级到 JDK 17 前你需要知道的其他“硬”变化5.1 强封装内部 APIsetAccessible 不再万能JDK 17 里的 JEP 403 把 JDK 内部 API 的强封装从默认开启变成了不可关闭的常态。这句话翻译成人话就是以前你用反射去访问sun.*或者jdk.internal.*下面的类虽然不推荐但经常能成功在 17 上默认情况下setAccessible(true)会直接抛异常只有少数几个被官方许可的包依然开放。受影响最典型的是各种“黑科技”框架、字节码增强工具、某些埋点 agent。升级到 17 后如果线上突然出现InaccessibleObjectException十有八九是强封装导致的。解决方式不是去加--add-opens绕一时而是看清依赖的三方库是否发布了兼容 17 的版本。5.2 被移除的实验性 AOT 与 Security Manager 的哲学JDK 17 删掉了实验性的 AOT/JIT 编译器原因是它们维护成本高使用率却很低。对于普通应用这类移除不影响任何东西。真正需要留意的是 Security ManagerJEP 411 将其标记为弃用为后续彻底移除做准备。还在依赖 Security Manager 做权限控制的项目要尽快做迁移规划。当然绝大多数微服务项目根本没把安全策略建立在 Java 的 Security Manager 上而是靠框架层鉴权影响有限。5.3 一个靠谱的迁移清单从 8/11 迁移到 17我的步骤大致是这样先做全量依赖分析确认所用框架和第三方库的最低版本是否支持 17。把编译配置改成--release 17不要只改 source/target二者不一致容易编译出带老 target 的 class。扫描代码里的内部 API 调用和反射访问特别是setAccessible相关提前加启动日志观察有没有InaccessibleObjectException。默认不加--enable-preview先跑现有代码确认没有运行问题。跑一遍浮点敏感模块的回归测试确认数值结果在容忍范围内。语法层面的改造可以往后放sealed 和模式匹配都是“新增能力”不会破坏存量代码。这条清单背后的逻辑是JDK 17 的语法新特性是增量升级风险主要来自运行时和内部 API 的收紧。先把存量稳住再谈用新语法。5.4 实测中最容易翻车的几个点我实际帮别人排查迁移问题的时候反复遇到下面几类某性能监控 agent 使用了不少内部类升级到 17 后需要更新版本否则启动直接失败。Lombok 这类编译期注解处理器老版本在 JDK 17 上会报 “java.lang.IllegalAccessError” 或者编译警告需要升级到支持 17 的版本。用了很多模块化反射的框架比如某些依赖注入框架在 JDK 17 上需要配置--add-opens才能正常实例化内部类。这些都跟语法无关但它们决定了你能不能顺利“用上”语法新特性。所以我一直觉得升级 17 的路线图应该先是“稳定运行”然后才是“语法尝鲜”。我个人在几个模拟项目里把 sealed class 用在了结果类型和订单状态上把原本散落各处的 if-else 判断收敛成了类型安全的分支。切到 JDK 17 的头两周最明显的感受不是某个新语法很惊艳而是编译器开始替我检查那些以前只能靠人肉 review 的约束了。这种感觉有点像从动态类型的匆忙切换到静态类型的踏实感代码写得慢了一点但后期改起来省了很多心。如果你手里正好有一个状态较多、分支复杂的模块建议从它开始试点 sealed 类等 switch 模式匹配在后续版本正式转正后再整体迁过去。这个升级节奏是我目前认为最省力的路径。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑