invokedynamic · LambdaMetafactory · 隐藏类 · 函数式接口 · Stream 流水线 · Sink · 惰性求值 · 短路 · 有状态操作 · ForkJoinPool · record · ObjectMethods · 紧凑构造器 · sealed · typeSwitch · 穷举检查
Lambda、Stream、record、模式匹配:编译后变成了什么
这四样新语法有一个共同点:javac 只在字节码里留一条 invokedynamic,真正的实现等到运行时第一次执行那一行,才由 JDK 里的引导方法现场生成。Lambda 由 LambdaMetafactory 生成一个隐藏类;record 的 equals、hashCode、toString 由 ObjectMethods 生成;模式匹配的 switch 由 SwitchBootstraps 生成类型分派。Stream 不靠 indy,它的关键是“先搭流水线,终端操作一来,每个元素从头走到尾,再轮到下一个”。懂了这两条,就能推出它们各自的快慢和坑。
全景:一个订单对象的一生 · 第 2、5、6 站地图:J01 · JDK 8 到 25 的新特性(下一圈)上一圈:JDK 8、17、21、25(版本、升级)实验:JDK 17.0.20;模式匹配用 JDK 21.0.12 实测
①为什么需要:这一圈换个问法
在全景第 6 站,这一步要解决的是:算价那两行 lines.stream().map(OrderLine::amount).reduce(…) 和 switch (d) { case Minus(var off) -> … },到底变成了什么在执行,快不快、哪里会出事。第 2 站 Jackson 能把 JSON 变成 record、第 5 站订单行用 record,也是同一个问题。
上一圈学了什么JDK 8、17、21、25
按版本看:8 到 25 每个版本加了什么、为什么半年发一版、四条主线、老项目怎么升级、升级会在哪出事。回答的是“有什么、什么时候有的”。
这一圈学什么地图要求:懂机制
按机制看:Lambda、Stream、record、模式匹配编译后是什么、运行时怎么执行。回答的是“为什么能这样写、代价在哪、坑从哪来”。
②没有它会怎样:JDK 7 的写法,一句话回顾
| 今天写 | JDK 7 时写 | 疼在哪 |
|---|---|---|
l -> l.amount() | 匿名内部类 new Function<…>() { public … apply(…) {…} } | 5 行样板;每个都编译出一个 Demo$1.class 文件;容易意外捕获外部的 this |
lines.stream().filter().map().toList() | for 循环 + 临时 List | “做什么”淹没在“怎么循环”里;想并行要自己切分 |
record OrderLine(…) | final 类 + 字段 + 构造器 + getter + equals / hashCode / toString | 五六十行,或者靠 Lombok;改一个字段要改五个地方,漏改 equals 不报错 |
switch (d) { case Minus(var off) -> … } | if (d instanceof Minus) { Minus m = (Minus) d; … } | 每种都要强转;漏了一种不报错,悄悄算错价 |
不懂机制时的典型误用:觉得“Stream 就是更优雅的 for 循环”,在热点路径上对 1000 万个 Long 做 reduce,比 for 循环慢 40 多倍(⑤ 实测);或者随手 parallelStream(),结果占满了全服务共用的线程池(⑥ 坑 2)。
③相邻方案:它们分别替代了什么
| 新特性 | 替代谁 | 还在用旧办法的时候 |
|---|---|---|
| Lambda / 方法引用 | 匿名内部类 | 需要多个方法、需要自己的状态字段时,还是写类 |
| Stream | for 循环 + 临时集合 | 热点路径、要提前 break 带复杂条件、要改外部变量时,for 循环更直接 |
| record | 手写值对象 / Lombok @Value | 需要继承、可变字段、JPA 实体(要无参构造器和 setter)时,用普通类 |
| sealed + 模式匹配 | instanceof 链、访问者模式、在类里写多态方法 | 类型族要对外开放扩展(插件)时,用普通接口 + 多态 |
本页学四样的底层机制。选择规则:值对象一律 record;集合变换用 Stream,热点循环和基本类型大批量运算用 for 或 IntStream / LongStream;封闭的类型族用 sealed + switch,开放的用多态。
④原理:一条 invokedynamic,和一条流水线
- 字节码和 javap:
javap -c -p能看到方法调用指令,见面向对象那页④-2(invokevirtual / invokeinterface)。 - 函数式接口:只有一个抽象方法的接口,比如
Supplier、Function、Runnable。Lambda 只能赋给它。 - MethodHandle:可以把它理解成“一个指向方法的、能被 JIT 内联的指针”,比反射的 Method 轻得多。
④-1invokedynamic:字节码里只写“到时候问引导方法”
javap -v:Demo 里的 Lambda 指向 LambdaMetafactory.metafactory,record OrderLine 的 equals、hashCode、toString 都是一条 invokedynamic,引导方法是 ObjectMethods.bootstrap;JDK 21 编译的模式匹配 switch 是 invokedynamic typeSwitch。④-2Lambda:一个私有静态方法,加一个运行时生成的隐藏类
LambdaJDK 8 起
- 方法体被 javac 搬进本类的
private static lambda$main$0(捕获了 this 就是实例方法) - 运行时第一次执行时,LambdaMetafactory 生成一个实现 Supplier 的隐藏类,实测 类名
Demo$$Lambda$1/0x…,isHidden=true,不落成 class 文件 - 没捕获变量:每次求值返回同一个对象(实测
==为 true) - 捕获了变量:每次 new 一个,把捕获的值存进字段(实测 false)
- 捕获的局部变量必须是 effectively final:存的是值的拷贝,允许改就会和外面对不上
匿名内部类JDK 1.1 起
- javac 生成一个真实的
Demo$1.class文件,一个匿名类一个文件 - 每次求值都 new 一个对象
- 总是隐式拉着外部类的 this(哪怕没用到),容易造成内存泄漏
this指匿名类自己,不是外部类,容易写错- 能有自己的字段和多个方法
④-3Stream:先搭流水线,终端操作一来,元素一个一个从头走到尾
- 1中间操作只是在搭链:
filter、map、peek每调一次,只是在后面接一个新的流水线阶段(源码里的AbstractPipeline),什么都不执行。实测 搭好以后,peek 里一行都没打印。 - 2终端操作才开工:
findFirst、toList、reduce一调用,流水线从尾往头把每个阶段包成一个Sink(一个 Consumer),串成一条链,然后从数据源一个个推元素进去。 - 3一个元素先走完整条链:66 过 filter 被拦下,88 过 filter、过 map、到 findFirst,拿到了就叫停,99、12、7 根本不碰(短路)。不是“先全部 filter,再全部 map”。
- 4有状态的操作会打断这种流动:
sorted、distinct要看完所有元素才能决定第一个输出,于是在那里“蓄水”,前面的阶段要把全部元素都处理完。实测 加了 sorted 后,5 个元素全被 filter 看过。
订单行按顺序是 66(19.90)、88(299.00)、99(129.00)、12(9.90)、7(599.00),filter 留下单价大于 100 的,sorted 按单价从高到低。模拟和 ⑤ 的 JDK 17 实测输出一致,逻辑在 node 里跑过。
④-4record:编译器生成的类,和它的边界
- 1
record OrderLine(long skuId, String name, BigDecimal price, int qty)编译成一个final class OrderLine extends java.lang.Record:四个private final字段、一个按组件顺序的规范构造器、四个同名访问方法skuId()…… - 2equals、hashCode、toString 不是写死的字节码,而是一条 indy,引导方法
ObjectMethods.bootstrap按组件列表生成(④-1)。所以“改了组件,三件套自动跟着变”。 - 3紧凑构造器
OrderLine { if (qty <= 0) throw … }:只写校验和规范化,参数到字段的赋值由编译器补在最后。 - 4class 文件里多了
Record属性,列出组件名和类型。反射getRecordComponents()读的就是它,Jackson 靠它把 JSON 的键对上规范构造器的参数(全景第 2 站)。 - 5只是浅不可变:字段是 final,可字段指向的 List 自己能变。实测 外面往原列表加一行,
Cart里跟着变成 3 行;在紧凑构造器里List.copyOf的SafeCart还是 2 行。
④-5模式匹配的 switch:按类型分派,再由编译器检查穷举
Coupon,javac 报 the switch expression does not cover all possible input values;javap -c 里能看到 invokedynamic typeSwitch 和 new java/lang/MatchException。JDK 17 编译同一段代码报 “patterns in switch statements are a preview feature”,所以这一段只能用 21 跑。最少语法
Supplier<String> s = () -> "x"; Function<OrderLine, BigDecimal> f = OrderLine::amount;
BigDecimal sum = lines.stream().map(OrderLine::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
long cnt = LongStream.range(0, n).filter(i -> i % 2 == 0).count(); // 基本类型流,不装箱
Map<Long, List<Order>> byUser = orders.stream().collect(Collectors.groupingBy(Order::userId));
record OrderLine(long skuId, BigDecimal price, int qty) { OrderLine { if (qty <= 0) throw …; } }
if (d instanceof Minus m) { … m.off() … } // JDK 16
return switch (d) { case Minus(var off) -> …; case Percent p -> …; case None n -> sum; }; // JDK 21
⑤亲眼看到:交易靶场的订单行,在 JDK 17 上跑一遍
Demo(JDK 17)5 段:Lambda 的类和对象、Stream 的执行顺序、parallelStream 的线程、装箱的代价、record 生成了什么。模式匹配的 PatternSwitch 单独用 JDK 21 编译运行。完整代码:study-labs/java/javase/lambda-stream-record →
static Supplier<String> nonCapturing() { return () -> "无捕获"; }
static Supplier<String> capturing(String u) { return () -> "捕获了 " + u; }
nonCapturing() == nonCapturing();
capturing("1024") == capturing("1024");
// 66、88、99、12、7,单价 19.90、299、129、9.90、599
Stream<OrderLine> s = lines.stream()
.peek(l -> println("filter 看到 " + l.skuId()))
.filter(l -> l.price() > 100)
.peek(l -> println("map 看到 " + l.skuId()));
// 这里还什么都没打印
s.map(OrderLine::skuId).findFirst();
s.count(); // 再用一次
LongStream.range(0, 200_000).parallel()
.forEach(i -> threads.add(currentThread().getName()));
// 1000 万个数求和三种写法
for (long i = 0; i < n; i++) t += i;
LongStream.range(0, n).sum();
Stream.iterate(0L, i -> i + 1).limit(n).reduce(0L, Long::sum);
record Cart(long userId, List<OrderLine> lines) {}
record SafeCart(long userId, List<OrderLine> lines) {
SafeCart { lines = List.copyOf(lines); }
}
== 1. Lambda == lambda 的类:Demo$$Lambda$1/0x0000008001000a08,isHidden=true 匿名内部类:Demo$1,isHidden=false 第一次求值 7.57 ms,下一处 0.395 ms 无捕获:同一个对象 true;捕获了变量:false == 2. Stream == 流水线搭好了,还没有终端操作:上面一行都没打印 filter 看到 66 filter 看到 88 map 看到 88 findFirst → 88 再用一次 → IllegalStateException 加 sorted 后:filter 看到 66、88、99、12、7, map 看到 7 → 7 == 3. parallelStream == CPU 核数 2,干活的线程: [ForkJoinPool.commonPool-worker-1, main] == 4. 1000 万个数求和(5 次取最快)== for 循环 long 3.6 ms LongStream.sum 17.9 ms Stream<Long>.reduce 160.9 ms == 5. record == OrderLine[skuId=88, name=商品88, price=299.00, qty=2] a.equals(b)=true,hashCode 相等=true,a == b:false 组件:[skuId, name, price, qty] 紧凑构造器校验:qty 必须大于 0:0 外面加一行:Cart 变 3 行,SafeCart 还是 2 行 $ javac --release 21 PatternSwitch.java && java PatternSwitch # JDK 21.0.12 None[] → 598.00 Minus[off=20] → 578.00 Minus[off=999] → 0 Percent[pct=10] → 538.20 switch(null),没有 case null → NullPointerException
第 1 段的耗时、第 4 段的数字每次运行会变,只看量级。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 在
map或peek里往外部 List 加东西、记日志、扣库存,结果有时没执行(短路了,或者 JDK 9 起count()能直接算出大小就跳过整条流水线),并行时还会丢数据。 - 根源
- 中间操作执不执行、对哪些元素执行,由终端操作决定;Stream 不保证副作用。
- 做法
- 中间操作只做无副作用的变换;要收集结果用
collect/toList;要做事用终端的forEach。
- 现象
- 某个接口用
parallelStream()调了个慢的远程服务,结果其他所有用到并行流、CompletableFuture默认池的地方一起变慢。 - 根源
- 并行流默认跑在
ForkJoinPool.commonPool(),全 JVM 共用一个,线程数是 CPU 核数减一,调用线程自己也干活。实测 2 核机器上只有 worker-1 和 main。阻塞在 IO 上就把这几个线程全占住了。 - 做法
- 并行流只用于纯 CPU 计算、数据量大(几万以上)、没有 IO;有 IO 的并发用自己配的线程池或虚拟线程(J02)。
- 现象
- 1000 万个数求和:for 循环 3.6 ms,
LongStream17.9 ms,Stream<Long>.reduce160.9 ms。⑤ 第 4 段 - 根源
- 泛型流的元素是对象,每个数都要装箱成 Long、拆箱、产生垃圾;还要穿过一层层 Sink。
- 做法
- 数字用
mapToLong/LongStream;真正的热点循环用 for。业务里几十几百条订单行用 Stream 完全没问题,可读性更重要。
- 现象
- Hibernate 要无参构造器和可写字段、要做懒加载代理(生成子类),record 都给不了,启动报错;或者 record 里放了可变 List,被外面改了。
- 根源
- record 是 final 类、字段 final、只有规范构造器;不可变只到字段这一层。
- 做法
- 实体用普通类,DTO、命令、值对象、投影查询结果用 record;集合组件在紧凑构造器里
List.copyOf。
- 现象
apply(null, sum)抛 NullPointerException。⑤ 实测 为了“保险”加了default -> sum,后来新增 Coupon 没处理,编译器不再提醒,悄悄不打折。- 根源
- 为了兼容老 switch 的行为,没写
case null时遇到 null 就抛 NPE;default 能匹配一切,编译器自然认为已经穷举了。 - 做法
- sealed 类型族的 switch 不写 default;可能为 null 就显式写
case null ->或在入口校验。
- 现象
- 注册了一个回调 Lambda 存进全局监听器列表,里面用到了某个大对象的一个字段,结果整个大对象(甚至整个外部实例)都回收不了。
- 根源
- Lambda 捕获的是引用,存进生成类的字段里;用到实例字段等于捕获了 this。
- 做法
- 先把要用的值取到局部变量里再捕获;监听器记得注销。
使用规范
超过四五个操作、或者有分支判断,就拆成命名良好的方法,或者回到 for 循环。
读不懂、断点打不进去的“一行流”。
不要把 Stream 存进字段或作为返回值反复用;要多次用就返回 List 或 Supplier<Stream>。
IllegalStateException(⑤ 实测)。
OrderLine::amount 比 l -> l.amount() 短,生成的东西一样。
样板代码。
命令、DTO、事件、复合 key。需要校验写紧凑构造器,集合字段 List.copyOf。
漏改 equals、被外部修改。
优惠、支付方式、订单事件。让编译器在新增类型时逼你处理每一处。
坑 5。
深处
Stream 源码里的那条 Sink 链
每个中间操作都是 AbstractPipeline 的一个子类实例,链表一样指向上游。终端操作调用 wrapSink,从最后一个阶段往前,把每个阶段包成 Sink:filter 的 Sink 在 accept 里判断一下再交给下游,map 的 Sink 变换后交给下游。然后数据源的 Spliterator 逐个 tryAdvance,把元素推进第一个 Sink。
短路靠 Sink.cancellationRequested():findFirst 拿到元素后返回 true,源头就停止推送。sorted 的 Sink 在 accept 里只是攒进列表,在 end() 时才排序并把元素往下推,这就是“蓄水”。(按 JDK 17 本机源码 java.util.stream)
为什么第一次执行 Lambda 要几毫秒
第一次遇到 indy,JVM 要调 LambdaMetafactory:解析方法类型、用 ASM 风格的字节码生成器拼出一个隐藏类、定义它、链接。整个 JVM 里第一个 Lambda 还要初始化 java.lang.invoke 那一整套基础设施,所以最慢(实测 7.6 ms)。这也是 Spring Boot 启动时间的一小部分来源;JDK 的 CDS / AOT 缓存能把这些生成好的类存下来,下次启动直接用(见上一圈 JDK 页的“下一圈”)。
record 的 equals 为什么不写死
写死的话,每个 record 的 class 文件里都有一份逐字段比较的字节码。用 indy,所有 record 共用 ObjectMethods 里的一份实现,它按组件列表组合 MethodHandle,JIT 照样能内联。JDK 以后换更快的实现(比如对基本类型字段特化),已编译的 record 自动受益,这是 ④-1 那个设计理由的又一个例子。
版本线(只列机制相关)
| JDK 7 | invokedynamic 指令加入 JVM(当时给 JRuby 这类动态语言用) |
|---|---|
| JDK 8 | Lambda、方法引用、Stream、函数式接口 |
| JDK 9 | 字符串拼接改用 indy(StringConcatFactory);Stream 加 takeWhile、dropWhile |
| JDK 15 | Lambda 改用隐藏类生成(之前是 Unsafe 定义的匿名类) |
| JDK 16 | record、instanceof 模式匹配转正;Stream.toList() |
| JDK 17 | sealed 转正 |
| JDK 21 | switch 模式匹配、record 模式转正;Sequenced 集合 |
| JDK 22 | 未命名变量 _ 转正:case Minus(_) -> |
JDK 15 和 22 两行按 JEP 371、JEP 456 写,没有在本机核对。
Lambda 和匿名内部类有什么区别?
匿名内部类编译期生成真实的 class 文件、每次 new 对象、隐式持有外部 this;Lambda 的方法体编进本类的私有静态方法,运行时由 invokedynamic + LambdaMetafactory 生成隐藏类,不捕获变量时复用同一个实例,只在用到时才捕获 this(④-2)。
Stream 是怎么执行的?中间操作和终端操作的区别?
中间操作只搭流水线,惰性;终端操作把各阶段包成 Sink 链,元素逐个从源头推到底;有短路操作时提前停止;sorted、distinct 这类有状态操作要先收齐(④-3、⑥-3)。
parallelStream 有什么风险?
默认用全 JVM 共享的 ForkJoinPool.commonPool,线程数是核数减一;里面做 IO 或耗时操作会拖慢所有用这个池的代码;数据量小、操作轻时,并行的拆分合并开销反而更大(坑 2)。
record 和 Lombok @Data 有什么区别?
record 是语言级的不可变值类型:final、字段 final、规范构造器、equals 等由 JDK 在运行时生成,反射能识别(isRecord、组件);@Data 是编译期往普通类里插可变的 getter / setter,类型系统不知道它是“值”(④-4)。
switch 模式匹配为什么要配合 sealed?
sealed 的 permits 让编译器知道全部子类型,不写 default 时能检查是否穷举,新增类型时所有 switch 都编译失败、逼你处理(④-5,坑 5)。
- Collector 的四个函数(supplier、accumulator、combiner、finisher),自己写一个 Collector
- Spliterator 和并行拆分:为什么 ArrayList 并行比 LinkedList 好拆
- MethodHandle 和 invokedynamic 的完整机制,以及它和反射的性能差(接 J01“反射、注解与动态代理”)
- 上一圈 JDK 页列过的:结构化并发、AOT 缓存、Valhalla 值类型
↺回到⑤:改造你的案例
1. 看见 Sink 链
在⑤第 2 段的 findFirst() 那行打断点,单步进去,找到 AbstractPipeline.wrapAndCopyInto 和 copyIntoWithCancel,看 Sink 是怎么一层套一层的。
用到:⑥-3 Sink 链
2. 给 parallelStream 换个池
把⑤第 3 段放进 new ForkJoinPool(4).submit(() -> …).get() 里执行,看线程名变成什么;再想想为什么这招不被推荐当常规做法。
用到:坑 2
3. 让编译器替你找漏
在 PatternSwitch 里给 permits 加一个 Coupon,看报错;再加一个 default,看报错消失,体会坑 5。
用到:④-5(JDK 21)
自测
1. 用一句话说出这四样的共同点。
Lambda、record、模式匹配 switch 都编译成一条 invokedynamic,实现由 JDK 的引导方法在第一次执行时生成;Stream 是惰性搭链、终端操作时逐元素推过 Sink 链。
2. JDK 7 写同样的逻辑,最疼在哪?
匿名内部类、手写值对象、instanceof 加强转的样板太多,漏改 equals、漏处理一种类型都不报错(②)。
3. 在 map 里扣库存,有时没扣,根源是哪条原理?
中间操作是否执行由终端操作决定:短路时后面的元素不会进来,某些终端操作能直接算出结果就整条跳过(④-3,坑 1)。
4. 预测:⑤第 2 段如果把 findFirst 换成 anyMatch(l -> true),peek 会打印几行?
anyMatch 也是短路操作,拿到第一个通过 filter 的就停:filter 看到 66、filter 看到 88、map 看到 88,三行,和 findFirst 一样。
5. 预测:把⑤第 2 段的 sorted 换成 limit(1),filter 看到几个元素?
limit 是短路的有状态操作,但不用看完全部:88 通过 filter 后 limit 拿够 1 个就叫停,所以 filter 只看到 66 和 88 两个(④-3 第 3、4 条的区别:limit 不蓄水)。
6. 放回全景:第 6 站前后各发生了什么?这一页讲的东西用错了,整条链路会怎样?
前面是第 5 站 new 出 Order,后面是第 7 站扣库存。如果算价用 parallelStream,订单行只有一两行,拆分合并的开销反而更大,而且占用公共池,高峰时拖慢所有用到它的请求;如果在 map 里顺手扣库存,短路或跳过时就漏扣,第 7 站的条件更新也救不回来;如果优惠 switch 加了 default,新上线的优惠券类型悄悄不打折,第 10 站返回的金额就错了。第 2 站的 PlaceOrderCmd 若不是 record 而是普通类,Jackson 就得靠无参构造器和 setter,对象就成了可变的。