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)机制:功能已实现但可能变更,编译与运行都需要显式开启。
在 Maven 中可以通过 maven-compiler-plugin 统一配置:
预览特性的字节码带有 minor_version = 65535 标记,JVM 启动时会校验是否带了 --enable-preview。这意味着预览特性编译出的 class 不能分发给生产环境,只能在本地实验。字符串模板(String Templates)在 JDK 21 就是预览状态,JDK 23 中还经历了 API 调整,千万别写进要交付的代码。
二、Lambda 表达式与方法引用
2.1 函数式接口与基本语法
函数式接口(Functional Interface)指的是只有一个抽象方法的接口。Lambda 表达式本质上就是这个函数式接口的实现实例。
Lambda 的语法是 (参数列表) -> { 方法体 }。参数类型通常可以省略(编译器根据目标类型推断);单参数可省括号;方法体只有一句表达式时可省 {} 与 return。
2.2 effectively final 与变量捕获
Lambda 可以捕获外部局部变量,但被捕获的变量必须是 final 或 effectively final(即声明后从未被重新赋值)。这是 Java 为了保证线程安全与实现简单性做出的限制:局部变量存放在栈帧中,而 Lambda 可能在另一个线程或稍后的时刻执行,栈帧早已销毁,因此 JVM 只能复制一份值进入 Lambda 实例。如果允许修改就会出现”副本与本体不一致”的经典 bug。
2.3 方法引用的四种形式
方法引用(Method Reference)是 Lambda 的”语法糖中的语法糖”:当 Lambda 体恰好只是调用一个已存在的方法时,可以用 :: 直接引用。
| 形式 | 语法 | 等价 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 与匿名内部类的本质差异
这是面试高频题,差异可以归纳为四点:
this语义不同。匿名内部类是一个独立的类,其中的this指向匿名类自身;Lambda 不是一个类,它没有自己的this,Lambda 体内的this指向外围对象(即定义它的那个对象)。- 类文件数量不同。匿名内部类编译后会生成
Outer$1.class这样的独立 class 文件;Lambda 不会产生新的 class 文件。 - 字节码指令不同。匿名内部类是
new+invokespecial(Java 7 及以前的标准做法);Lambda 是invokedynamic。 - 捕获语义不同。匿名内部类可以捕获并修改外部变量(Java 允许访问 effectively final 的局部变量,内部类字段可写),Lambda 只能读取 effectively final。
2.5 invokedynamic 与 LambdaMetafactory 的执行链路
早期有一种误解:”Lambda 就是匿名内部类的语法糖”。这是错的。JDK 8 的设计者刻意避开了为每个 Lambda 生成内部类,原因是内部类方案有三个硬伤:类文件膨胀、类加载开销大、未来无法替换实现策略。
真正的实现是 invokedynamic(indy),这条指令原本为动态语言(JRuby、Groovy)在 JVM 上运行而设计(JSR-292,Java 7 引入),Lambda 是它在 Java 语言中的第一个大规模应用。完整链路如下:
编译器在编译期做三件事:
- 把 Lambda 体脱糖(desugar)成一个私有方法,非捕获型编译为
private static方法,捕获型编译为private实例方法(捕获的变量变成参数或字段)。 - 在类文件中写入一条
invokedynamic指令,其引导方法(Bootstrap Method)指向LambdaMetafactory.metafactory,常量池里带上函数式接口的方法名、方法签名、实现方法的 MethodHandle。 - 生成
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 get() | 无入参、有返回值(生产者) | IntSupplier、BooleanSupplier |
Consumer | void accept(T) | 有入参、无返回值(消费者) | BiConsumer、IntConsumer、ObjIntConsumer |
Function | R apply(T) | 一入参、一返回值(转换器) | BiFunction、UnaryOperator、IntFunction、IntToLongFunction |
Predicate | boolean test(T) | 一入参、返回布尔(判定器) | BiPredicate、IntPredicate |
命名规律要记牢:Bi 表示两个入参;Int/Long/Double 前缀表示入参是基本类型(避免装箱);ToInt/ToLong/ToDouble 后缀表示返回值是基本类型;UnaryOperator 是 Function 的特化,BinaryOperator 是 BiFunction 的特化。
3.2 自定义函数式接口与 @FunctionalInterface
@FunctionalInterface 注解不是必需的,但它有两个价值:编译期保护(一旦你加了第二个抽象方法就报错)和自文档化。注意它只约束”抽象方法数量”,默认方法与静态方法不计入。
3.3 函数组合:andThen / compose / negate / and / or
组合是函数式编程的核心武器:把小函数拼成大函数,避免层层嵌套。
3.4 重构实战:把命令式代码改写为函数式
四、Stream API 深入
4.1 Stream 与集合的本质区别
| 维度 | 集合(Collection) | 流(Stream) |
|---|---|---|
| 存储 | 存储全部元素,是内存中的数据结构 | 不存储数据,按需从源头拉取 |
| 求值 | 急切(eager):add 时元素已在 | 惰性(lazy):只有终端操作才触发计算 |
| 遍历次数 | 可无限次遍历 | 一次性,消费后即关闭,再调用抛 IllegalStateException |
| 遍历方式 | 外部迭代(for / iterator,用户控制) | 内部迭代(库控制,可并行、可优化) |
| 数据规模 | 有限 | 可以是无限流(iterate、generate) |
| 关注点 | 数据本身 | 对数据的计算 |
4.2 创建 Stream 的六种方式
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 收集结果。
peek 不执行是最经典的踩坑:很多人写完 stream.peek(System.out::println) 发现控制台什么都没有,就以为 Stream “坏了”。真相是 peek 是中间操作,必须接终端操作。另外在 limit/count 这类短路场景下,JIT 与 Stream 框架可能判定 peek 的结果无人使用而整个跳过,所以 peek 只适合调试,绝不要在里面写业务逻辑(count() 在 JDK 9+ 还会直接跳过中间操作优化)。
4.5 常用操作与 Collectors 全家桶
4.5.1 自定义 Collector
Collector 有五个要素:supplier(创建可变容器)、accumulator(累加元素)、combiner(并行时合并容器)、finisher(容器转结果)、characteristics(特性标志,影响框架能否优化)。
4.6 flatMap 的扁平化思维
map 是”一对一的盒子套盒子”,flatMap 是”把盒子拆开摊平”。判断标准:如果 map 之后得到的是 Stream> 或 Stream>,就该用 flatMap。
4.7 Stream 的坑与避坑指南
4.8 parallelStream 的拆分策略与 Spliterator
并行流的底层是 Fork/Join 框架(本系列第(十三)篇讲过线程池,这里只聚焦拆分逻辑):
- 数据源被包装成一个 Spliterator(可拆分迭代器),它除了
tryAdvance(遍历)还有trySplit(把自己一分为二)。 - 框架递归调用
trySplit,直到子任务小到不值得再拆(阈值约 1024 个元素),形成一棵任务树。 - 每个叶子任务在 ForkJoinPool 的工作线程上执行,通过 work-stealing 调度。
- 每个子任务独立执行完整的流水线(各自的 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 的设计初衷是作为方法返回值,明确告诉调用者”这里可能没有值”。它不应该用作类的字段(Serializable 都不支持)、方法参数(会造成调用方必须包装的负担)或集合元素(用空集合表示无元素即可,别用 List>)。
5.2 三大取值方法:orElse / orElseGet / 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> |
filter | 8 | 条件过滤 | 不满足则变 empty |
stream | 9 | 转 Stream | 空则为空流,可无缝接 Stream 链 |
or | 9 | 链式兜底 | a.or(() -> b) |
5.4 与持久层查询的配合
Optional 误用清单:① 用Optional作为字段或参数;②isPresent()+get()的老式写法;③ 在orElse里放高开销计算;④ 集合查询返回Optional>(应返回空集合);⑤ 用Optional.of(可能为 null 的值)(应用ofNullable);⑥ 在 Lambda 里创建Optional只为调用get();⑦ 把Optional当null一样return null回去(比不用 Optional 还糟)。
六、JDK 9-11:工程实用性的飞跃
6.1 集合工厂方法与不可变集合
6.2 Stream 增强与 String 新方法
6.3 var 局部变量类型推断的使用边界
var 是 Java 10 引入的,只用于局部变量,编译期推断出确切类型后写入字节码,Java 依然是强类型语言,不存在运行期动态类型。
使用建议:当类型在右侧已经明确写出(new ArrayList())或类型名极长(Map>)时用 var 能显著提升可读性;当初始化表达式类型不明显(var result = service.query())时不要用 var,那会让读代码的人必须跳到方法签名才知道类型。
6.4 HttpClient:JDK 11 正式版
java.net.http.HttpClient 取代了老旧的 HttpURLConnection,支持 HTTP/2、WebSocket、同步/异步、响应式 Body 处理。
6.5 单文件源码运行与 JVM 改进
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 表达式用 -> 箭头语法彻底解决。
7.2 文本块 Text Blocks(JDK 15 转正)
文本块用三重双引号包裹,解决 SQL/JSON/HTML 拼接的地狱。编译期会做两件事:去掉每行末尾的 incidental 空白(按最小缩进对齐)和规范化换行符。
7.3 instanceof 模式匹配(JDK 16 转正)
7.4 Record 记录类(JDK 16 转正)
Record 是不可变透明载体(transparent carrier):你只声明组件(components),编译器自动生成私有 final 字段、公共访问器(name() 而非 getName())、规范构造器、equals/hashCode/toString。
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)。
编译器与 JVM 的 permits 校验规则(硬核点):
permits列出的类必须存在且可访问,必须直接继承/实现该密封类。- 每个被允许的子类必须用
final、sealed、non-sealed三者之一修饰,没有第四个选项。这是强制的:否则无限继承链又回来了。 - 密封类与其子类必须在同一个包(使用无名模块时)或同一个模块(使用 JPMS 时)。
- 校验分两层:
javac在编译期检查;class 文件里写入PermittedSubclasses属性,JVM 在类加载时校验——即使你手工改字节码绕过 javac,JVM 也会拒绝加载非法子类。 - 反射层面新增
Class.isSealed()与Class.getPermittedSubclasses()。
7.6 Helpful NullPointerException
JDK 14 起(JEP 358),JVM 可以打印出精确到变量名的 NPE 信息,默认开启(也可显式加 -XX:+ShowCodeDetailsInExceptionMessages)。
这条信息直接告诉你是哪个方法调用的返回值为 null,在生产日志里价值极高——再也不用靠”第 108 行”猜了。
八、JDK 18-21:模式匹配的终局与并发新纪元
8.1 switch 模式匹配(JDK 21 转正)
switch 终于可以按类型分支,配合 when 守卫与 Record 解构,Java 具备了完整的模式匹配能力。
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 |
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 | getFirst()、getLast()、addFirst()、addLast()、removeFirst()、removeLast()、reversed() | List、Deque、LinkedHashSet、SortedSet/LinkedHashSet |
SequencedSet | 继承上述(去重语义) | LinkedHashSet、SortedSet |
SequencedMap | firstEntry()、lastEntry()、pollFirstEntry()、pollLastEntry()、putFirst()、putLast()、sequencedKeySet()、reversed() | LinkedHashMap、SortedMap |
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 升级路线建议
- 还在 Java 8:先升到 Java 17,这是当前生态支持最完整的 LTS(Spring Boot 3.x 强制要求 17+)。迁移重点:模块强封装导致的反射报错(
--add-opens)、javax.*→jakarta.*命名空间切换、构建插件版本升级。 - 已在 Java 11/17:评估 Java 21,主要收益是虚拟线程与分代 ZGC;风险点主要是 GC 参数与字节码增强工具(Byte Buddy、ASM、CGLIB)的版本兼容。
- 语言级别采用顺序:先用 Record 与 switch 表达式(收益最大、风险最小),再上文本块与模式匹配,最后是密封类与预览特性。
- 永远在 CI 上跑一遍最新非 LTS 版本的编译与测试,提前半年发现兼容性问题。
8.8 高频面试题十问十答
- Lambda 表达式的底层实现是什么? 不是匿名内部类。编译期把 Lambda 体脱糖为一个私有方法(非捕获为 static),在原位置写入
invokedynamic指令,引导方法是LambdaMetafactory.metafactory;首次执行时由它用 ASM 在内存中动态生成实现函数式接口的类,并永久链接到调用点。 - 为什么 Lambda 捕获的局部变量必须是 effectively final? 局部变量在栈帧中,Lambda 可能在其它线程或稍后执行,JVM 只能复制一份值;若允许修改会造成副本与本体不一致。
- Lambda 里的
this指向谁? 指向外围对象(定义它的那个实例),因为 Lambda 不是类、没有自己的this。匿名内部类的this指向匿名类自身。 - Stream 为什么是惰性的? 中间操作只往流水线链表上挂
StatelessOp/StatefulOp节点,不触发计算;只有终端操作才反向回溯组装Sink链,由数据源 Spliterator 推送元素逐个穿过整条链。 - Stream 能复用吗? 不能。消费后流即关闭,再次调用抛
IllegalStateException。需要多次遍历就重新创建或用Supplier>。 - 什么情况下不该用 parallelStream? 数据量小、单元素处理很轻、数据源难拆分(LinkedList、IO 流)、任务阻塞(会污染
ForkJoinPool.commonPool())、依赖顺序、有共享可变状态。 - Record 和 Lombok
@Data有什么区别? Record 是语言级特性、编译期生成、不可变、访问器无 get 前缀、不能继承与加实例字段;Lombok 是注解处理器、可变、可继承。DTO/值对象用 Record,JPA 实体用 Lombok。 - switch 表达式和旧 switch 语句有什么区别? 箭头语法无需
break(无 fall-through)、可作为表达式直接返回值、多值用逗号、case 块用yield返回、配合密封类型可省略default且编译器能检查穷举性。 orElse与orElseGet的区别?orElse的参数总会被求值(急切),orElseGet的 Supplier 只在值为空时才执行(惰性)。默认值需要计算时必须用orElseGet。- 密封类的子类必须满足什么约束? 必须在
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 反编译本篇的示例代码,亲眼看看编译器到底生成了什么——这是把”知道”变成”理解”的最短路径。