第10章 JDK 17 新特性:语法糖与新能力

第10章 JDK 17 新特性:语法糖与新能力

本系列是《Java 高级用法》篇,承接《Java 基础入门》21 章的内容。面向已经学完基础篇的小白读者:你已经掌握了变量、流程控制、类与对象、继承、集合等基础,从本章开始,我们进入"更高级"的用法。

背景介绍 :JDK 17 是 2021 年发布的 LTS(Long Term Support,长期支持)版本,官方承诺长期维护,是企业里用得越来越多的版本。它带来的是一批"语法糖"(Syntactic Sugar,底层能力不变、但语法上让你少写重复代码、降低出错概率的写法)和新能力:switch 表达式、文本块、record、sealed 类、instanceof 模式匹配,以及 GC(垃圾回收器)增强和强封装。本章所有示例都需 JDK 17 才能运行 (正文里标注"JDK 8 老写法"的对比片段除外),请先确认你的环境是 JDK 17(java -version 能看到 17 字样)。

先把"版本地图"讲清楚:JDK 8(2014 年)、JDK 11(2018 年)、JDK 17(2021 年)是目前 Java 世界最重要的三个 LTS 版本,大约每三年出一个 LTS。企业为了稳定,普遍"认准 LTS",所以很多公司现在正处于"JDK 8 → JDK 11 → JDK 17"的升级路上。本章的语法糖全部是 JDK 17 的新语法,JDK 8 和 JDK 11 一个都没有------这也是为什么升级到 JDK 17 会带来"写代码变爽"的直观感受。

为什么叫"语法糖"?打个比方:语法糖就像"自动挡汽车"------发动机还是那个发动机(底层 JVM 能力没变),但你把"踩离合、换挡"交给了变速箱(编译器),自己只管踩油门。它不改变程序本质,却让你少写代码、少犯错。

面试常问:JDK 17 相比 JDK 8 最大的变化是什么?答案分三层------①语法层:本章这些新语法;②运行时层:GC 更稳、强封装更安全;③生态层:企业新项目越来越多直接上 17。下面我们逐个看。

一、switch 表达式:从"语句"到"表达式"

基础篇里你学过的 switch 是"语句"(statement):执行分支,但本身没有值。它有两个痛点:一是每个 case 末尾容易忘写 break 导致"穿透"(fall-through,继续执行下一个 case);二是想根据分支给变量赋值时,只能先声明再赋值,啰嗦还容易漏。

JDK 17 的 switch 表达式(switch expression)用箭头 -> 解决这两个问题:箭头后面要么是"一个值",要么是一段代码块。先看最基础的写法:

java 复制代码
// 需 JDK 17 才能运行
public class SwitchArrowDemo {
    public static void main(String[] args) {
        String fruit = "apple";

        // 经典 switch 语句(JDK 8 写法,放在这里只作对比)
        int price;
        switch (fruit) {
            case "apple":
                price = 5;
                break;            // 忘写 break 就会穿透到 banana
            case "banana":
                price = 3;
                break;
            default:
                price = 0;
        }
        System.out.println("经典写法:" + price);
        // 输出:经典写法:5

        // 箭头写法:不需要 break,箭头右边就是分支的值
        int price2 = switch (fruit) {
            case "apple" -> 5;
            case "banana" -> 3;
            default -> 0;
        };
        System.out.println("箭头写法:" + price2);
        // 输出:箭头写法:5
    }
}

为什么箭头写法不需要 break?因为 -> 的语义是"这个 case 到此为止",编译器根本不允许"穿透",从语法层面消灭了一类 bug。再看多分支合并和 yield

java 复制代码
// 需 JDK 17 才能运行
public class YieldDemo {
    public static void main(String[] args) {
        int day = 2;

        // 箭头右边可以合并多个值,也可以写代码块;
        // 代码块里必须用 yield 把值"交还"给 switch 表达式
        String name = switch (day) {
            case 1 -> "周一";
            case 2, 3, 4, 5 -> {
                System.out.println("工作日,还要上班");
                yield "工作日";   // yield = 把结果交出去,类似 return
            }
            default -> "周末";
        };
        System.out.println(name);
        // 输出:工作日,还要上班
        // 输出:工作日
    }
}

最后注意:case 1 -> "周一"; 里每个分支的表达式类型必须一致(这里是 String),整个 switch 表达式的类型由分支共同决定,这保证了它能安全地赋值给变量。

同样的"按水果定价"需求,switch 表达式能写出好几种风格,工作中按场景挑:

java 复制代码
// 需 JDK 17 才能运行
public class SwitchStyleDemo {
    // 风格一:箭头 + 直接值(最简洁,大多数情况用这个)
    static int priceOf(String fruit) {
        return switch (fruit) {
            case "apple" -> 5;
            case "banana" -> 3;
            case "orange" -> 4;
            default -> 0;              // 兜底:没匹配到就返回 0
        };
    }

    // 风格二:箭头 + 代码块(分支里有多行逻辑时,用 yield 交还结果)
    static String descOf(String fruit) {
        return switch (fruit) {
            case "apple" -> {
                System.out.println("------正在计算苹果价格------");
                yield "苹果 5 元/斤";
            }
            case "banana" -> {
                System.out.println("------正在计算香蕉价格------");
                yield "香蕉 3 元/斤";
            }
            default -> "没有这个水果";
        };
    }

    // 风格三:switch 表达式直接作为实参传给方法
    static void print(String fruit) {
        System.out.println(fruit + " -> " + switch (fruit) {
            case "apple" -> "红的";
            case "banana" -> "黄的";
            default -> "其他";
        });
    }

    public static void main(String[] args) {
        System.out.println("苹果:" + priceOf("apple"));
        // 输出:苹果:5
        System.out.println("不认识的:" + priceOf("durian"));
        // 输出:不认识的:0(走了 default)

        System.out.println(descOf("apple"));
        // 输出:------正在计算苹果价格------
        // 输出:苹果 5 元/斤

        print("apple");
        // 输出:apple -> 红的
    }
}

再提醒一个极易踩的坑:switch 的选择器不能是 null 。不管是语句还是表达式,选择器为 null 都会直接抛 NullPointerException。如果变量可能为 null,必须先判空:

java 复制代码
// 需 JDK 17 才能运行
public class SwitchNullDemo {
    public static void main(String[] args) {
        String fruit = null;

        // 错误示范:switch 遇到 null 直接炸
        try {
            int price = switch (fruit) {
                case "apple" -> 5;
                default -> 0;
            };
            System.out.println(price);
        } catch (NullPointerException e) {
            System.out.println("switch 遇到 null:" + e.getClass().getSimpleName());
            // 输出:switch 遇到 null:NullPointerException
        }

        // 正确姿势:先判空,再进 switch
        String fruit2 = null;
        if (fruit2 != null) {
            System.out.println("价格:" + switch (fruit2) {
                case "apple" -> 5;
                default -> 0;
            });
        } else {
            System.out.println("水果不能为空");
            // 输出:水果不能为空
        }
    }
}

工作中 switch 表达式最常见的三个场景:①枚举转展示文案;②状态码映射业务含义;③状态机里根据当前状态决定下一步动作。框架源码里也大量使用,因为"有值、防穿透、可穷尽检查"让分支逻辑安全得多。

面试常问:switch 表达式和 switch 语句的区别?答三点------①语句没有值、表达式有值,表达式能直接赋值/传参;②表达式用 -> 防穿透,语句靠 break 容易忘写;③选择器是枚举或 sealed 类型时,表达式要求分支穷尽,漏一个编译报错。

二、文本块:三引号里的多行字符串

以前写多行文本(JSON、SQL、HTML)有多痛苦?要把换行写成 \n,还要处理引号转义,又长又难看。文本块(Text Block)用三个双引号 """ 包住内容,直接原样书写多行,编译器自动处理缩进和换行:

java 复制代码
// 需 JDK 17 才能运行
public class TextBlockDemo {
    public static void main(String[] args) {
        String json = """
            {
              "name": "小明",
              "age": 18
            }
            """;
        System.out.println(json);
        // 输出:{
        // 输出:  "name": "小明",
        // 输出:  "age": 18
        // 输出:}
    }
}

这里有两个细节必须讲清楚(这就是"为什么"):其一,文本块从 """ 后面的下一个换行 开始算内容,所以第一行不会多出一个空行;其二,编译器会去掉所有行公共的缩进 (本例每行都缩进了 12 个空格,编译器统一去掉),所以输出不会带着缩进。结束的 """ 写在最左边,它的位置决定了"公共缩进"的基准线。

文本块里仍然可以做转义,最常用的是 \ 表示"续行"(不在字符串里产生换行)和 \" 表示一个引号:

java 复制代码
// 需 JDK 17 才能运行
public class TextBlockEscapeDemo {
    public static void main(String[] args) {
        // 反斜杠续行:两行代码拼成一行内容
        String sql = """
                SELECT id, name \
                FROM user \
                WHERE age > 18
                """;
        System.out.println(sql);
        // 输出:SELECT id, name FROM user WHERE age > 18

        // 想在文本块里写一个引号,用 \" 转义
        String s = """
                她说:"你好"
                """;
        System.out.println(s);
        // 输出:她说:"你好"
    }
}

对比 JDK 8 的写法你就能体会文本块的价值------同样的 SQL 要写成 "SELECT id, name \nFROM user \nWHERE age > 18",肉眼很难检查对不对。

文本块最常见的用途有三个:拼 SQL (尤其带大段条件的报表 SQL)、拼 JSON 报文 (调第三方接口时,报文格式一目了然)、拼 HTML/邮件模板(整段复制网页源码,不用手工转义每一处引号)。注意:文本块里写 JSON 时,键的引号不用转义(那是内容的一部分)。

文本块还能配合占位符 formatted 使用(JDK 17 的 String 自带,类似 String.format),模板和参数分离,这是做邮件/短信模板的绝配:

java 复制代码
// 需 JDK 17 才能运行
public class TextBlockFormatDemo {
    public static void main(String[] args) {
        String html = """
                <h1>%s</h1>
                <p>价格:%.2f 元</p>
                """.formatted("苹果", 5.5);
        System.out.println(html);
        // 输出:<h1>苹果</h1>
        // 输出:<p>价格:5.50 元</p>
    }
}

面试常问:文本块里怎么保留行尾空格?答:用转义符 \s(表示一个"普通空格"),因为文本块默认会去掉每行末尾的空格。这个知识点扩展知识里会演示。

三、record 记录类:纯数据类的"一键生成"

写一个纯数据类(只有字段、构造器、getter、equals、hashCode、toString,没有任何业务逻辑)时,基础篇教你的写法要手敲一大片样板代码。record(记录类)用一行声明就能得到这一切:

java 复制代码
// 需 JDK 17 才能运行
public record Person(String name, int age) {
}

就这一行,Person 自动获得了:全参构造器、name() / age() 访问方法(注意 record 的访问方法不叫 getXxx,直接叫字段名)、以及 equals / hashCode / toString。看效果:

java 复制代码
// 需 JDK 17 才能运行
public class RecordDemo {
    public static void main(String[] args) {
        Person p1 = new Person("小明", 18);
        Person p2 = new Person("小明", 18);
        System.out.println(p1);                       // toString 自动生成
        // 输出:Person[name=小明, age=18]
        System.out.println(p1.equals(p2));            // equals 按字段比较
        // 输出:true
        System.out.println(p1.name() + " 今年 " + p1.age() + " 岁");
        // 输出:小明 今年 18 岁

        // 换个角度:字段不同的两个对象,equals 为 false
        Person p3 = new Person("小红", 18);
        System.out.println(p1.equals(p3));
        // 输出:false
    }
}

record 还支持"紧凑构造器"(compact constructor):不写参数列表,直接在构造体里做校验,编译器会在校验后自动帮你完成字段赋值:

java 复制代码
// 需 JDK 17 才能运行
public record Person2(String name, int age) {
    // 紧凑构造器:省略参数列表,只写校验逻辑
    public Person2 {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("年龄不合法:" + age);
        }
    }
}

验证一下:

java 复制代码
// 需 JDK 17 才能运行
public class RecordValidateDemo {
    public static void main(String[] args) {
        try {
            new Person2("张三", 200);          // 年龄越界
        } catch (IllegalArgumentException e) {
            System.out.println("校验生效:" + e.getMessage());
            // 输出:校验生效:年龄不合法:200
        }
        System.out.println(new Person2("李四", 20));   // 合法值正常创建
        // 输出:Person2[name=李四, age=20]
    }
}

为什么 record 适合纯数据类?因为它把"数据载体"这类重复劳动彻底自动化,而它本身是隐式 final 的,不能继承也不能被继承------正符合"纯数据类不需要被扩展"的直觉。它仍然可以实现接口(比如 Comparable),需要自定义行为时也可以在类体里加方法。看一个"record + 接口 + 自定义方法"的完整例子(成绩单排序):

java 复制代码
// 需 JDK 17 才能运行
record Student(String name, int score) implements Comparable<Student> {
    public String grade() {                // 自定义实例方法:算等级
        if (score >= 90) return "优秀";
        if (score >= 60) return "及格";
        return "不及格";
    }

    @Override
    public int compareTo(Student o) {      // 实现接口的方法:按分数排
        return Integer.compare(this.score, o.score);
    }
}

public class StudentDemo {
    public static void main(String[] args) {
        System.out.println(new Student("小明", 95).grade());
        // 输出:优秀
        System.out.println(new Student("小刚", 58).grade());
        // 输出:不及格

        java.util.List<Student> list = new java.util.ArrayList<>();
        list.add(new Student("小强", 70));
        list.add(new Student("小明", 95));
        list.add(new Student("小刚", 58));
        java.util.Collections.sort(list);        // 直接复用 record 的 compareTo
        System.out.println(list);
        // 输出:[Student[name=小刚, score=58], Student[name=小强, score=70], Student[name=小明, score=95]]
    }
}

工作中 record 的典型场景:①DTO/VO(接口出入参对象,以前每个都要手写 getter/setter 一大片);②把多个返回值打包(比如"查询结果 + 总数");③Map 的 key 想要"多字段组合"时,用一个 record 当 key,equals/hashCode 自动正确。很多框架(比如新版 Spring 的配置绑定)也直接支持把配置映射到 record 上。

面试常问:record 和 Lombok 的 @Data 有什么区别?答:record 是语言层面的,隐式 final、字段不可变、自带紧凑构造器校验;Lombok 是编译期注解处理,靠生成代码,类本身还是普通类。能用 record 的地方优先 record。

四、sealed 密封类:精确控制继承

继承是开放的,但有时你希望"继承只允许发生在指定的几个类上"。以前只有两个极端:不写任何修饰符(谁都能继承)或写 final(谁都不能继承)。sealed(密封)类给出了中间选项:只有 permits 列表里列出的类才能继承我

java 复制代码
// 需 JDK 17 才能运行
// sealed 声明本类可被继承,permits 列出允许的子类(需和本类同包/同模块);
// 有抽象方法就必须显式声明 abstract(sealed 不会自动帮你 abstract)
public abstract sealed class Shape permits Circle, Rectangle {
    public abstract double area();
}

final class Circle extends Shape {           // 子类必须声明为 final
    private final double r;
    Circle(double r) { this.r = r; }
    @Override public double area() { return Math.PI * r * r; }
}

final class Rectangle extends Shape {        // 或 sealed / non-sealed
    private final double w, h;
    Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override public double area() { return w * h; }
}

要点:permits 列表里的每个类都必须直接继承 sealed 类 ,且每个子类还必须再声明自己的继承状态------final(到此为止)、sealed(继续往下限)或 non-sealed(解除限制),少一个都编译报错。验证一下:

java 复制代码
// 需 JDK 17 才能运行
public class SealedDemo {
    public static void main(String[] args) {
        Shape s1 = new Circle(2.0);
        Shape s2 = new Rectangle(3.0, 4.0);
        System.out.println("圆面积:" + s1.area());
        // 输出:圆面积:12.566370614359172
        System.out.println("矩形面积:" + s2.area());
        // 输出:矩形面积:12.0
    }
}

final 的区别一句话总结:final 是"谁都不能继承我",sealed 是"只有我点名的几个能继承我"。sealed 的价值在于:框架作者可以把"允许扩展点"明确写出来,防止乱继承;同时编译器知道子类全集,可以做穷尽性检查(见扩展知识)。

再看 non-sealed 的实际效果------它表示"封到这里为止,之后放开":

java 复制代码
// 需 JDK 17 才能运行
abstract sealed class Shape2 permits Circle2, FreeShape {
    abstract double area();
}

final class Circle2 extends Shape2 {
    private final double r;
    Circle2(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}

// non-sealed:继承链条到这里"解封",之后谁都能继续继承
non-sealed class FreeShape extends Shape2 {
    @Override double area() { return 0; }
}

// FreeShape 是 non-sealed 的,所以这个新类可以直接继承它
class Triangle extends FreeShape {
    private final double base, h;
    Triangle(double base, double h) { this.base = base; this.h = h; }
    @Override double area() { return base * h / 2; }
}

public class NonSealedDemo {
    public static void main(String[] args) {
        Shape2 t = new Triangle(3.0, 4.0);
        System.out.println("三角形面积:" + t.area());
        // 输出:三角形面积:6.0
    }
}

sealed 不仅能用类,也能用在接口上(permits 列的是实现类),这是设计"业务类型体系"时特别顺手的手段------比如订单状态、消息类型:

java 复制代码
// 需 JDK 17 才能运行
sealed interface Animal permits Dog, Cat {
    String sound();
}

final class Dog implements Animal {
    @Override public String sound() { return "汪汪"; }
}

final class Cat implements Animal {
    @Override public String sound() { return "喵喵"; }
}

public class SealedInterfaceDemo {
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println(a.sound());
        // 输出:汪汪
        System.out.println(new Cat().sound());
        // 输出:喵喵
    }
}

工作中 sealed 的典型场景:①领域模型里的"类型闭合"设计------比如支付方式只有微信/支付宝/银行卡三种,写死 permits,别人加不了奇怪的实现;②配合 switch 表达式做穷尽分支(扩展知识 1);③框架的 SPI(扩展点)设计。一句话:sealed 是给"继承"装上红绿灯,防止设计失控

面试常问:sealed 类的子类必须满足什么?答:必须在 permits 列表里(未命名模块下还得同包)、必须直接继承 sealed 类、必须声明自己是 final / sealed / non-sealed 三者之一。

五、instanceof 模式匹配:类型判断后直接用

基础篇里你写的类型判断一定是两步:先 instanceof 判断,再强转,两步之间还有隐患。JDK 17 的 instanceof 模式匹配(pattern matching for instanceof)把两步合并成一步:判断成功的同时,把变量绑定为强转后的类型

java 复制代码
// 需 JDK 17 才能运行
public class PatternDemo {
    public static void main(String[] args) {
        Object obj = "hello, java";

        // 模式匹配:if 判断 obj 是 String 类型,成功后 s 直接可用
        if (obj instanceof String s) {
            System.out.println("字符串长度:" + s.length());
        }
        // 输出:字符串长度:11

        // 带 && 的条件里也能用,但 || 不行(因为右边可能不执行判断)
        if (obj instanceof String s2 && s2.length() > 5) {
            System.out.println("长字符串:" + s2.toUpperCase());
        }
        // 输出:长字符串:HELLO, JAVA

        // 判断失败走 else,和普通 if 一样
        Object num = 100;
        if (num instanceof String s3) {
            System.out.println("是字符串:" + s3);
        } else {
            System.out.println("不是字符串,是 " + num.getClass().getSimpleName());
            // 输出:不是字符串,是 Integer
        }
    }
}

对比 JDK 8 的老写法,你就能理解"为什么":

java 复制代码
// JDK 8 的老写法:判断和强转是两步(示意片段,三个版本都能运行)
public class OldInstanceofDemo {
    public static void main(String[] args) {
        Object obj = "hello";
        if (obj instanceof String) {
            String s = (String) obj;      // 还要再强转一次,多写一行还容易错
            System.out.println(s.length());
        }
        // 输出:5
    }
}

模式匹配的好处:少写强转、模式变量作用域更精准(只在判断成功的分支里存在)、还避免了"判断之后、强转之前对象被改动"的竞态问题。模式匹配最经典的实战是重写 equals------以前要"判断类型 + 强转 + 逐个字段比"三步,现在一步到位,代码还更安全(类型不对时 instanceof 直接失败,根本不用强转):

java 复制代码
// 需 JDK 17 才能运行
public class PatternEqualsDemo {
    static class Point {
        final int x, y;
        Point(int x, int y) { this.x = x; this.y = y; }

        @Override
        public boolean equals(Object o) {
            // 老写法:if (o instanceof Point) { Point p = (Point) o; ... }
            // 新写法:instanceof 模式匹配一步到位
            return o instanceof Point p && p.x == x && p.y == y;
        }

        @Override
        public int hashCode() { return x * 31 + y; }

        @Override
        public String toString() { return "(" + x + "," + y + ")"; }
    }

    public static void main(String[] args) {
        System.out.println(new Point(1, 2).equals(new Point(1, 2)));
        // 输出:true
        System.out.println(new Point(1, 2).equals(new Point(2, 2)));
        // 输出:false
        System.out.println(new Point(1, 2).equals("我不是点"));
        // 输出:false(instanceof 直接失败,连强转都不用)
        System.out.println(new Point(1, 2).equals(null));
        // 输出:false(null 永远不会被 instanceof 匹配上)
    }
}

工作中 instanceof 模式匹配的典型场景:①统一处理多种类型消息(文本、图片、语音,分别取出各自字段);②重写 equals/hashCode(上面就是标准写法);③解析异构配置值(Object 类型的值,判断是 String 还是 Number 再取)。

面试常问:模式变量在 &&|| 中有什么区别?答:&& 右侧的绑定,变量作用域会延伸到整个 if 块,因为左侧为 true 时右侧必然执行;|| 右侧的绑定不行------左侧为 true 时右侧根本不执行,变量可能没被绑定,编译器直接报错。

六、GC 增强与强封装:两处"看不见"的变化

除了语法糖,JDK 17 还有两处运行时层面的变化,写代码时感知不到,但要知道它们存在。

GC 增强(一句话带过):ZGC(Z Garbage Collector,低延迟垃圾回收器)从 JDK 11 的实验特性起步,到 JDK 17 已经是非常成熟的生产级选项,能让超大堆(几百 GB)的暂停时间维持在毫秒级;G1(Garbage First,JDK 17 默认回收器)也持续优化、减少停顿。知道"JDK 17 的 GC 更稳、停顿更少"就够了,调参是运维层面的事。下面两个启动参数就是"选哪个回收器":

text 复制代码
# 需 JDK 17 才能运行(启动参数,写在 java 命令里)
java -XX:+UseZGC -Xms2g -Xmx2g MyApp     # 用 ZGC:追求超低延迟
java -XX:+UseG1GC -Xms2g -Xmx2g MyApp    # 用 G1:JDK 17 默认,稳

强封装(strong encapsulation) :JDK 17 起,JDK 内部的类和 API 默认被强封装,不允许外部通过反射访问 内部成员,尝试访问会抛 InaccessibleObjectException。最典型的例子是 java.base 模块里 String 的私有字段 value------它在 JDK 17 默认不开放给反射:

java 复制代码
// 需 JDK 17 才能运行
public class EncapsulationDemo {
    public static void main(String[] args) throws Exception {
        try {
            // 尝试反射访问 java.base 模块里 String 的内部字段(强封装下会失败)
            Class<?> clazz = String.class;
            clazz.getDeclaredField("value").setAccessible(true);
            System.out.println("访问成功");
        } catch (Exception e) {
            System.out.println("访问被拒:" + e.getClass().getSimpleName());
            // 输出:访问被拒:InaccessibleObjectException
        }
    }
}

小知识:网上很多老教程拿 sun.misc.Unsafe 当"强封装受害者",其实 sun.misc 所在的 jdk.unsupported 模块因历史原因仍然开放,访问它反而成功;真正默认被拒的是 java.base 这类未开放模块。判断标准只有一个:模块是否 opens 了这个包(扩展知识 7 会讲怎么临时 opens)。

这一条和第 11 章"反射与注解"直接相关:JDK 17 里 setAccessible(true) 只能作用于你自己的类,对 JDK 内部模块是失效的。这是安全性的提升,也提醒我们:不要依赖 JDK 内部 API 写代码。对比一下,反射自己的类完全不受影响:

java 复制代码
// 需 JDK 17 才能运行
public class OwnClassReflectDemo {
    public static void main(String[] args) throws Exception {
        java.lang.reflect.Field f = DemoUser.class.getDeclaredField("name");
        f.setAccessible(true);                 // 自己的类:可以正常打开
        DemoUser u = new DemoUser();
        System.out.println("自己的类反射正常:" + f.get(u));
        // 输出:自己的类反射正常:小明
    }
}

class DemoUser {
    private String name = "小明";
}

升级到 JDK 17 后,如果某些老框架启动时突然抛 InaccessibleObjectException,八成就是它偷偷反射了 JDK 内部类------正解是升级框架版本,而不是硬碰硬加 --add-opens

扩展知识

1. switch 表达式的穷尽性(exhaustiveness)

当 switch 表达式的选择器是枚举或 sealed 类型时,编译器要求分支必须"穷尽"所有可能值,否则编译报错。比如对 sealed 的 Shape(Circle、Rectangle 两个子类)做 switch 表达式,两个分支必须都写上------这保证了 switch 表达式"一定有值",不会出现"一个都没匹配上"的运行时错误。先看枚举:

java 复制代码
// 需 JDK 17 才能运行
public class EnumExhaustiveDemo {
    enum Color { RED, GREEN, BLUE }

    public static void main(String[] args) {
        Color c = Color.GREEN;
        // 对枚举做 switch 表达式:三个值都要写全,否则编译报错
        String name = switch (c) {
            case RED -> "红色";
            case GREEN -> "绿色";
            case BLUE -> "蓝色";
        };
        System.out.println(name);
        // 输出:绿色

        // 这里可以不写 default------因为枚举值已经穷尽;
        // 但以后如果给 Color 新增一个值,这里立刻编译报错,逼你补上分支,
        // 这就是"穷尽性检查"的价值:漏分支的 bug 在编译期就被抓住
    }
}

再看 sealed 类:编译器知道 Shape 的直接子类只有 Circle、Rectangle(正文第四节定义的),所以"模式匹配 switch"要求分支穷尽,漏一个编译报错------和枚举的穷尽检查是同一套原理。这里给出 JDK 17 能直接运行的"判断 + 取字段"写法(用 instanceof 模式匹配,JDK 17 正式支持):

java 复制代码
// 需 JDK 17 才能运行(复用正文第四节定义的 Shape、Circle、Rectangle)
public class SealedSwitchDemo {
    public static void main(String[] args) {
        Shape s = new Circle(2.0);
        String desc;
        if (s instanceof Circle c) {
            desc = "圆形,面积 " + c.area();
        } else if (s instanceof Rectangle r) {
            desc = "矩形,面积 " + r.area();
        } else {
            desc = "未知形状";
        }
        System.out.println(desc);
        // 输出:圆形,面积 12.566370614359172
    }
}

补充说明:把 sealed 和 switch 分支直接合体的"类型模式 switch"(switch (s) { case Circle c -> ...; }),在 JDK 17 还是预览特性 (需 --enable-preview),正式支持要到更晚的版本------本章不引入预览特性,正文统一用 instanceof 模式匹配;穷尽检查的原理一致:编译器知道 sealed 子类全集,分支不写全就编译报错

注意:普通字符串/整数的 switch 表达式,因为类型不封闭(理论上取值无限),所以必须有 default 才能编译通过;只有枚举和 sealed 类型能靠"穷尽"免掉 default。

2. record 的细节:静态成员、泛型与自定义方法

record 的类体里只能写静态成员、方法、构造器(紧凑构造器或显式构造器),不能声明实例字段 (字段就是那串组件列表,不能多加)。record 也可以带泛型,紧凑构造器里同样能用类型变量。下面这个 Pair 把 record 的"自定义能力"一次看全:

java 复制代码
// 需 JDK 17 才能运行
record Pair<K, V>(K key, V value) {
    public Pair {                        // 紧凑构造器:类型变量 K、V 照常可用
        if (key == null) {
            throw new IllegalArgumentException("key 不能为 null");
        }
    }

    public static <A, B> Pair<A, B> of(A key, B value) {  // 静态工厂方法
        return new Pair<>(key, value);
    }

    public Pair<V, K> swap() {           // 自定义实例方法:交换键值
        return new Pair<>(value, key);
    }

    @Override
    public String toString() {           // 可以覆盖自动生成的 toString
        return "(" + key + " -> " + value + ")";
    }
}

public class PairDemo {
    public static void main(String[] args) {
        Pair<String, Integer> p1 = new Pair<>("年龄", 18);
        System.out.println(p1);
        // 输出:(年龄 -> 18)

        Pair<String, Integer> p2 = Pair.of("身高", 175);   // 走静态工厂
        System.out.println(p2.swap());
        // 输出:(175 -> 身高)

        try {
            new Pair<String, Integer>(null, 1);            // 触发紧凑构造器校验
        } catch (IllegalArgumentException e) {
            System.out.println("校验生效:" + e.getMessage());
            // 输出:校验生效:key 不能为 null
        }
    }
}

注意 record 的访问方法不叫 getKey() 而叫 key();想在类体里加"额外构造器"也是允许的,但它必须委托给主构造器(this(...)),这是保持字段完整性的保证。

3. 嵌套 record 与局部 record

record 除了作为独立类,还能嵌套 在类里(隐式 static,不能用外部类的实例字段)和定义在方法内部(局部 record,JDK 17 支持),非常适合"临时打包几个值"的场景:

java 复制代码
// 需 JDK 17 才能运行
public class NestedRecordDemo {
    record Point(int x, int y) {        // 嵌套 record:隐式 static
        int distanceSquared() { return x * x + y * y; }
    }

    public static void main(String[] args) {
        Point p = new Point(3, 4);
        System.out.println(p);
        // 输出:Point[x=3, y=4]
        System.out.println("距离平方:" + p.distanceSquared());
        // 输出:距离平方:25

        // 局部 record:定义在方法内部,只在本方法可见
        record Range(int min, int max) {
            Range {
                if (min > max) throw new IllegalArgumentException("min 不能大于 max");
            }
        }
        Range r = new Range(1, 10);
        System.out.println(r);
        // 输出:Range[min=1, max=10]
    }
}

实战场景:一个方法想返回"两个数 + 一个标记",以前要么建一个外部类,要么用 Object[](类型不安全)。现在直接在方法里定义一个局部 record,一行搞定,还带着自动生成的 equals/hashCode/toString。

4. sealed 接口与 non-sealed:从"封闭"到"解封"

正文第四节展示了 sealed 接口。这里把"三种子类状态"的区别再捋一遍:final = 封闭到此为止;sealed = 我还要继续指定下一层;non-sealed = 解除限制,之后的继承回到普通开放状态。三者必须三选一,这是编译器强制要求:

java 复制代码
// 需 JDK 17 才能运行
sealed interface Msg permits TextMsg, ImageMsg, OtherMsg {
    String type();
}

record TextMsg(String content) implements Msg {
    @Override public String type() { return "text"; }
}

record ImageMsg(String url) implements Msg {
    @Override public String type() { return "image"; }
}

// non-sealed:其他实现者不再受限制
non-sealed interface OtherMsg extends Msg {
    @Override default String type() { return "other"; }
}

public class SealedMsgDemo {
    public static void main(String[] args) {
        Msg m = new TextMsg("你好");
        // JDK 17 的分发写法:instanceof 模式匹配(类型模式 switch 是预览特性)
        String desc;
        if (m instanceof TextMsg t) {
            desc = "文本:" + t.content();
        } else if (m instanceof ImageMsg i) {
            desc = "图片:" + i.url();
        } else {
            desc = "其他类型";
        }
        System.out.println(desc);
        // 输出:文本:你好
    }
}

这个例子把本章两个主角(record + sealed)组合起来了:record 承担"每种消息的数据结构",sealed 保证"消息类型就这三种(或明确放开)",instanceof 模式匹配负责"分发"------这是 JDK 17 时代"类型安全的业务分发"的标准姿势,比"if-else 判断 type 字符串"安全得多(等类型模式 switch 正式落地后,还能用 switch 分支做穷尽检查)。

5. instanceof 模式变量的作用域细节

模式变量(pattern variable)的作用域是"流作用域"(flow scoping):编译器根据代码路径判断变量在哪些位置一定已被绑定。几个规则值得单独演示:

java 复制代码
// 需 JDK 17 才能运行
public class PatternScopeDemo {
    public static void main(String[] args) {
        Object obj = "hello";

        if (obj instanceof String s) {
            System.out.println("分支内可用:" + s.toUpperCase());
            // 输出:分支内可用:HELLO
        }
        // System.out.println(s);   // ❌ 编译错误:s 的作用域到不了这里

        // && 右侧绑定的变量:作用域延伸到整个 if 块
        if (obj instanceof String t && t.length() > 2) {
            System.out.println("&& 右侧绑定可用:" + t);
            // 输出:&& 右侧绑定可用:hello
        }

        // || 右侧的错误用法(编译器直接报错,不能运行):
        // if (obj instanceof String u || u.length() > 2) { }
        // 原因:|| 左边为 true 时右边根本不执行,u 可能没被绑定

        // 取反判断后,变量反而在 if 之后可用(此时一定不是 String):
        if (!(obj instanceof String v)) {
            System.out.println("不是字符串");
        } else {
            System.out.println("是字符串:" + v);
            // 输出:是字符串:hello
        }
    }
}

理解流作用域对读懂框架代码很有帮助:很多框架源码用 if (x instanceof SomeType t && t.isValid()) 少写一层嵌套。

6. 文本块的缩进控制:公共缩进、\s 与 formatted

文本块去缩进的规则是:取所有内容行(含结尾的 """ 行)中公共的最短缩进,全部去掉 。所以想让输出有缩进,就把内容行写得比结尾 """ 更靠右;想让输出没有缩进,结尾 """ 顶格写。行尾空格默认会被删除,想保留就用 \s

java 复制代码
// 需 JDK 17 才能运行
public class TextBlockSpaceDemo {
    public static void main(String[] args) {
        // \s 表示"保留这个空格":常用于行尾、或需要精确对齐的位置
        String withTrailing = """
                保留末尾空格\s
                """;
        System.out.println("[" + withTrailing + "]");
        // 输出:[保留末尾空格 ]

        // 对比:不加 \s 时行尾空格被删掉
        String noTrailing = """
                没有末尾空格 
                """;
        System.out.println("[" + noTrailing + "]");
        // 输出:[没有末尾空格 ]
    }
}

补充一个小坑:结尾 """ 的缩进也算在"公共缩进"计算里。所以如果你把结尾 """ 写得很靠右(比如和内容行对齐),公共缩进就会变大,输出会带上多余空格。规范写法:结尾 """ 单独一行、尽量靠左。

7. 强封装与 --add-opens:临时放行与正确姿势

强封装是 JDK 17 的安全底线,但不排除"排查问题时要临时看看 JDK 内部"。--add-opens 启动参数可以针对某个模块的某个包临时打开反射访问:

text 复制代码
# 需 JDK 17 才能运行(仅排查用,生产环境不要这么干)
# 打开 java.base 模块 java.lang 包的反射访问后,正文的 EncapsulationDemo 会打印"访问成功"
java --add-opens java.base/java.lang=ALL-UNNAMED EncapsulationDemo

这个参数的含义拆开读:java.base/java.lang 表示"模块/包",ALL-UNNAMED 表示"对未命名模块(也就是你写的普通代码)开放"。它能解决"老框架临时跑起来"的问题,但不是长久之计 ------正确的做法永远是升级依赖,而不是到处加 --add-opens。另外注意:--add-opens 在 JDK 11 也存在(当时配合"默认放行"的宽松期),JDK 17 是"默认拒绝 + 显式开放"的模式。

易错点

  • ❌ 在 JDK 8 或 JDK 11 的项目里写 switch 表达式 / 文本块 / record,编译直接报错。✅ 这些语法需 JDK 17 才能运行,要把 IDEA 的 Project SDK 和 Maven/Gradle 的 source/target 都设成 17。
  • ❌ switch 表达式的代码块分支里忘了写 yield,编译报错。✅ 箭头右边是代码块时,必须用 yield 值; 把结果交出去;箭头右边直接写表达式时才不需要。
  • ❌ switch 的选择器传了 null(比如从 Map 里取出来的值没判空),运行抛 NullPointerException。✅ 进 switch 前先判空;这也是很多人从"if-else 搬家到 switch"时踩的第一坑。
  • ❌ switch 表达式各分支返回不同类型(case 1 -> 10; case 2 -> "十";),编译报错。✅ 所有分支的类型必须一致,整个表达式的类型由共同类型决定。
  • ❌ 在文本块里手写 \n 表示换行,结果出现字面的反斜杠 n。✅ 文本块里换行直接回车;想"不换行"用 \(续行符);想保留行尾空格用 \s
  • ❌ 试图让 record 继承别的类(record A extends B),或往 record 类体里加实例字段,编译报错。✅ record 是隐式 final 的、字段就是组件列表;需要扩展行为时实现接口、加方法、加静态成员。
  • ❌ sealed 类的 permits 列表漏了某个子类,或子类没写 final / sealed / non-sealed,编译报错。✅ permits 列出的每个类都必须直接继承 sealed 类,且必须显式声明继承状态(未命名模块下还要求与 sealed 类同包)。
  • ❌ 旧习惯:if (obj instanceof String) 之后再写一行强转。✅ JDK 17 直接 if (obj instanceof String s),模式变量 s 直接可用;注意模式变量不能用在 || 右侧。
  • ❌ 以为 sealed 和 final 等价,给所有类都写 sealed。✅ 语义不同:final 禁止一切继承,sealed 允许"指定类"继承;没有明确继承边界需求时用 final 即可。
  • ❌ 以为 setAccessible(true) 能反射打开 JDK 内部类,结果抛 InaccessibleObjectException。✅ JDK 17 强封装下反射只能作用于自己的类;临时排查可用 --add-opens,生产要升级依赖。

JDK 8 / 11 / 17 版本调用方式

先记住大前提:JDK 8 和 JDK 11 在语法层面完全一致------本章的所有新语法,这两个版本一个都没有,所以"老写法"一列对 JDK 8、JDK 11 通用;三者的差异主要在运行时(GC、安全策略)。对照表如下:

场景 JDK 8 / JDK 11 的写法 JDK 17 的写法
多分支判断 经典 switch + break,不能直接赋值 switch 表达式:int x = switch (v) { case 1 -> 10; default -> 0; };(需 JDK 17 才能运行)
多行字符串 拼接:"第一行\n" + "第二行\n",肉眼难检查 文本块 """ ... """,原样书写(需 JDK 17 才能运行)
多行字符串带占位符 String.format("...\n...", 参数) 文本块 + .formatted(...)(需 JDK 17 才能运行)
纯数据类 手写字段、构造器、getter、equals、hashCode、toString public record Person(String name, int age) {}(需 JDK 17 才能运行)
限制继承 final(完全禁止)或不限制(完全开放) sealed + permits(精确控制,需 JDK 17 才能运行)
类型判断 instanceof 之后再手动强转 instanceof 模式匹配,绑定模式变量直接用(需 JDK 17 才能运行)
垃圾回收 JDK 8 默认 Parallel GC;JDK 11 默认 G1、ZGC 还是实验特性 ZGC 成熟可用、G1 持续改进(需 JDK 17 才能运行)
反射访问 JDK 内部类 JDK 8 畅通无阻;JDK 11 默认放行但打警告 默认强封装,访问抛 InaccessibleObjectException(需 JDK 17 才能运行)

再看三个版本之间的"升级路线",这对实际工作很有用:

  • JDK 8 → JDK 11 :默认回收器从 Parallel 换成 G1(停顿更可控);Class.newInstance() 被标记废弃;JDK 内部类的反射访问开始收紧(默认放行但打印警告)。
  • JDK 11 → JDK 17:本章所有语法糖一次到位;强封装从"放行+警告"变成"默认拒绝";ZGC 从实验走向成熟。这一跳"感知变化"最大,但也是企业最愿意做的一次升级。
  • 升级时的三件套检查 :①IDEA 的 Project Structure → Project SDK 选 17,Language level 选 17;②Maven 项目在 pom.xml 里把 maven.compiler.source / maven.compiler.target 改成 17(Gradle 在 build.gradle 里设 sourceCompatibility = 17);③启动命令确认 java -version 是 17。三处漏一处,新语法就会编译报错。

一句话总结版本差异:JDK 8 是"经典语法 + 老 GC",JDK 11 是"换引擎",JDK 17 是"换引擎 + 换方向盘"

小结

  1. switch 表达式用 -> 代替 break,天然防穿透,整个表达式可以直接赋值给变量;代码块分支用 yield 值; 返回结果。
  2. switch 表达式各分支类型必须一致;选择器不能为 null,可能为 null 时要先判空;普通类型必须有 default,枚举/sealed 类型靠穷尽分支可省略 default。
  3. 文本块用 """ 写多行字符串,编译器自动去掉公共缩进;\ 是续行符,\" 是转义引号,\s 是保留空格,.formatted(...) 做模板占位。
  4. record 一行声明就自动生成构造器、访问方法、equals、hashCode、toString,适合纯数据类。
  5. record 是隐式 final 的,不能继承也不能被继承,但可以实现接口、可以带泛型、可以用紧凑构造器做校验、可以加方法和静态成员。
  6. 嵌套 record(隐式 static)和局部 record(方法内定义)适合临时打包数据,免去建外部类的样板代码。
  7. sealed 类通过 permits 精确列出允许继承的子类,子类必须声明为 final / sealed / non-sealed,与"一刀切"的 final 不同。
  8. sealed 类/接口配合 switch 表达式能做穷尽性检查:漏一个分支编译报错,业务分发更安全。
  9. instanceof 模式匹配把"判断 + 强转"合并成一步,模式变量只在成功分支里可用;&& 可用、|| 不可用;重写 equals 时最常用。
  10. JDK 17 的 ZGC 成熟、G1 改进让 GC 更稳定;强封装限制了反射访问 JDK 内部类(抛 InaccessibleObjectException),是安全性的提升,--add-opens 只能临时应急。

下一篇:第11章 反射与注解

相关推荐
吴声子夜歌2 小时前
ApacheCommons——commons-text(模板替换与文本算法)
java·开发语言·算法·apache
Haooog2 小时前
Agent 开发中的 Memory:State、短期记忆、长期记忆与 Memory Retrieval
java·agent·memory
砚底藏山河2 小时前
【量化纯GET实战 #23】多股票相关性:用收益率看板块联动
java·数据库·python·金融·数据分析
抠脚小弟2 小时前
Spring Cache 使用详解:从入门到实战
java·spring
ZGG0032 小时前
MySQL 索引详解:B+ 树、聚簇索引与最左前缀
java·数据库·mysql
萧瑟余晖3 小时前
Java深入解析篇五十四之对象模型详解
java·开发语言
张文是假的啊3 小时前
Java | record | Controller逻辑
java·开发语言
勿忘,瞬间3 小时前
Mybatis高阶
java·数据库·mybatis
嘻哈baby4 小时前
Go 函数中的参数为什么不支持默认值?
java·开发语言·jvm