# Java 17 新特性

Source: https://codewiki.com/zh/java/java17-features/

> - **what**: Java 17 源码级别包含密封类，并汇集了此前正式发布的记录类、`instanceof` 模式匹配、文本块和 `switch` 表达式。
> - **trap**: 「Java 17 可用」不等于「Java 17 首次引入」；类型模式 `switch`、记录模式和 `when` 守卫都不属于稳定的 Java 17 语法。
> - **fix**: 用当前 JDK 的 `javac --release 17` 编译全部源码和测试，并分别审查记录类的可变组件与密封层次的直接子类型。

## 是什么，为什么存在

Java 17 新特性不是一个彼此同时出现的语法包，
而是 Java 17 源码级别能够稳定使用的一组语言能力。
其中，密封类（sealed class）在 Java 17 正式发布；其余四项在 Java 14 到 Java 16 之间已经转正。
把它们放在同一迁移基线下讨论，有助于区分「目标项目可以使用什么」与「某项语法最初在哪个版本发布」。

这组能力解决的是常见的建模摩擦。
记录类（record class）用声明状态的方式定义数据载体；模式变量（pattern variable）把类型检查与安全转换合并。
文本块（text block）保留多行文本的源码结构；`switch` 表达式（switch expression）让分支直接产生值。
密封类型则把「哪些直接子类型合法」变成编译器可以检查的约束。

这些特性并没有把 Java 变成另一种语言。
记录类仍然是类，文本块仍然产生 `String`，模式匹配仍然执行运行时类型检查，`switch` 表达式仍然遵循静态类型和完整性规则。
价值来自更准确地表达意图，而不是省下字符本身。

你会在升级到 Java 17 的代码库、库的最低 Java 版本声明、
构建插件配置以及 AI 生成的现代 Java 代码中遇到这些边界。
如果项目已经能使用 Java 17，这些构造通常可以逐步采用，不要求一次重写整个系统。
不过，源码级别、运行时版本和依赖项的类文件版本是三个不同的兼容性问题。

## 工作原理

### 五项特性对应五个正式版本

下表记录的是正式发布版本，不是预览版本。
Java 17 可以稳定编译表中全部语法，但只有密封类是在 Java 17 转正。

| 特性 | 正式版本 | 编译器提供的主要保证 |
| --- | --- | --- |
| `switch` 表达式 | Java 14 | 每条正常完成路径产生兼容的值 |
| 文本块 | Java 15 | 规范化换行、处理附带缩进和转义 |
| `instanceof` 模式匹配 | Java 16 | 匹配成功路径中的类型绑定和流作用域 |
| 记录类 | Java 16 | 从组件声明派生成员与值语义方法 |
| 密封类 | Java 17 | 限制允许的直接子类或直接实现类 |

预览特性需要 `--enable-preview`，
并绑定到编译时所用的具体 JDK 版本。
生产代码若承诺稳定的 Java 17 源码级别，就不应靠预览开关获得类型模式 `switch`。
该语法后来继续变化，因此从新教程复制的 `case Type value ->` 或 `when` 守卫不能倒推为 Java 17 稳定语法。

### 记录类声明数据形状

`record Account(String owner, Tier tier)` 声明两个记录组件。
编译器据此提供同名私有 `final` 字段、规范构造器、`owner()` 与 `tier()` 访问器，以及基于全部组件的 `equals`、`hashCode` 和 `toString`。
记录类隐式继承 `java.lang.Record`，并且隐式为 `final`，所以不能再继承其他类或被普通类扩展。

紧凑规范构造器省略参数列表和显式字段赋值。
构造器体可以校验或重新绑定参数，随后编译器把最终参数值赋给字段。
这种写法适合在对象诞生时建立不变量，但它不会自动复制列表、数组或其他可变组件。

### 模式变量使用流作用域

表达式 `value instanceof Account account` 先检查运行时类型，成功后才绑定 `account`。
变量的可用范围由控制流决定，而不只是由最近的花括号决定。
因此，`&&` 右侧可以使用成功匹配后的变量；如果条件取反后立即返回，变量也可以在后续路径中使用。

`null instanceof Account account` 的结果是 `false`，不会绑定变量，也不会抛出异常。
模式匹配只消除重复的显式转换，不会验证对象内部字段，不会把开放类型层次变成封闭层次，也不会替代业务校验。

### 密封类型约束直接继承关系

`sealed` 类型通过 `permits` 列出允许的直接子类型；当允许的子类型与它声明在同一编译单元时，编译器也可以推断该列表。
每个直接子类型必须明确选择 `final`、`sealed` 或 `non-sealed`，记录类和枚举则可能隐式满足要求。
`non-sealed` 会重新开放该分支，所以「根类型密封」不等于整个后代集合永远有限。

在命名模块中，允许的直接子类型必须与密封类型属于同一模块。
在未命名模块中，它们必须属于同一包。
这些约束让类型所有者控制边界，也意味着跨模块插件接口通常不适合作为密封根类型。

### 文本块和 `switch` 仍有精确语义

文本块的开始定界符后
必须换行。
编译器会规范化行终止符、删除附带的公共缩进，再解释转义序列；结束定界符的位置会参与缩进计算。
结果只是普通 `String`，通常还包含结束定界符之前的最后一个换行。

`switch` 表达式必须覆盖选择器的所有可能值。
箭头规则不会贯穿；单个表达式直接提供结果，块规则则用 `yield` 提供结果。
在稳定的 Java 17 中，枚举适合展示这种完整性，而针对密封类型的模式 `switch` 仍属于预览边界。

## 示例

### 在一个 Java 17 程序中组合稳定特性

第一个示例把记录类、`instanceof` 模式变量、枚举 `switch` 表达式和文本块放在同一个源码文件中。
它没有使用预览开关，因而可以作为 Java 17 源码兼容性的最小烟雾测试。

<!-- quick -->

```java
public class ReleaseFeatures {
    enum Tier { BASIC, PRO, ENTERPRISE }

    record Account(String owner, Tier tier) {
        Account {
            if (owner == null || owner.isBlank()) {
                throw new IllegalArgumentException("owner is required");
            }
            owner = owner.strip();
        }
    }

    static String label(Object value) {
        if (!(value instanceof Account account)) {
            return "unsupported";
        }

        int seats = switch (account.tier()) {
            case BASIC -> 1;
            case PRO -> 10;
            case ENTERPRISE -> 100;
        };

        return """
            %s (%s)
            seats=%d
            """.formatted(account.owner(), account.tier(), seats);
    }

    public static void main(String[] args) {
        System.out.print(label(new Account(" Lin ", Tier.PRO)));
        System.out.println(label(42));
    }
}
```

```text
Lin (PRO)
seats=10
unsupported
```

<!-- /quick -->

紧凑构造器先拒绝空名称，再把重新绑定后的 `owner` 写入记录字段。
否定的模式匹配分支返回后，后续代码可以把 `account` 当作已经绑定的 `Account` 使用。
枚举分支全部覆盖，所以不需要 `default`。

### 用密封接口建模封闭结果

Java 17 可以稳定声明密封层次，也可以对每个分支使用 `instanceof` 模式匹配。
这里故意不使用类型模式 `switch`；最后的异常分支同时防止未来修改造成静默遗漏。

```java
import java.util.ArrayList;
import java.util.List;

public class SealedResult {
    sealed interface Lookup permits Found, Missing {}

    record Found(String id, List<String> roles) implements Lookup {
        Found {
            roles = List.copyOf(roles);
        }
    }

    record Missing(String id) implements Lookup {}

    static String describe(Lookup result) {
        if (result instanceof Found found) {
            return found.id() + ": " + found.roles().size() + " roles";
        }
        if (result instanceof Missing missing) {
            return missing.id() + ": missing";
        }
        throw new IllegalStateException("unhandled result: " + result);
    }

    public static void main(String[] args) {
        var sourceRoles = new ArrayList<>(List.of("reader", "author"));
        var found = new Found("u-17", sourceRoles);

        sourceRoles.add("admin");
        System.out.println(describe(found));
        System.out.println(describe(new Missing("u-18")));
        System.out.println("source roles: " + sourceRoles.size());
        System.out.println("snapshot roles: " + found.roles().size());
    }
}
```

```text
u-17: 2 roles
u-18: missing
source roles: 3
snapshot roles: 2
```

两个记录类隐式为 `final`，因此合法地关闭了密封接口的两个分支。
`List.copyOf` 让 `Found` 保存不可修改的快照；之后修改源列表不会改变记录的值。
如果组件本身是可变领域对象，还需要该对象自己的复制或不可变策略。

### 看清文本块的缩进和 `yield`

第三个示例让 `switch` 的块规则通过 `yield` 产生值，再把结果插入文本块。
结束定界符与内容的公共缩进被移除，内容内部额外的两个空格则会保留。

```java
public class TextBlockReport {
    enum Status { QUEUED, RUNNING, DONE }

    static String render(Status status, int count) {
        String detail = switch (status) {
            case QUEUED -> "waiting";
            case RUNNING -> {
                if (count == 0) {
                    yield "starting";
                }
                yield "processed " + count;
            }
            case DONE -> "complete";
        };

        return """
            status=%s
              detail=%s
            """.formatted(status, detail);
    }

    public static void main(String[] args) {
        System.out.print(render(Status.RUNNING, 3));
        System.out.print(render(Status.DONE, 0));
    }
}
```

```text
status=RUNNING
  detail=processed 3
status=DONE
  detail=complete
```

`yield` 只结束当前 `switch` 表达式的块规则，并把值交给表达式；`return` 则会试图结束整个方法。
文本块最后的换行来自结束定界符位于下一行。
如果结束定界符紧跟最后的内容，结果就不会自动带上该换行。

## 陷阱

> **陷阱:** 把「Java 17 新特性」理解成五项语法都在 Java 17 首次发布，会让版本说明和最低版本配置失真。

**修复：** 分别记录正式版本：`switch` 表达式是 14，文本块是 15，记录类与 `instanceof` 模式匹配是 16，密封类是 17。
迁移文档可以把它们称为 Java 17 基线可用特性，但不要改写发布历史。

> **陷阱:** AI 或新教程常生成 `switch (value) { case Order order -> ... }`，甚至加入 `when` 守卫，然后把它标成稳定的 Java 17 代码。

**修复：** 对承诺 Java 17 的构建使用 `javac --release 17`，且不要添加 `--enable-preview`。
在这个源码级别使用 `instanceof` 链、访问者或多态方法；需要正式的模式 `switch` 时，把最低版本明确提高到 Java 21。

> **陷阱:** 记录组件字段是 `final`，但记录不会自动让组件指向的 `List`、数组或领域对象不可变。

**修复：** 在规范构造器中校验组件，并按所有权要求使用 `List.copyOf`、数组副本或不可变领域类型。
还要检查访问器是否会重新暴露可变存储；记录类提供的是浅层状态约束，不是递归冻结。

> **陷阱:** 给封闭枚举的 `switch` 表达式随手添加 `default`，会让新增枚举常量继续编译并走入一个含糊的旧分支。

**修复：** 对真正由当前模块拥有的封闭枚举，列出全部常量并让编译器在源码重编译时暴露遗漏。
来自文本或网络的未知值应在解析边界单独处理；源码完整性也不能替代针对旧调用方的二进制兼容性测试。

> **陷阱:** 文本块改善了排版，却不会转义 JSON、HTML、Shell 或 SQL 中插入的动态数据。

**修复：** 使用对应格式的序列化器、转义器或参数化查询。
`formatted()` 只执行字符串格式化；把不可信输入填进 SQL 文本块，仍然会产生注入漏洞。

> **陷阱:** 在可扩展插件边界上使用密封接口，或在某个允许分支上写 `non-sealed` 后仍假设所有后代都已知，会得到错误的封闭模型。

**修复：** 只在一个模块或包真正拥有类型集合时使用密封层次，并逐个审查直接子类型的修饰符。
扩展点应采用普通接口；如果某个分支有意开放，就不要把根类型的 `permits` 列表当作全部运行时类型清单。

<!-- deep -->

## 迁移边界与编译结果

### `--release` 同时约束语法和平台 API

在较新的 JDK 上，`javac --release 17` 会按 Java 17 的语言规则编译，并限制代码使用 Java 17 对应的已记录 Java SE API。
它还生成 Java 17 可识别的类文件；Java 17 的类文件主版本号是 61。
只设置当前 JDK 的默认编译选项，无法证明产物能在 Java 17 运行。

`--release` 不会检查第三方依赖是否也提供 Java 17 兼容的类文件，也不会证明反射、服务加载或框架代理在目标运行时行为一致。
因此，迁移验证至少要包含源码编译、依赖解析和真实 Java 17 运行测试。
本主题的示例还在 Java 25 上运行，以确认这些 Java 17 类文件可被当前目标 JDK 执行。

### 记录类在类文件中保留结构

记录类的类文件继承 `java.lang.Record`，并带有描述记录组件的 `Record` 属性。
字段、访问器和对象方法不是预处理器展开的源码文本，而是编译器依据记录声明生成的类成员和引导信息。
反射和序列化框架可以识别记录结构，但框架是否支持记录构造方式仍取决于该框架版本。

自动生成的相等性会逐组件比较。
这不会把数组变成按元素比较的值类型，也不会为可变组件建立快照。
如果记录要充当映射键或跨线程值，组件本身的相等性、稳定性和线程安全仍然属于设计契约。

### 密封关系是类文件契约

密封类或接口的类文件记录允许的直接子类型。
JVM 在加载相关类型时会执行约束，因此它不只是源码检查器使用的提示。
加入、移除或重新开放一个允许分支都可能影响外部编译代码，应作为 API 演化来审查。

Java 17 的稳定语法还不能用类型模式 `switch` 消费这个层次，所以示例使用 `instanceof` 链和显式兜底。
在 Java 21 或更高源码级别改成完整的模式 `switch` 后，新增允许分支通常会在重新编译消费者时暴露遗漏。
已经发布的旧类文件仍需要二进制兼容性测试，不能只依赖新一次编译。

### 模式变量主要改变源码控制流

`instanceof` 模式把检查、转换和局部变量绑定写成一个构造。
编译器仍会产生相应的运行时类型检查与转换操作，并用控制流证明变量在哪些路径已经绑定。
它不提供新的动态分派协议，也不会绕过类型擦除。

因此，`value instanceof List strings` 仍然非法，因为运行时无法检查被擦除的元素类型实参。
可以匹配 `List<?>`，再按需求验证元素。
把模式变量引入更短，并不意味着后续转换、空值或集合内容已经安全。

### 文本块和 `switch` 不承诺性能捷径

文本块在编译期变成普通字符串常量或普通字符串构造的一部分。
`switch` 表达式也会变成与选择器类型相适应的控制流；语言规范承诺结果与完整性语义，不承诺某条源码形式必然更快。
没有基准和目标 JVM 配置时，不应把这些特性包装成性能优化。

迁移时应按表达能力选择它们：文本块用于看清多行内容，`switch` 表达式用于让有限分支产生一个值。
性能敏感路径仍需在目标运行时、真实数据分布和实际编译参数下测量。
源码变短本身不是性能证据。

<!-- /deep -->

[检查点: java/java17-features](https://codewiki.com/zh/java/java17-features/#checkpoint)

## 延伸阅读

- [JEP 409：密封类](https://openjdk.org/jeps/409)
- [JEP 394：`instanceof` 模式匹配](https://openjdk.org/jeps/394)
- [JEP 395：记录类](https://openjdk.org/jeps/395)
- [JEP 378：文本块](https://openjdk.org/jeps/378)
- [JEP 361：`switch` 表达式](https://openjdk.org/jeps/361)
