Java 从入门到精通(十七):Java 8~21 新特性全景——Lambda、Stream、Record 与虚拟线程

本文是「Java 从入门到精通」系列的第 17 篇,假定你已经掌握集合框架、泛型、异常与并发基础(对应本系列第(七)(十二)(十三)篇)。全文代码以 JDK 21 为准,涉及预览特性的部分会用 --enable-preview 标注。阅读建议:Lambda 与 Stream 两章请务必动手跑一遍代码,尤其是 peek 不执行、流一次性消费、toMap 键冲突这三个坑,只有亲眼见过异常栈才会真正记住。

一、版本演进全景:从 Java 8 到 Java 21

2014 年发布的 Java 8 是 Java 历史上最重要的分水岭:它把函数式编程正式引入 Java 语法,Lambda、Stream、默认方法、新的日期时间 API 四件套同时落地。此后 Oracle 改为六个月一个版本的发布节奏,每两年挑一个版本作为 LTS(长期支持版)。到今天,Java 8 已经全面停止免费商业支持,而 Java 21 成为新一代的主流基线。

1.1 关键特性时间线

版本 发布时间 LTS 关键特性(各取 2-3 个)
Java 8 2014-03 是 Lambda 表达式、Stream API、java.time 日期时间 API、接口默认方法
Java 9 2017-09 否 模块系统 JPMS、集合工厂方法 List.of、JShell、Stream 增强
Java 10 2018-03 否 局部变量类型推断 var、G1 并行 Full GC、应用类数据共享
Java 11 2018-09 是 HttpClient 正式版、单文件源码运行、String 增强(isBlank/lines/repeat)、ZGC 实验版
Java 12 2019-03 否 Switch 表达式(预览)、Shenandoah GC、CompactNumberFormat
Java 13 2019-09 否 文本块(预览)、Switch 表达式二次预览、Socket API 重构
Java 14 2020-03 否 Switch 表达式转正、instanceof 模式匹配(预览)、Record(预览)、Helpful NPE
Java 15 2020-09 否 文本块转正、密封类(预览)、EdDSA 签名、Shenandoah 转正
Java 16 2021-03 否 Record 转正、instanceof 模式匹配转正、Stream.toList()、Unix 域套接字
Java 17 2021-09 是 密封类转正、增强伪随机数发生器、macOS/AArch64 支持、移除 Applet
Java 18 2022-03 否 UTF-8 默认字符集、简单 Web 服务器、模式匹配二次预览
Java 19 2022-09 否 虚拟线程(预览)、结构化并发(预览)、Linux/RISC-V
Java 20 2023-03 否 Scoped Values(预览)、Record 模式(二次预览)、虚拟线程二次预览
Java 21 2023-09 是 虚拟线程转正、switch 模式匹配转正、Record 模式转正、Sequenced Collections、分代 ZGC、字符串模板(预览)

要点:LTS 并不是”功能更多”的版本,而是”支持周期更长”的版本。Oracle 对 Java 8/11/17/21 提供八年以上的商业支持,非 LTS 版本在下一个版本发布后即停止更新。生产环境选 LTS,但不要抗拒在开发和 CI 中使用最新非 LTS 版本做兼容性预演。

1.2 升级收益与迁移成本的权衡

升级的收益往往是隐性的、长期的:更少的样板代码(Record、var、文本块)、更强的表达力(模式匹配、密封类)、更高的吞吐(虚拟线程、分代 ZGC)、更低的内存占用(紧凑字符串、G1 改进)。而成本是显性的、短期的:依赖库兼容性、sun.misc.Unsafe 等内部 API 的强封装(JDK 9 起)、模块化的拆分、字节码增强工具(如部分老版本 CGLIB、ASM)的失效。

实际项目中比较稳妥的做法是分阶段升级:先把编译目标停留在低版本、运行时升级(-release 8 编译 + JDK 17 运行),验证稳定后再放开语言级别;遇到类加载或反射报错时,优先排查 IllegalAccessError 与 --add-opens 需要开放的包。

1.3 预览特性开关

Java 对尚未定稿的语法提供预览(Preview)机制:功能已实现但可能变更,编译与运行都需要显式开启。

1
2
3
4
# 编译:开启预览特性,并锁定语言级别
javac --release 21 --enable-preview Main.java
# 运行:同样必须加 --enable-preview,否则报预览类文件版本错误
java --enable-preview Main

在 Maven 中可以通过 maven-compiler-plugin 统一配置:

1
2
3
4
5
6
7
8
<!-- pom.xml 片段:开启 JDK 21 预览特性 -->
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>21</release>
<compilerArgs>--enable-preview</compilerArgs>
</configuration>
</plugin>

预览特性的字节码带有 minor_version = 65535 标记,JVM 启动时会校验是否带了 --enable-preview。这意味着预览特性编译出的 class 不能分发给生产环境,只能在本地实验。字符串模板(String Templates)在 JDK 21 就是预览状态,JDK 23 中还经历了 API 调整,千万别写进要交付的代码。

二、Lambda 表达式与方法引用

2.1 函数式接口与基本语法

函数式接口(Functional Interface)指的是只有一个抽象方法的接口。Lambda 表达式本质上就是这个函数式接口的实现实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.*;

public class LambdaBasic {
public static void main(String[] args) {
// 传统写法:匿名内部类,5 行样板代码只为表达"比较两个字符串长度"
Comparator<String> oldWay = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return Integer.compare(a.length(), b.length());
}
};

// Lambda:只保留参数列表与方法体,类型由编译器推断
Comparator<String> newWay = (a, b) -> Integer.compare(a.length(), b.length());

List<String> words = new ArrayList<>(List.of("java", "lambda", "go", "rust"));
words.sort(newWay); // go, java, rust, lambda
System.out.println(words);
words.forEach(w -> System.out.println(w.toUpperCase())); // 单参数可省略括号
}
}

Lambda 的语法是 (参数列表) -> { 方法体 }。参数类型通常可以省略(编译器根据目标类型推断);单参数可省括号;方法体只有一句表达式时可省 {} 与 return。

2.2 effectively final 与变量捕获

Lambda 可以捕获外部局部变量,但被捕获的变量必须是 final 或 effectively final(即声明后从未被重新赋值)。这是 Java 为了保证线程安全与实现简单性做出的限制:局部变量存放在栈帧中,而 Lambda 可能在另一个线程或稍后的时刻执行,栈帧早已销毁,因此 JVM 只能复制一份值进入 Lambda 实例。如果允许修改就会出现”副本与本体不一致”的经典 bug。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class CaptureDemo {
public static void main(String[] args) {
int factor = 10; // effectively final:从未被重新赋值
// factor = 20; // 取消注释会编译报错:变量必须是 final 或 effectively final
Runnable r = () -> System.out.println(factor * 3);
new Thread(r).start();

// 实例字段与静态字段没有这个限制(它们存在堆里,本身就是共享的)
Counter c = new Counter();
Runnable inc = () -> c.value++;
inc.run();
System.out.println(c.value); // 1
}

static class Counter { int value; }
}

2.3 方法引用的四种形式

方法引用(Method Reference)是 Lambda 的”语法糖中的语法糖”:当 Lambda 体恰好只是调用一个已存在的方法时,可以用 :: 直接引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.*;
import java.util.function.*;

public class MethodRefDemo {
public static void main(String[] args) {
// 1. 静态方法引用:(args) -> ClassName.staticMethod(args)
Function<String, Integer> f1 = Integer::parseInt;

// 2. 特定对象的实例方法引用:(args) -> instance.instanceMethod(args)
List<String> box = new ArrayList<>();
Consumer<String> f2 = box::add;

// 3. 特定类型的任意对象实例方法引用:(a, rest) -> a.instanceMethod(rest)
BiFunction<String, String, Boolean> f3 = String::startsWith; // (s, prefix) -> s.startsWith(prefix)

// 4. 构造器引用:(args) -> new ClassName(args)
Supplier<List<String>> f4 = ArrayList::new;
Function<String, StringBuilder> f5 = StringBuilder::new;

System.out.println(f1.apply("2048") + 1); // 2049
f2.accept("hello");
System.out.println(f3.apply("hello", "he")); // true
System.out.println(f4.get().size()); // 0
}
}
形式 语法 等价 Lambda 示例
静态方法引用 类名::静态方法 (a, b) -> 类名.静态方法(a, b) Integer::parseInt
特定对象实例方法 对象::实例方法 (a) -> 对象.实例方法(a) System.out::println
特定类型任意对象实例方法 类名::实例方法 (a, b) -> a.实例方法(b) String::compareToIgnoreCase
构造器引用 类名::new (a) -> new 类名(a) ArrayList::new、int[]::new

2.4 Lambda 与匿名内部类的本质差异

这是面试高频题,差异可以归纳为四点:

  1. this 语义不同。匿名内部类是一个独立的类,其中的 this 指向匿名类自身;Lambda 不是一个类,它没有自己的 this,Lambda 体内的 this 指向外围对象(即定义它的那个对象)。
  2. 类文件数量不同。匿名内部类编译后会生成 Outer$1.class 这样的独立 class 文件;Lambda 不会产生新的 class 文件。
  3. 字节码指令不同。匿名内部类是 new + invokespecial(Java 7 及以前的标准做法);Lambda 是 invokedynamic。
  4. 捕获语义不同。匿名内部类可以捕获并修改外部变量(Java 允许访问 effectively final 的局部变量,内部类字段可写),Lambda 只能读取 effectively final。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThisSemantics {
private String name = "outer";

void anonymous() {
Runnable r = new Runnable() {
private String name = "anonymous";
@Override public void run() {
System.out.println(this.name); // anonymous:this 指向匿名内部类实例
}
};
r.run();
}

void lambda() {
Runnable r = () -> System.out.println(this.name); // outer:this 指向 ThisSemantics 实例
r.run();
}

public static void main(String[] args) {
new ThisSemantics().anonymous(); // anonymous
new ThisSemantics().lambda(); // outer
}
}

2.5 invokedynamic 与 LambdaMetafactory 的执行链路

早期有一种误解:”Lambda 就是匿名内部类的语法糖”。这是错的。JDK 8 的设计者刻意避开了为每个 Lambda 生成内部类,原因是内部类方案有三个硬伤:类文件膨胀、类加载开销大、未来无法替换实现策略。

真正的实现是 invokedynamic(indy),这条指令原本为动态语言(JRuby、Groovy)在 JVM 上运行而设计(JSR-292,Java 7 引入),Lambda 是它在 Java 语言中的第一个大规模应用。完整链路如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.lang.invoke.*;
import java.util.function.*;

public class LambdaMetafactoryDemo {
public static void main(String[] args) throws Throwable {
// 手动模拟 JVM 为 Lambda 所做的事情:
// 1. 找一个"调用点",它的类型是函数式接口 IntBinaryOperator
// 2. 实现方法是一个静态方法(Lambda 体被编译成私有静态方法 lambda$main$0)
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodType samType = MethodType.methodType(IntBinaryOperator.class); // 工厂返回类型
MethodType implType = MethodType.methodType(int.class, int.class, int.class); // 实现方法签名
MethodHandle impl = lookup.findStatic(LambdaMetafactoryDemo.class,
"lambda$main$0", implType);
MethodType instantiatedType = MethodType.methodType(int.class, int.class, int.class);

CallSite site = LambdaMetafactory.metafactory(
lookup, "applyAsInt", samType, instantiatedType, impl, instantiatedType);
IntBinaryOperator op = (IntBinaryOperator) site.getTarget().invoke();
System.out.println(op.applyAsInt(6, 7)); // 42
}

// 编译器会把 Lambda 体编译成这样的私有静态方法(非捕获型)
private static int lambda$main$0(int a, int b) {
return a * b;
}
}

编译器在编译期做三件事:

  1. 把 Lambda 体脱糖(desugar)成一个私有方法,非捕获型编译为 private static 方法,捕获型编译为 private 实例方法(捕获的变量变成参数或字段)。
  2. 在类文件中写入一条 invokedynamic 指令,其引导方法(Bootstrap Method)指向 LambdaMetafactory.metafactory,常量池里带上函数式接口的方法名、方法签名、实现方法的 MethodHandle。
  3. 生成 BootstrapMethods 属性表。

运行期第一次执行到这条 invokedynamic 时,JVM 调用引导方法,LambdaMetafactory 用 ASM 在内存中动态生成一个类(类名形如 LambdaMetafactoryDemo$$Lambda$14,是 LambdaMetafactoryDemo 的隐藏类 hidden class),该类实现目标函数式接口,其 applyAsInt 方法体直接转发到那个私有静态方法;随后把生成的类与调用点永久绑定(Linkage),后续调用就是一次普通的接口调用,可被 JIT 完全内联。

要点:为什么要绕这一圈?因为链接策略被延后到运行期。Lambda 的具体实现类是运行期生成的,JVM 未来可以换成”生成真正轻量的值对象””使用常量折叠”等更优策略,而所有已编译的 class 文件无需重新编译就能受益。这正是 invokedynamic 的设计精髓:把策略选择权从编译器交还给运行时。

2.6 Lambda 的性能

  • 首次链接有成本:第一次执行 invokedynamic 时要触发引导方法、生成类、链接,这是毫秒级的。之后就是常量开销。
  • 非捕获型 Lambda 会被缓存:没有捕获任何外部变量的 Lambda 是无状态的,JVM 会复用同一个实例(LambdaMetafactory 的生成类里有个静态单例字段)。捕获型 Lambda 每次执行都会 new 一个实例。
  • JIT 可以完全内联:Lambda 调用路径很短,C2 编译后通常与普通方法调用无差别,逃逸分析还可能把捕获型实例标量替换掉。
  • 结论:在热点代码中不要为了”性能”而拒绝 Lambda;真正的性能风险来自在循环里反复创建捕获型 Lambda 以及 Stream 的装箱开销,而不是 Lambda 本身。

三、函数式接口体系:java.util.function

3.1 四大基础接口与派生

java.util.function 包提供了 43 个接口,但只需记住四个”根接口”,其余都是它们的变体:

接口 抽象方法 含义 常见派生
Supplier<T> T get() 无入参、有返回值(生产者) IntSupplier、BooleanSupplier
Consumer<T> void accept(T) 有入参、无返回值(消费者) BiConsumer<T,U>、IntConsumer、ObjIntConsumer<T>
Function<T,R> R apply(T) 一入参、一返回值(转换器) BiFunction<T,U,R>、UnaryOperator<T>、IntFunction<R>、IntToLongFunction
Predicate<T> boolean test(T) 一入参、返回布尔(判定器) BiPredicate<T,U>、IntPredicate

命名规律要记牢:Bi 表示两个入参;Int/Long/Double 前缀表示入参是基本类型(避免装箱);ToInt/ToLong/ToDouble 后缀表示返回值是基本类型;UnaryOperator 是 Function<T,T> 的特化,BinaryOperator 是 BiFunction<T,T,T> 的特化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.function.*;

public class FunctionPkgDemo {
public static void main(String[] args) {
Predicate<String> notEmpty = s -> s != null && !s.isEmpty();
Function<String, Integer> toLen = String::length;
UnaryOperator<Integer> square = x -> x * x;
BinaryOperator<Integer> max = Integer::max;
IntFunction<String> intToStr = i -> "编号" + i; // 入参 int,避免 Integer 装箱
BiFunction<Integer, Integer, String> fmt = (a, b) -> a + "/" + b;

System.out.println(notEmpty.test("java")); // true
System.out.println(toLen.apply("java")); // 4
System.out.println(square.apply(9)); // 81
System.out.println(max.apply(3, 7)); // 7
System.out.println(intToStr.apply(17)); // 编号17
System.out.println(fmt.apply(1, 2)); // 1/2
}
}

3.2 自定义函数式接口与 @FunctionalInterface

@FunctionalInterface 注解不是必需的,但它有两个价值:编译期保护(一旦你加了第二个抽象方法就报错)和自文档化。注意它只约束”抽象方法数量”,默认方法与静态方法不计入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.function.Function;

// 自定义:带受检异常的函数式接口(标准库没有,实际项目中很常见)
@FunctionalInterface
public interface CheckedFunction<T, R> {
R apply(T t) throws Exception;

// 默认方法不计入抽象方法数量,仍然合法
default <V> CheckedFunction<T, V> andThen(CheckedFunction<R, V> after) {
return t -> after.apply(apply(t));
}

// 静态方法:把受检异常转为运行时异常,方便在 Stream 中使用
static <T, R> Function<T, R> unchecked(CheckedFunction<T, R> f) {
return t -> {
try {
return f.apply(t);
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
}

3.3 函数组合:andThen / compose / negate / and / or

组合是函数式编程的核心武器:把小函数拼成大函数,避免层层嵌套。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.function.*;
import java.util.*;
import java.util.stream.*;

public class ComposeDemo {
public static void main(String[] args) {
Function<Integer, Integer> times2 = x -> x * 2;
Function<Integer, Integer> plus1 = x -> x + 1;

// andThen:先执行自己,再执行参数 -> plus1(times2(x))
Function<Integer, Integer> f1 = times2.andThen(plus1);
// compose:先执行参数,再执行自己 -> times2(plus1(x))
Function<Integer, Integer> f2 = times2.compose(plus1);

System.out.println(f1.apply(3)); // 7
System.out.println(f2.apply(3)); // 8

Predicate<String> startWithA = s -> s.startsWith("A");
Predicate<String> lenGt3 = s -> s.length() > 3;
List<String> data = List.of("Apple", "Ant", "Banana", "Ax");

System.out.println(data.stream().filter(startWithA.and(lenGt3)).toList()); // [Apple]
System.out.println(data.stream().filter(startWithA.or(lenGt3)).toList()); // [Apple, Banana]
System.out.println(data.stream().filter(startWithA.negate()).toList()); // [Banana]

// Consumer 也有 andThen,常用于"先打印再落库"这类流水线
Consumer<String> log = s -> System.out.println("[log] " + s);
Consumer<String> save = s -> System.out.println("[db ] " + s);
data.forEach(log.andThen(save));
}
}

3.4 重构实战:把命令式代码改写为函数式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import java.util.*;
import java.util.stream.*;
import java.math.BigDecimal;

// 需求:从订单列表中找出"金额大于 1000 且状态为已支付"的订单,按金额降序取前 3 个订单号
class Order {
private final String id;
private final BigDecimal amount;
private final String status;

Order(String id, String amount, String status) {
this.id = id; this.amount = new BigDecimal(amount); this.status = status;
}

public String getId() { return id; }
public BigDecimal getAmount() { return amount; }
public String getStatus() { return status; }
}

public class RefactorDemo {
// 改造前:21 行命令式代码,5 个临时变量,阅读时要在脑子里维护一个状态机
static List<String> before(List<Order> orders) {
List<Order> filtered = new ArrayList<>();
for (Order o : orders) {
if (o.amount.compareTo(new BigDecimal("1000")) > 0 && "PAID".equals(o.status)) {
filtered.add(o);
}
}
filtered.sort(new Comparator<Order>() {
@Override public int compare(Order a, Order b) {
return b.amount.compareTo(a.amount);
}
});
List<String> ids = new ArrayList<>();
int n = Math.min(3, filtered.size());
for (int i = 0; i < n; i++) {
ids.add(filtered.get(i).id);
}
return ids;
}

// 改造后:声明式,每一步读起来就是一句需求描述
static List<String> after(List<Order> orders) {
return orders.stream()
.filter(o -> o.amount.compareTo(new BigDecimal("1000")) > 0)
.filter(o -> "PAID".equals(o.status))
.sorted(Comparator.comparing(Order::getAmount).reversed())
.limit(3)
.map(Order::getId)
.toList(); // JDK 16+
}

public static void main(String[] args) {
List<Order> orders = List.of(
new Order("A01", "1200", "PAID"),
new Order("A02", "800", "PAID"),
new Order("A03", "3000", "PAID"),
new Order("A04", "5000", "UNPAID"),
new Order("A05", "2000", "PAID"));
System.out.println(before(orders)); // [A03, A05, A01]
System.out.println(after(orders)); // [A03, A05, A01]
}
}

四、Stream API 深入

4.1 Stream 与集合的本质区别

维度 集合(Collection) 流(Stream)
存储 存储全部元素,是内存中的数据结构 不存储数据,按需从源头拉取
求值 急切(eager):add 时元素已在 惰性(lazy):只有终端操作才触发计算
遍历次数 可无限次遍历 一次性,消费后即关闭,再调用抛 IllegalStateException
遍历方式 外部迭代(for / iterator,用户控制) 内部迭代(库控制,可并行、可优化)
数据规模 有限 可以是无限流(iterate、generate)
关注点 数据本身 对数据的计算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.stream.*;
import java.util.*;

public class StreamOnceDemo {
public static void main(String[] args) {
Stream<String> s = Stream.of("a", "b", "c");
System.out.println(s.count()); // 3
try {
s.forEach(System.out::println); // 抛 IllegalStateException: stream has already been operated upon or closed
} catch (IllegalStateException e) {
System.out.println("错误:" + e.getMessage());
}
// 正确做法:需要多次遍历就重新创建流,或用 Supplier<Stream<T>> 包装
Supplier<Stream<String>> supplier = () -> Stream.of("a", "b", "c");
System.out.println(supplier.get().count());
supplier.get().forEach(System.out::println);
}
}

4.2 创建 Stream 的六种方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;
import java.math.BigInteger;

public class CreateStreamDemo {
public static void main(String[] args) throws IOException {
// 1. 集合:Collection.stream() / parallelStream()
Stream<String> s1 = List.of("a", "b").stream();

// 2. 数组:Arrays.stream() 或 Stream.of()
IntStream s2 = Arrays.stream(new int[]{1, 2, 3});
Stream<Integer> s2b = Arrays.stream(new Integer[]{1, 2, 3}); // 注意不是 IntStream

// 3. Stream.of / Stream.ofNullable(JDK 9+,null 变空流,避免 NPE)
Stream<String> s3 = Stream.of("x", "y");
Stream<String> s3b = Stream.ofNullable(null); // 空流

// 4. 生成器:iterate(有种子,按顺序推导)/ generate(无状态,无限)
Stream<Integer> s4 = Stream.iterate(1, n -> n * 2).limit(5); // 1,2,4,8,16
Stream<BigInteger> fib = Stream.iterate(new BigInteger[]{BigInteger.ZERO, BigInteger.ONE},
a -> new BigInteger[]{a[1], a[0].add(a[1])})
.map(a -> a[0]).limit(10);
Stream<Double> randoms = Stream.generate(Math::random).limit(3);

// 5. 文件:Files.lines(按行,需关闭)/ BufferedReader.lines()
try (Stream<String> lines = Files.lines(Path.of("pom.xml"))) {
long n = lines.filter(l -> l.contains("java")).count();
System.out.println("包含 java 的行数:" + n);
}

// 6. 基本类型流与 range:IntStream.range 不含上界,rangeClosed 含上界
int sum = IntStream.rangeClosed(1, 100).sum(); // 5050
System.out.println(s4.toList() + " " + fib.toList());
System.out.println(randoms.toList().size() + " " + sum);
}
}

4.3 中间操作与终端操作对照表

类别 操作 说明 是否有状态
中间 filter 按 Predicate 过滤 无状态
中间 map / mapToInt/Long/Double 一对一转换 / 转基本类型流 无状态
中间 flatMap / flatMapToInt... 一对多转换并扁平化 无状态
中间 peek 调试用,观察元素流过 无状态
中间 distinct 去重,依赖 equals/hashCode 有状态
中间 sorted / sorted(cmp) 排序,需缓冲全部元素 有状态
中间 limit / skip 截断 / 跳过(有序流可短路) 有状态
中间 takeWhile / dropWhile(JDK 9+) 按条件取前缀 / 丢前缀 有状态
终端 forEach / forEachOrdered 遍历,后者保证有序 —
终端 collect 汇聚为集合/字符串/Map/自定义 —
终端 reduce 归约为单个值 —
终端 count / min / max 计数 / 最值(min/max 返回 Optional) —
终端 anyMatch / allMatch / noneMatch 短路匹配 —
终端 findFirst / findAny 查找(返回 Optional) —
终端 toArray / toList(JDK 16+) 转为数组 / 不可变 List —

4.4 流水线结构与惰性求值

Stream 的内部实现是一条双向链表:源码位于 java.util.stream,核心类是 AbstractPipeline 的三个子类。

  • Head:流水线的头节点,持有数据源(Spliterator)与是否并行的标志。
  • StatelessOp(无状态中间操作,如 filter/map):处理单个元素即可输出结果,不需要知道其它元素。
  • StatefulOp(有状态中间操作,如 sorted/distinct/limit):必须缓冲或看到多个元素才能输出结果。

终端操作触发时,Stream 从最后一个操作开始反向回溯,把每个中间操作包装成一个 Sink,通过 opWrapSink 方法把后一个 Sink 传给前一个,形成一条 Sink 链(本质上是职责链模式)。数据源通过 Spliterator.forEachRemaining 把元素一个个喂给链头,元素依次穿过所有 Sink,最后由终端操作的 Sink 收集结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.stream.*;
import java.util.*;

public class LazyDemo {
public static void main(String[] args) {
System.out.println("=== 没有终端操作,中间操作一行都不执行 ===");
Stream.of("a1", "a2", "b1", "c2", "c1")
.filter(s -> { System.out.println("filter: " + s); return s.startsWith("a"); });
// 上面这行运行后没有任何输出!因为没有终端操作

System.out.println("=== 加上终端操作后,元素逐个穿过流水线 ===");
Stream.of("a1", "a2", "b1", "c2", "c1")
.filter(s -> { System.out.println("filter: " + s); return s.startsWith("c"); })
.map(s -> { System.out.println("map: " + s); return s.toUpperCase(); })
.forEach(s -> System.out.println("forEach: " + s));
// 输出顺序:filter b1 / filter c2 / map c2 / forEach C2 ...
// 注意是"纵向"执行:一个元素走完全部操作,再处理下一个元素

System.out.println("=== 短路优化:limit(2) 让流水线提前结束 ===");
Stream.of("a1", "a2", "a3", "a4")
.peek(s -> System.out.println("peek: " + s))
.limit(2)
.toList();
// 只输出 peek: a1 / peek: a2,a3 a4 根本没被处理
}
}

peek 不执行是最经典的踩坑:很多人写完 stream.peek(System.out::println) 发现控制台什么都没有,就以为 Stream “坏了”。真相是 peek 是中间操作,必须接终端操作。另外在 limit/count 这类短路场景下,JIT 与 Stream 框架可能判定 peek 的结果无人使用而整个跳过,所以 peek 只适合调试,绝不要在里面写业务逻辑(count() 在 JDK 9+ 还会直接跳过中间操作优化)。

4.5 常用操作与 Collectors 全家桶

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import java.util.*;
import java.util.stream.*;
import java.math.BigDecimal;

// Record 作为 DTO:自动获得构造器、访问器、equals/hashCode/toString
record Order(String id, String category, BigDecimal amount, String status) {}

public class CollectorsDemo {
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("1", "数码", new BigDecimal("2999"), "PAID"),
new Order("2", "数码", new BigDecimal("899"), "PAID"),
new Order("3", "图书", new BigDecimal("59"), "PAID"),
new Order("4", "图书", new BigDecimal("129"), "UNPAID"),
new Order("5", "服饰", new BigDecimal("399"), "PAID"),
new Order("6", "数码", new BigDecimal("5999"), "PAID"));

// 1. toList / toSet / toCollection:JDK 16 的 toList 返回不可变 List
List<String> ids = orders.stream().map(Order::id).toList();
Set<String> cats = orders.stream().map(Order::category).collect(Collectors.toSet());
LinkedList<String> linked = orders.stream().map(Order::id)
.collect(Collectors.toCollection(LinkedList::new));

// 2. joining:拼接字符串,可带分隔符、前缀、后缀
String joined = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
System.out.println(joined); // [1, 2, 3, 4, 5, 6]

// 3. toMap:注意键冲突!第三个参数是 merge 函数
Map<String, Order> byId = orders.stream()
.collect(Collectors.toMap(Order::id, o -> o, (a, b) -> a)); // 冲突时保留前者

// 4. groupingBy:一级分组
Map<String, List<Order>> byCat = orders.stream()
.collect(Collectors.groupingBy(Order::category));

// 5. groupingBy + 下游收集器:分组后统计(分组计数、分组求和)
Map<String, Long> countByCat = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.counting()));
Map<String, BigDecimal> sumByCat = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.reducing(BigDecimal.ZERO, Order::amount, BigDecimal::add)));

// 6. groupingBy + summarizingXXX:一次拿到 count/sum/min/max/average
Map<String, IntSummaryStatistics> statByCat = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.summarizingInt(o -> o.amount.intValue())));
System.out.println("数码: " + statByCat.get("数码"));

// 7. 多级分组:先按状态再按品类
Map<String, Map<String, List<Order>>> multi = orders.stream()
.collect(Collectors.groupingBy(Order::status,
Collectors.groupingBy(Order::category)));

// 8. partitioningBy:按布尔条件二分,key 只有 true/false
Map<Boolean, List<Order>> paidPartition = orders.stream()
.collect(Collectors.partitioningBy(o -> "PAID".equals(o.status)));

// 9. 分组后取每组金额最高的订单(下游用 collectingAndThen + maxBy)
Map<String, Optional<Order>> topByCat = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.maxBy(Comparator.comparing(Order::amount))));
System.out.println(topByCat);
}
}

4.5.1 自定义 Collector

Collector<T, A, R> 有五个要素:supplier(创建可变容器)、accumulator(累加元素)、combiner(并行时合并容器)、finisher(容器转结果)、characteristics(特性标志,影响框架能否优化)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.*;
import java.util.stream.*;

public class CustomCollectorDemo {
// 需求:把字符串流收集为一个"不可变且已排序"的 List
static <T extends Comparable<? super T>> Collector<T, ?, List<T>> toSortedImmutableList() {
return Collector.of(
ArrayList::new, // supplier:可变容器
List::add, // accumulator:累加
(left, right) -> { left.addAll(right); return left; }, // combiner:并行合并
list -> { list.sort(null); return Collections.unmodifiableList(list); }, // finisher
Collector.Characteristics.UNORDERED // 声明不关心顺序,允许框架优化
);
}

public static void main(String[] args) {
List<String> result = Stream.of("pear", "apple", "orange", "banana")
.collect(toSortedImmutableList());
System.out.println(result); // [apple, banana, orange, pear]
// result.add("x"); // 抛 UnsupportedOperationException
}
}

4.6 flatMap 的扁平化思维

map 是”一对一的盒子套盒子”,flatMap 是”把盒子拆开摊平”。判断标准:如果 map 之后得到的是 Stream<Stream<T>> 或 Stream<List<T>>,就该用 flatMap。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.*;
import java.util.stream.*;

public class FlatMapDemo {
public static void main(String[] args) {
List<List<String>> nested = List.of(
List.of("Java", "Kotlin"),
List.of("Python", "Go"),
List.of("Rust"));

// map:得到 Stream<List<String>>,还要再处理一层,很别扭
List<List<String>> wrong = nested.stream().map(l -> l).toList();

// flatMap:直接摊平成 Stream<String>
List<String> flat = nested.stream().flatMap(Collection::stream).toList();
System.out.println(flat); // [Java, Kotlin, Python, Go, Rust]

// 实战:句子列表 -> 去重后的单词列表
List<String> sentences = List.of("Hello world", "Hello java", "java stream");
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.toLowerCase().split(" ")))
.distinct()
.sorted()
.toList();
System.out.println(words); // [hello, java, stream, world]

// 实战:笛卡尔积(两个集合的所有组合)
List<String> colors = List.of("红", "蓝");
List<String> sizes = List.of("S", "L");
List<String> product = colors.stream()
.flatMap(c -> sizes.stream().map(s -> c + "-" + s))
.toList();
System.out.println(product); // [红-S, 红-L, 蓝-S, 蓝-L]

// 基本类型流的 flatMap:IntStream.flatMap 返回 IntStream,避免装箱
int[] boxed = IntStream.of(1, 2).flatMap(i -> IntStream.range(0, i)).toArray();
System.out.println(Arrays.toString(boxed)); // [0, 0, 1]
}
}

4.7 Stream 的坑与避坑指南

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.*;
import java.util.stream.*;
import java.util.concurrent.*;

public class StreamPitfallDemo {
public static void main(String[] args) throws Exception {
// 坑 1:复用已消费的流(见 4.1)

// 坑 2:toMap 键冲突抛 IllegalStateException
List<String> dup = List.of("a", "a", "b");
try {
dup.stream().collect(Collectors.toMap(s -> s, String::length)); // 抛异常
} catch (IllegalStateException e) {
System.out.println("坑2:" + e.getMessage()); // Duplicate key a
}
Map<String, Integer> ok = dup.stream()
.collect(Collectors.toMap(s -> s, String::length, Integer::sum)); // 用 merge 函数

// 坑 3:基本类型流的装箱开销
// 慢:Stream<Integer> 每次运算都要装箱
long boxedSum = IntStream.range(0, 10_000_000).boxed().mapToLong(i -> i).sum();
// 快:全程 IntStream/LongStream
long rawSum = LongStream.range(0, 10_000_000).sum();
System.out.println("坑3:" + boxedSum + " " + rawSum);

// 坑 4:parallelStream 默认使用公共 ForkJoinPool,会被阻塞任务污染
// 下面的代码会让整个 JVM 里所有 parallelStream 都变慢(commonPool 只有 CPU 核心数-1 个线程)
// List.of("a","b").parallelStream().forEach(s -> { Thread.sleep(5000); });

// 正确做法:I/O 密集型并行任务放到自定义线程池中执行(必须在 pool 内提交才生效)
ForkJoinPool custom = new ForkJoinPool(4);
custom.submit(() ->
IntStream.range(0, 10).parallel().forEach(i -> System.out.print(i + " "))
).get();
custom.shutdown();

// 坑 5:forEach 在并行流下不保证顺序,需要顺序用 forEachOrdered
IntStream.range(0, 10).parallel().forEach(i -> System.out.print(i + " ")); // 乱序
System.out.println();
IntStream.range(0, 10).parallel().forEachOrdered(i -> System.out.print(i + " ")); // 0..9
System.out.println();

// 坑 6:sorted 会把所有元素缓冲到内存,无限流上用 sorted 会 OOM
// 坑 7:Optional.get() 前不判断,抛 NoSuchElementException
System.out.println("坑2修复:" + ok);
}
}

4.8 parallelStream 的拆分策略与 Spliterator

并行流的底层是 Fork/Join 框架(本系列第(十三)篇讲过线程池,这里只聚焦拆分逻辑):

  1. 数据源被包装成一个 Spliterator(可拆分迭代器),它除了 tryAdvance(遍历)还有 trySplit(把自己一分为二)。
  2. 框架递归调用 trySplit,直到子任务小到不值得再拆(阈值约 1024 个元素),形成一棵任务树。
  3. 每个叶子任务在 ForkJoinPool 的工作线程上执行,通过 work-stealing 调度。
  4. 每个子任务独立执行完整的流水线(各自的 Sink 链),最后由 combiner 合并结果。

因此并行流生效的前提是:数据源易拆分(ArrayList、数组、IntStream.range 的 Spliterator 拆分是 O(1);LinkedList、Files.lines、BufferedReader.lines 拆分代价高甚至无法有效拆分)、操作无状态无副作用、结果可合并。

并行流不是性能优化的万能药。数据量小于一万、操作本身很轻(如简单 map)、数据源是 LinkedList、涉及 I/O 阻塞这四种情况下,并行往往比串行更慢。真正的收益场景是:数据量大(十万级以上)、单个元素处理耗时(CPU 密集)、数据源可均匀拆分。另外请记住 parallelStream() 走的是 ForkJoinPool.commonPool(),一旦有任务阻塞,会影响到 JVM 中所有使用并行流的代码。

五、Optional 与空值处理

5.1 定位:返回值的容器

Optional<T> 的设计初衷是作为方法返回值,明确告诉调用者”这里可能没有值”。它不应该用作类的字段(Serializable 都不支持)、方法参数(会造成调用方必须包装的负担)或集合元素(用空集合表示无元素即可,别用 List<Optional<T>>)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.*;

public class OptionalBasic {
// 反模式:字段用 Optional(不可序列化、增加内存开销、语义无收益)
// static class Bad { Optional<String> name; }

// 正确:方法返回值用 Optional
static Optional<String> findNameById(int id) {
return id > 0 ? Optional.of("user" + id) : Optional.empty();
}

public static void main(String[] args) {
Optional<String> a = Optional.of("java"); // 明确非空,传 null 立刻 NPE(快速失败)
Optional<String> b = Optional.ofNullable(null); // 可能为 null
Optional<String> c = Optional.empty(); // 明确为空

// 反模式:isPresent + get 等价于传统的 if (x != null),没有任何收益
if (a.isPresent()) {
System.out.println(a.get().toUpperCase());
}

// 正模式:函数式消费
a.ifPresent(s -> System.out.println(s.toUpperCase()));
a.ifPresentOrElse(s -> System.out.println("有值:" + s),
() -> System.out.println("无值")); // JDK 9+
}
}

5.2 三大取值方法:orElse / orElseGet / orElseThrow 的区别

这是面试必考点,核心差异在求值时机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.*;

public class OrElseDemo {
static String defaultValue() {
System.out.println(" >> defaultValue() 被调用了");
return "默认值";
}

public static void main(String[] args) {
Optional<String> present = Optional.of("真实值");
Optional<String> empty = Optional.empty();

System.out.println("1) orElse(有值时):");
System.out.println(" 结果 = " + present.orElse(defaultValue()));
// 关键点:即使 Optional 有值,defaultValue() 依然会被执行!参数先求值再传参。

System.out.println("2) orElseGet(有值时):");
System.out.println(" 结果 = " + present.orElseGet(OrElseDemo::defaultValue));
// Supplier 懒执行,有值时根本不调用

System.out.println("3) orElseGet(空值时):");
System.out.println(" 结果 = " + empty.orElseGet(OrElseDemo::defaultValue));

System.out.println("4) orElseThrow(空值时抛自定义异常):");
try {
empty.orElseThrow(() -> new IllegalStateException("用户不存在"));
} catch (IllegalStateException e) {
System.out.println(" 捕获:" + e.getMessage());
}
System.out.println("5) orElseThrow() 无参版(JDK 10+,抛 NoSuchElementException):");
System.out.println(" " + present.orElseThrow());
}
}

结论:默认值是常量或已存在的变量时用 orElse;默认值需要计算(查库、拼字符串、构造对象)时务必用 orElseGet,否则白白付出计算代价。

5.3 Optional 方法速查表

方法 JDK 作用 备注
of / ofNullable / empty 8 创建 of(null) 直接 NPE
isPresent / isEmpty 8 / 11 判断是否有值 isEmpty 是 JDK 11 新增
get 8 取值 空值时抛异常,尽量少用
orElse / orElseGet 8 兜底值 前者急切、后者惰性
orElseThrow() / orElseThrow(Supplier) 10 / 8 兜底异常 无参版抛 NoSuchElementException
ifPresent / ifPresentOrElse 8 / 9 消费 返回 void
map / flatMap 8 转换 flatMap 避免 Optional<Optional<T>>
filter 8 条件过滤 不满足则变 empty
stream 9 转 Stream 空则为空流,可无缝接 Stream 链
or 9 链式兜底 a.or(() -> b)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.*;
import java.util.stream.*;

public class OptionalChain {
static Optional<String> getCity() { return Optional.of(" HangZhou "); }
static Optional<Integer> parse(String s) {
try { return Optional.of(Integer.parseInt(s)); }
catch (NumberFormatException e) { return Optional.empty(); }
}

public static void main(String[] args) {
// 链式:去空白 -> 过滤非空 -> 转大写 -> 兜底
String city = getCity()
.map(String::strip) // JDK 11 的 strip,比 trim 更彻底(支持 Unicode 空白)
.filter(s -> !s.isEmpty())
.map(String::toUpperCase)
.orElse("UNKNOWN");
System.out.println(city); // HANGZHOU

// flatMap:避免嵌套 Optional
Optional<Integer> nested = getCity().flatMap(OptionalChain::parse);

// stream():把 Optional 无缝接入 Stream 管道(JDK 9+)
List<Optional<String>> list = List.of(Optional.of("a"), Optional.empty(), Optional.of("b"));
List<String> flat = list.stream().flatMap(Optional::stream).toList();
System.out.println(flat); // [a, b]

// or():JDK 9+ 的备用 Optional 链
String v = Optional.<String>empty()
.or(() -> Optional.of("第二备选"))
.orElse("最终兜底");
System.out.println(v); // 第二备选
}
}

5.4 与持久层查询的配合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// MyBatis Mapper:直接返回 Optional,MyBatis 3.5+ 支持 Optional 返回类型
public interface UserMapper {
Optional<User> findById(Long id); // 查不到自动变 Optional.empty()
List<User> findByStatus(String status); // 集合查询不要返回 Optional<List>,返回空集合即可
}

// JPA Repository 同理
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}

// Service 层:用 orElseThrow 把"查不到"翻译成业务异常
class UserService {
private final UserMapper mapper;
UserService(UserMapper mapper) { this.mapper = mapper; }

User requireUser(Long id) {
return mapper.findById(id)
.orElseThrow(() -> new IllegalArgumentException("用户不存在, id=" + id));
}
static class User {}
}

Optional 误用清单:① 用 Optional 作为字段或参数;② isPresent()+get() 的老式写法;③ 在 orElse 里放高开销计算;④ 集合查询返回 Optional<List<T>>(应返回空集合);⑤ 用 Optional.of(可能为 null 的值)(应用 ofNullable);⑥ 在 Lambda 里创建 Optional 只为调用 get();⑦ 把 Optional 当 null 一样 return null 回去(比不用 Optional 还糟)。

六、JDK 9-11:工程实用性的飞跃

6.1 集合工厂方法与不可变集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.*;

public class FactoryMethodDemo {
public static void main(String[] args) {
// JDK 9+:一行创建不可变集合(底层是 ImmutableCollections,比 Collections.unmodifiableList 更省内存)
List<String> list = List.of("a", "b", "c");
Set<Integer> set = Set.of(1, 2, 3); // 重复元素抛 IllegalArgumentException
Map<String, Integer> map = Map.of("a", 1, "b", 2); // 最多 10 对
Map<String, Integer> map2 = Map.ofEntries(Map.entry("a", 1), Map.entry("b", 2)); // 任意数量

// 注意:of() 不接受 null 元素(存 null 会 NPE),这是刻意设计
// 需要可变集合就包一层
List<String> mutable = new ArrayList<>(List.of("a", "b"));

// JDK 10+:List.copyOf / Set.copyOf / Map.copyOf 做防御性拷贝
List<String> copy = List.copyOf(mutable);
System.out.println(list + " " + set + " " + map + " " + copy);
}
}

6.2 Stream 增强与 String 新方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.*;
import java.util.stream.*;

public class Jdk9StreamDemo {
public static void main(String[] args) {
// takeWhile:从头取,遇到第一个不满足的元素就停(有序流上才短路)
System.out.println(Stream.of(1, 2, 3, 4, 1, 2).takeWhile(i -> i < 4).toList()); // [1, 2, 3]
// dropWhile:从头丢弃,遇到第一个不满足的就开始保留
System.out.println(Stream.of(1, 2, 3, 4, 1, 2).dropWhile(i -> i < 4).toList()); // [4, 1, 2]
// iterate 三参版:带终止条件的 iterate(JDK 9+),替代了 limit 的老写法
System.out.println(Stream.iterate(1, i -> i < 20, i -> i * 2).toList()); // [1,2,4,8,16]
// ofNullable:null 变空流,在 map 之后特别有用
System.out.println(Stream.ofNullable(null).count()); // 0

String s = " Hello\nJava\r\n \n";
System.out.println(s.isBlank()); // false(有非空白字符)
System.out.println(s.strip()); // 去首尾 Unicode 空白(比 trim 强)
System.out.println(s.stripLeading());
System.out.println(s.lines().toList()); // 按行切分为 Stream<String>
System.out.println("ab".repeat(3)); // ababab
System.out.println(" ".isBlank()); // true
}
}

6.3 var 局部变量类型推断的使用边界

var 是 Java 10 引入的,只用于局部变量,编译期推断出确切类型后写入字节码,Java 依然是强类型语言,不存在运行期动态类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.*;

public class VarDemo {
public static void main(String[] args) {
var name = "java"; // 推断为 String
var list = new ArrayList<String>(); // 推断为 ArrayList<String>
try (var reader = new java.io.BufferedReader(new java.io.StringReader("hi"))) {
System.out.println(reader.readLine());
} catch (Exception e) { /* 忽略 */ }
for (var s : List.of("a", "b")) {
System.out.println(s.toUpperCase());
}

// 以下都是非法的:
// var x; // 必须初始化
// var n = null; // 无法推断
// var fn = (a, b) -> a + b; // Lambda 不能作为 var 的目标(无法推断函数式接口类型)
// var field = 1; // 不能用于字段
// var m(String a) {} // 不能用于方法参数与返回类型
}
}

使用建议:当类型在右侧已经明确写出(new ArrayList<String>())或类型名极长(Map<String, List<OrderSummary>>)时用 var 能显著提升可读性;当初始化表达式类型不明显(var result = service.query())时不要用 var,那会让读代码的人必须跳到方法签名才知道类型。

6.4 HttpClient:JDK 11 正式版

java.net.http.HttpClient 取代了老旧的 HttpURLConnection,支持 HTTP/2、WebSocket、同步/异步、响应式 Body 处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;

public class HttpClientDemo {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // 优先 HTTP/2
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();

// 1. 同步 GET
HttpRequest get = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/get"))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> resp = client.send(get, HttpResponse.BodyHandlers.ofString());
System.out.println("状态码:" + resp.statusCode());
System.out.println("响应体:" + resp.body());

// 2. 异步 GET:返回 CompletableFuture,不阻塞当前线程
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(get, HttpResponse.BodyHandlers.ofString());
future.thenApply(HttpResponse::body)
.thenAccept(b -> System.out.println("异步长度:" + b.length()))
.join(); // 示例中为等待结果,生产环境不要 join

// 3. POST JSON
HttpRequest post = HttpRequest.newBuilder()
.uri(URI.create("https://httpbin.org/post"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"java\"}"))
.build();
System.out.println(client.send(post, HttpResponse.BodyHandlers.ofString()).statusCode());

// 4. 直接把响应体保存为文件(流式,不占内存)
// client.send(get, HttpResponse.BodyHandlers.ofFile(Path.of("out.json")));
}
}

6.5 单文件源码运行与 JVM 改进

1
2
3
4
# JDK 11+:无需 javac,直接运行单个源文件(源启动器模式),适合脚本与教学
java Hello.java
# 甚至可以写 shebang 让 .java 脚本直接执行(JDK 11+)
# #!/usr/bin/java --source 11

JVM 层面值得一提的改进:G1 从 JDK 9 起成为默认垃圾收集器(取代 Parallel GC),并把原本单线程的 Full GC 改为并行;AppCDS(Application Class-Data Sharing) 可以把类元数据存档复用,显著缩短启动时间(对 Spring Boot 这类大应用收益明显);Epsilon GC 是一个”只分配不回收”的 No-op 收集器,用于性能测试与超短生命周期任务;ZGC 在 JDK 11 以实验特性登场,JDK 15 转正。

七、JDK 12-17:语言表达力的黄金五年

7.1 switch 表达式(JDK 14 转正)

旧 switch 语句有三大痛点:必须写 break(忘了就 fall-through)、每个 case 是独立作用域导致变量泄漏、作为表达式使用时需要临时变量。新 switch 表达式用 -> 箭头语法彻底解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class SwitchExprDemo {
// 旧写法:break 满天飞,还要一个临时变量承接结果
static int oldDaysInMonth(int month, int year) {
int days;
switch (month) {
case 1: case 3: case 5: case 7: case 8: case 10: case 12:
days = 31; break;
case 4: case 6: case 9: case 11:
days = 30; break;
case 2:
days = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 ? 29 : 28;
break;
default:
throw new IllegalArgumentException("非法月份: " + month);
}
return days;
}

// 新写法:箭头语法、多值逗号分隔、不需要 break、可直接作为表达式赋值
static int newDaysInMonth(int month, int year) {
return switch (month) {
case 1, 3, 5, 7, 8, 10, 12 -> 31;
case 4, 6, 9, 11 -> 30;
case 2 -> (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 ? 29 : 28;
default -> throw new IllegalArgumentException("非法月份: " + month);
};
}

// 需要多语句时用代码块 + yield 返回值
static String describe(int score) {
return switch (score / 10) {
case 10, 9 -> "优秀";
case 8, 7 -> "良好";
case 6 -> {
System.out.println("及格线边缘");
yield "及格"; // yield 只在 switch 表达式的代码块中使用
}
default -> "不及格";
};
}

public static void main(String[] args) {
System.out.println(newDaysInMonth(2, 2024)); // 29
System.out.println(describe(65)); // 及格
}
}

7.2 文本块 Text Blocks(JDK 15 转正)

文本块用三重双引号包裹,解决 SQL/JSON/HTML 拼接的地狱。编译期会做两件事:去掉每行末尾的 incidental 空白(按最小缩进对齐)和规范化换行符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class TextBlockDemo {
public static void main(String[] args) {
// 传统写法:一堆 \n 和 + 号,缩进全乱
String oldJson = "{\n" +
" \"name\": \"java\",\n" +
" \"version\": 21\n" +
"}";

// 文本块:所见即所得
String json = """
{
"name": "java",
"version": 21,
"features": ["record", "sealed", "virtual-thread"]
}
""";

String sql = """
SELECT u.id, u.name, COUNT(o.id) AS cnt
FROM t_user u
LEFT JOIN t_order o ON o.user_id = u.id
WHERE u.status = 1
GROUP BY u.id, u.name
ORDER BY cnt DESC
""";

// \s 转义:保留行尾空格(默认行尾空白会被裁掉),常用于需要精确控制的场景
String withTrailingSpace = """
line1\s
line2
""";

// formatted():JDK 15 起文本块直接支持格式化,无需 String.format
String tpl = """
<h1>%s</h1>
<p>共 %d 条记录</p>
""".formatted("报表", 42);

System.out.println(json);
System.out.println(sql.split("\n")[0]);
System.out.println(tpl);
}
}

7.3 instanceof 模式匹配(JDK 16 转正)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.util.*;

public class InstanceofPatternDemo {
// 旧写法:强转三次,变量作用域外泄
static String oldWay(Object o) {
if (o instanceof String) {
String s = (String) o; // 手动强转
if (s.length() > 5) {
return "长字符串:" + s.toUpperCase();
}
return "短字符串:" + s;
}
return "非字符串";
}

// 新写法:类型模式——判断与绑定一步完成,绑定变量作用域精确(仅在 && 右侧可用的流作用域规则)
static String newWay(Object o) {
if (o instanceof String s && s.length() > 5) {
return "长字符串:" + s.toUpperCase(); // s 已绑定,直接可用
}
if (o instanceof String s) {
return "短字符串:" + s;
}
if (!(o instanceof Integer i)) {
return "非整数";
}
return "整数:" + (i * 2); // i 在这里依然可用(因为上面已经 return)
}

// 结合 equals 的常见写法
static boolean safeEquals(Object a, Object b) {
return (a instanceof String sa) && (b instanceof String sb) && sa.equals(sb);
}

public static void main(String[] args) {
System.out.println(newWay("hello world")); // 长字符串:HELLO WORLD
System.out.println(newWay(21)); // 整数:42
}
}

7.4 Record 记录类(JDK 16 转正)

Record 是不可变透明载体(transparent carrier):你只声明组件(components),编译器自动生成私有 final 字段、公共访问器(name() 而非 getName())、规范构造器、equals/hashCode/toString。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.*;

// 一行顶五十行:自动生成字段、构造器、访问器、equals/hashCode/toString
public record Point(int x, int y) {
// 紧凑构造器(compact constructor):省略参数列表与赋值,只做校验与规范化
public Point {
if (x < 0 || y < 0) {
throw new IllegalArgumentException("坐标不能为负: " + x + "," + y);
}
}

// 可以添加实例方法、静态方法、额外的构造器,但不能添加实例字段(静态字段可以)
public double distanceTo(Point other) {
return Math.hypot(x - other.x, y - other.y);
}

public static Point origin() { return new Point(0, 0); }
}

class RecordUsage {
public static void main(String[] args) {
Point p = new Point(3, 4);
System.out.println(p.x()); // 3 —— 访问器就是方法名本身,没有 get 前缀
System.out.println(p); // Point[x=3, y=4] —— 自动生成的 toString
System.out.println(p.equals(new Point(3, 4))); // true —— 按组件逐个比较

// 反编译可见:Point 是 final 类,继承 java.lang.Record,
// 字段是 private final,方法带 @Override 且被标记为 record 组件方法
System.out.println(Point.class.isRecord()); // true
System.out.println(Arrays.toString(Point.class.getRecordComponents()));
try {
new Point(-1, 2);
} catch (IllegalArgumentException e) {
System.out.println("校验生效:" + e.getMessage());
}
}
}

Record 的字节码产物:javap -v Point.class 可以看到 ①类是 final 且 extends java.lang.Record;②每个组件生成 private final 字段与同名无参访问器方法;③生成 equals/hashCode/toString 三个方法(基于 Objects.hash 与 invokedynamic ObjectMethods.bootstrap,后者的策略同样可在运行期替换);④生成一个 Class.getRecordComponents 所需的 Record 属性表。Record 不能继承任何类(已隐式继承 Record),但可以实现接口。

Record vs Lombok @Data:① Record 是语言级特性,字节码由编译器生成,没有注解处理器、没有依赖、没有 IDE 插件也能编译;② Record 不可变(字段全是 final,没有 setter),Lombok 的 @Data 生成 setter;③ Record 的访问器是 x() 不是 getX(),Jackson 等框架需要额外适配(新版已支持);④ Record 不能继承、不能加实例字段,Lombok 的 @Data 可以;⑤ 需要 JPA 实体、需要可变、需要继承 → 用 Lombok;需要 DTO、值对象、方法多返回值、Map 复合键 → 用 Record。

7.5 密封类 sealed(JDK 17 转正)

密封类用来精确控制谁能继承我,配合模式匹配就能实现编译期可穷举的代数数据类型(ADT)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// sealed:限制子类;permits 明确列出允许的子类(同包或同模块时 permits 可省略,编译器自动推断)
public sealed interface Shape
permits Circle, Rectangle, Triangle { // 三个子类必须在同一包/同一模块
double area();
}

final class Circle implements Shape {
private final double radius;
Circle(double radius) { this.radius = radius; }
public double area() { return Math.PI * radius * radius; }
}

// sealed 子类:继续向下限制
sealed class Rectangle implements Shape permits Square {
private final double w, h;
Rectangle(double w, double h) { this.w = w; this.h = h; }
public double area() { return w * h; }
}

// non-sealed 子类:重新开放继承(打破密封链)
non-sealed class Triangle implements Shape {
public double area() { return 42; }
}

final class Square extends Rectangle {
Square(double side) { super(side, side); }
}

编译器与 JVM 的 permits 校验规则(硬核点):

  1. permits 列出的类必须存在且可访问,必须直接继承/实现该密封类。
  2. 每个被允许的子类必须用 final、sealed、non-sealed 三者之一修饰,没有第四个选项。这是强制的:否则无限继承链又回来了。
  3. 密封类与其子类必须在同一个包(使用无名模块时)或同一个模块(使用 JPMS 时)。
  4. 校验分两层:javac 在编译期检查;class 文件里写入 PermittedSubclasses 属性,JVM 在类加载时校验——即使你手工改字节码绕过 javac,JVM 也会拒绝加载非法子类。
  5. 反射层面新增 Class.isSealed() 与 Class.getPermittedSubclasses()。

7.6 Helpful NullPointerException

JDK 14 起(JEP 358),JVM 可以打印出精确到变量名的 NPE 信息,默认开启(也可显式加 -XX:+ShowCodeDetailsInExceptionMessages)。

1
2
3
4
5
6
7
8
9
10
11
public class HelpfulNpeDemo {
record User(String name, Address addr) {}
record Address(String city) {}

public static void main(String[] args) {
User u = new User("tom", null);
// JDK 14+ 的异常信息:
// Cannot invoke "Address.city()" because "u.addr()" is null
System.out.println(u.addr().city());
}
}

这条信息直接告诉你是哪个方法调用的返回值为 null,在生产日志里价值极高——再也不用靠”第 108 行”猜了。

八、JDK 18-21:模式匹配的终局与并发新纪元

8.1 switch 模式匹配(JDK 21 转正)

switch 终于可以按类型分支,配合 when 守卫与 Record 解构,Java 具备了完整的模式匹配能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.*;

public class PatternSwitchDemo {
sealed interface Expr permits NumExpr, AddExpr, MulExpr, NegExpr {}
record NumExpr(int value) implements Expr {}
record AddExpr(Expr left, Expr right) implements Expr {}
record MulExpr(Expr left, Expr right) implements Expr {}
record NegExpr(Expr inner) implements Expr {}

// 传统写法:一堆 instanceof + 强转
static int evalOld(Expr e) {
if (e instanceof NumExpr) return ((NumExpr) e).value();
if (e instanceof AddExpr) return evalOld(((AddExpr) e).left()) + evalOld(((AddExpr) e).right());
if (e instanceof MulExpr) return evalOld(((MulExpr) e).left()) * evalOld(((MulExpr) e).right());
if (e instanceof NegExpr) return -evalOld(((NegExpr) e).inner());
throw new IllegalArgumentException();
}

// switch 模式匹配 + 记录解构模式:一行一个分支,结构一目了然
static int eval(Expr e) {
return switch (e) {
case NumExpr(int v) -> v; // 解构出 value
case AddExpr(var l, var r) -> eval(l) + eval(r); // 嵌套解构
case MulExpr(var l, var r) -> eval(l) * eval(r);
case NegExpr(var i) -> -eval(i);
// 密封接口已穷举所有子类型,编译器知道无需 default
};
}

// 守卫 when:在类型模式上附加条件
static String classify(Object o) {
return switch (o) {
case Integer i when i > 100 -> "大整数 " + i;
case Integer i -> "小整数 " + i;
case String s when s.isBlank() -> "空串";
case String s -> "字符串 " + s.length();
case null -> "null 值"; // JDK 21 支持显式 null 分支
default -> "其它";
};
}

public static void main(String[] args) {
Expr expr = new AddExpr(new NumExpr(2), new MulExpr(new NumExpr(3), new NumExpr(4)));
System.out.println(eval(expr)); // 14
System.out.println(classify(200)); // 大整数 200
}
}

type switch 的编译产物:编译器会对每个 case 生成 instanceof 判断 + 类型模式绑定(等价手写的强转),并把整条 switch 编译成先按类型分派再按条件判断的字节码;JDK 21 的实现会把匹配逻辑编译为 invokedynamic SwitchBootstraps.typeSwitch(同样是 indy 引导),使得匹配策略可以在运行期优化(比如按类型哈希做跳转表)。当 switch 的选择器类型是密封接口且所有子类型都被穷举时,编译器不需要 default 分支也不会报”缺少返回语句”——这是密封类带来的最大红利。

8.2 虚拟线程:简要回顾

虚拟线程(Virtual Threads,JEP 444,JDK 21 转正)是JVM 管理的用户态线程,由载体线程(carrier thread,即平台线程)承载,遇到阻塞(如 LockSupport.park、Socket I/O)时会自动从载体线程上卸载,从而用一个平台线程支撑成千上万个并发任务。完整的原理、调度模型与线程池用法见本系列第(十四)篇,这里只做横向对比。

维度 平台线程(Thread) 虚拟线程(VirtualThread)
实现 对操作系统线程的 1:1 包装 JVM 管理,M:N 映射到载体线程
默认栈 约 1MB(-Xss) 按需增长的堆上栈,初始几百字节
创建成本 约 1ms,数量受限(千级) 微秒级,可达百万级
适用场景 CPU 密集型、需要线程局部状态的场景 I/O 密集型、高并发请求处理(典型 Web 服务)
池化建议 必须池化 不要池化,用完即建
注意事项 — synchronized 会 pin 住载体线程(JDK 21 有此问题,JDK 24 已大幅改善,优先用 ReentrantLock);ThreadLocal 内存放大,考虑用 Scoped Values
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.concurrent.*;

public class VirtualThreadQuickLook {
public static void main(String[] args) throws Exception {
// 三种创建方式
Thread v1 = Thread.ofVirtual().name("vt-1").start(() -> System.out.println("方式1"));
Thread v2 = Thread.startVirtualThread(() -> System.out.println("方式2"));
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("方式3")).get();
}
v1.join(); v2.join();

// 对比:创建 10 万个虚拟线程 vs 平台线程
long start = System.currentTimeMillis();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
exec.submit(() -> { Thread.sleep(10); return null; });
}
} // 关闭时等待全部完成
System.out.println("10 万虚拟线程耗时:" + (System.currentTimeMillis() - start) + "ms");
}
}

8.3 结构化并发与 Scoped Values(预览)

  • 结构化并发(Structured Concurrency,JDK 21 预览):把一组并发子任务视为一个工作单元,StructuredTaskScope 保证子任务的生命周期不会超出父任务的作用域,任一子任务失败即可整体取消,解决”线程泄漏 + 取消传播”的老问题。
  • Scoped Values(JDK 21 预览):用于在线程内及子线程间共享不可变数据,是 ThreadLocal 的轻量替代,尤其适合虚拟线程场景(ThreadLocal 在百万级虚拟线程下内存开销惊人)。

由于两者在 JDK 21 仍是预览特性,生产环境请等待转正版本,本文不再展开代码。

8.4 Sequenced Collections(JDK 21)

JDK 21 新增了三个接口,补齐了”有确定遭遇顺序(encounter order)的集合”这一长期缺失的抽象:

接口 新增方法 已有实现
SequencedCollection<E> getFirst()、getLast()、addFirst()、addLast()、removeFirst()、removeLast()、reversed() List、Deque、LinkedHashSet、SortedSet/LinkedHashSet
SequencedSet<E> 继承上述(去重语义) LinkedHashSet、SortedSet
SequencedMap<K,V> firstEntry()、lastEntry()、pollFirstEntry()、pollLastEntry()、putFirst()、putLast()、sequencedKeySet()、reversed() LinkedHashMap、SortedMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.*;

public class SequencedDemo {
public static void main(String[] args) {
// 过去取 LinkedHashSet 的最后一个元素要转成迭代器或 ArrayList,现在一行搞定
SequencedSet<String> set = new LinkedHashSet<>(List.of("a", "b", "c"));
System.out.println(set.getFirst() + " / " + set.getLast()); // a / c
System.out.println(set.reversed()); // [c, b, a]

SequencedMap<String, Integer> map = new LinkedHashMap<>();
map.put("one", 1); map.put("two", 2);
System.out.println(map.firstEntry() + " " + map.lastEntry()); // one=1 two=2
System.out.println(map.pollLastEntry()); // two=2,并移除
System.out.println(map); // {one=1}
}
}

8.5 其它重要演进

  • 分代 ZGC(JDK 21,JEP 439):ZGC 引入年轻代/老年代分代,大幅降低 GC 期间的内存分配开销与 CPU 占用,同时保持亚毫秒级停顿。
  • 字符串模板(String Templates,JDK 21 预览,JEP 430):用 STR." Hello \{name} " 语法做安全的字符串插值,配合 FMT 处理器可做格式化;注意它在 JDK 23 中 API 再次调整,切勿用于生产代码。
  • Vector API(JDK 21 第六次孵化):提供平台无关的 SIMD 向量计算能力,让 Java 能利用 CPU 的 AVX 指令做并行数值计算。
  • UTF-8 默认字符集(JDK 18,JEP 400):FileReader、InputStreamReader 等默认编码终于统一为 UTF-8,跨平台行为一致。

8.6 语法糖版本速查表

语法/API 转正版本 一句话说明
Lambda / 方法引用 Java 8 invokedynamic + LambdaMetafactory
Stream API Java 8 惰性流水线 + Sink 链
接口默认方法/静态方法 Java 8 接口可带实现
var 局部变量推断 Java 10 仅局部变量,编译期推断
集合工厂 List.of Java 9 不可变、不接受 null
Optional.stream/isEmpty/or Java 9/11/9 Optional 增强
HttpClient Java 11 替代 HttpURLConnection
switch 表达式 Java 14 -> 箭头 + yield
文本块 Java 15 """ 三重引号
instanceof 模式匹配 Java 16 判断与绑定一步
Record Java 16 不可变透明载体
Stream.toList() Java 16 返回不可变 List
密封类 Java 17 permits 精确控制继承
Helpful NPE Java 14 精确到变量名的 NPE
switch 模式匹配 Java 21 类型模式 + 守卫 + 记录解构
虚拟线程 Java 21 M:N 用户态线程
Sequenced Collections Java 21 getFirst/getLast/reversed

8.7 升级路线建议

  1. 还在 Java 8:先升到 Java 17,这是当前生态支持最完整的 LTS(Spring Boot 3.x 强制要求 17+)。迁移重点:模块强封装导致的反射报错(--add-opens)、javax.* → jakarta.* 命名空间切换、构建插件版本升级。
  2. 已在 Java 11/17:评估 Java 21,主要收益是虚拟线程与分代 ZGC;风险点主要是 GC 参数与字节码增强工具(Byte Buddy、ASM、CGLIB)的版本兼容。
  3. 语言级别采用顺序:先用 Record 与 switch 表达式(收益最大、风险最小),再上文本块与模式匹配,最后是密封类与预览特性。
  4. 永远在 CI 上跑一遍最新非 LTS 版本的编译与测试,提前半年发现兼容性问题。

8.8 高频面试题十问十答

  1. Lambda 表达式的底层实现是什么? 不是匿名内部类。编译期把 Lambda 体脱糖为一个私有方法(非捕获为 static),在原位置写入 invokedynamic 指令,引导方法是 LambdaMetafactory.metafactory;首次执行时由它用 ASM 在内存中动态生成实现函数式接口的类,并永久链接到调用点。
  2. 为什么 Lambda 捕获的局部变量必须是 effectively final? 局部变量在栈帧中,Lambda 可能在其它线程或稍后执行,JVM 只能复制一份值;若允许修改会造成副本与本体不一致。
  3. Lambda 里的 this 指向谁? 指向外围对象(定义它的那个实例),因为 Lambda 不是类、没有自己的 this。匿名内部类的 this 指向匿名类自身。
  4. Stream 为什么是惰性的? 中间操作只往流水线链表上挂 StatelessOp/StatefulOp 节点,不触发计算;只有终端操作才反向回溯组装 Sink 链,由数据源 Spliterator 推送元素逐个穿过整条链。
  5. Stream 能复用吗? 不能。消费后流即关闭,再次调用抛 IllegalStateException。需要多次遍历就重新创建或用 Supplier<Stream<T>>。
  6. 什么情况下不该用 parallelStream? 数据量小、单元素处理很轻、数据源难拆分(LinkedList、IO 流)、任务阻塞(会污染 ForkJoinPool.commonPool())、依赖顺序、有共享可变状态。
  7. Record 和 Lombok @Data 有什么区别? Record 是语言级特性、编译期生成、不可变、访问器无 get 前缀、不能继承与加实例字段;Lombok 是注解处理器、可变、可继承。DTO/值对象用 Record,JPA 实体用 Lombok。
  8. switch 表达式和旧 switch 语句有什么区别? 箭头语法无需 break(无 fall-through)、可作为表达式直接返回值、多值用逗号、case 块用 yield 返回、配合密封类型可省略 default 且编译器能检查穷举性。
  9. orElse 与 orElseGet 的区别? orElse 的参数总会被求值(急切),orElseGet 的 Supplier 只在值为空时才执行(惰性)。默认值需要计算时必须用 orElseGet。
  10. 密封类的子类必须满足什么约束? 必须在 permits 列表中(或同包/同模块下自动推断),且必须用 final、sealed、non-sealed 之一修饰,否则编译失败;JVM 类加载时还会依据 class 文件中的 PermittedSubclasses 属性二次校验。

本篇覆盖 Java 8~21 的主干特性,但每个主题都还能往深处再走一层:Lambda 的隐藏类(Hidden Class)机制、Stream 的 AbstractPipeline.opWrapSink 源码、Record 的 ObjectMethods.bootstrap 引导逻辑、Fork/Join 的 work-stealing 细节。建议用 javap -v -p 反编译本篇的示例代码,亲眼看看编译器到底生成了什么——这是把”知道”变成”理解”的最短路径。