第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 是"换引擎 + 换方向盘"。
小结

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