Java 17新特性

梳理Java 17源码级别可用的稳定语言特性,用可运行示例说明机制,并划清迁移时最容易混淆的版本边界。

难度 进阶 时长 标准深度约11分钟
版本 Java 25 LTS
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源码兼容性的最小烟雾测试。

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));
    }
}
Lin (PRO)
seats=10
unsupported

紧凑构造器先拒绝空名称,再把重新绑定后的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());
    }
}
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));
    }
}
status=RUNNING
  detail=processed 3
status=DONE
  detail=complete

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

陷阱

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

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

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

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

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

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

深入 迁移边界与编译结果

迁移边界与编译结果

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

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

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

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

延伸阅读

检查点

4个问题 · 2道输出预测题 · 1道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?