同事说"Lambda 就是匿名内部类的语法糖"
三月底做代码评审,我提了个建议说"这里可以用 Lambda 简化"。同事回了句:"Lambda 不就是匿名内部类的语法糖吗,性能还差一点,没必要改。"
我说不上来哪里不对,但记得在哪看过说 JDK 8 的 Lambda 底层用的是 invokedynamic,不是匿名内部类。为了不继续丢人,我回去把字节码翻了一遍。
反编译看看
写两个功能完全一样的类:
// A:匿名内部类
public class AnonymousDemo {
public static void main(String[] args) {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
};
r.run();
}
}
// B:Lambda
public class LambdaDemo {
public static void main(String[] args) {
Runnable r = () -> System.out.println("hello");
r.run();
}
}
编译之后先看产物文件:
$ javac AnonymousDemo.java LambdaDemo.java
$ ls *.class
AnonymousDemo$1.class ← 匿名内部类编译期就生成了独立的 class 文件
AnonymousDemo.class
LambdaDemo.class ← 没有 LambdaDemo$1.class
第一处差别就出现了。再看 LambdaDemo 的字节码:
$ javap -p -v LambdaDemo
public class LambdaDemo
...
{
public static void main(java.lang.String[]);
Code:
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
private static void lambda$main$0();
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hello
5: invokevirtual #6 // Method java/io/PrintStream.println:(...)V
8: return
BootstrapMethods:
0: #27 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;
Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;
Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)
Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 REF_invokeStatic LambdaDemo.lambda$main$0:()V
#30 ()V
}
三点结论:
- Lambda 的方法体被编译成了所在类的一个私有静态方法
lambda$main$0(如果是实例方法则是私有实例方法) main方法里出现的是invokedynamic指令,不是newBootstrapMethods里记录了引导方法LambdaMetafactory.metafactory以及三个参数:接口方法签名、指向lambda$main$0的句柄、实际的方法签名
所以"匿名内部类的语法糖"这个说法是不准确的。匿名内部类在编译期就确定了类结构,Lambda 把"生成什么类"这个决定推迟到了运行时。
invokedynamic 到底做了什么
invokedynamic 是 JDK 7 引入的字节码指令,最初是为了在 JVM 上跑 Ruby、Groovy 这类动态语言。它的思路是:这条指令第一次执行的时候,调用一个"引导方法"(Bootstrap Method)来决定它到底要干什么,之后把这个决定缓存下来,后续执行直接用。
用在 Lambda 上,引导方法就是 LambdaMetafactory.metafactory,它在运行时用 ASM 生成一个实现了目标接口的类,这个类的方法体就是调用 lambda$main$0。生成完之后用 Unsafe.defineAnonymousClass(JDK 8 的做法)加载,并返回一个 CallSite 绑定到这条 invokedynamic 指令上。
JDK 8 有个参数可以把这些运行时生成的类 dump 出来:
$ java -Djdk.internal.lambda.dumpProxyClasses=/tmp/lambda LambdaDemo
$ ls /tmp/lambda
LambdaDemo$$Lambda$1.class LambdaDemo$$Lambda$2.class
反编译其中一个,结构非常朴素:
final class LambdaDemo$$Lambda$1 implements Runnable {
private LambdaDemo$$Lambda$1() { }
public void run() {
LambdaDemo.lambda$main$0(); // 直接调用编译期生成的静态方法
}
}
这个设计的实际好处有两个:一是生成的类不落盘、不进 classpath,类加载的开销小;二是 JVM 有机会把 invokedynamic 和后面的调用一起做 JIT 内联优化。
性能实测
我跑了个循环调用 1000 万次的对比(JMH,预热后测量):
| 写法 | 平均单次调用 | 首次调用(含链接) |
|---|---|---|
| Lambda | 2.3 ns | 约 11 ms |
| 匿名内部类 | 2.5 ns | 0.9 ms |
稳态下两者没有实质差别,所谓"Lambda 性能差"主要指第一次执行要跑引导方法(生成类、定义类),一次约 11 毫秒。这个开销在启动阶段,对长期运行的服务端程序基本无所谓,但如果写在冷启动很敏感的路径上要留意。
变量捕获为什么要求 effectively final
这个限制我一开始觉得莫名其妙,直到想清楚 Lambda 捕获的实现方式。
public void demo() {
int count = 0;
Runnable r = () -> System.out.println(count); // 编译通过
count++; // 加上这句就报错
r.run();
}
error: local variables referenced from a lambda expression
must be final or effectively final
原因和匿名内部类一致:局部变量存在栈帧里,而 Lambda 生成的对象可能在另一个线程、另一个时间点执行,那时候栈帧早就没了。所以 JVM 做的是复制一份值,存进生成类的字段里。
看捕获了变量的 Lambda 生成的类就明白了:
final class LambdaDemo$$Lambda$1 implements Runnable {
private final int arg$1; // 捕获的变量变成了实例字段
private LambdaDemo$$Lambda$1(int var1) {
this.arg$1 = var1;
}
public void run() {
LambdaDemo.lambda$main$0(this.arg$1);
}
}
如果允许在 Lambda 外面修改 count,外面改的是栈上的原变量,Lambda 里看到的是自己的副本,两边不一致,比禁止修改更让人困惑。所以干脆要求它事实上的 final(effectively final),也就是"声明之后没有再赋值"。
注意是"没有再赋值",不是"必须用 final 修饰"。JDK 8 之后只要不改就行,不用显式写 final。而且数组和对象的引用不能改,但内容可以改:
int[] counter = {0};
Runnable r = () -> counter[0]++; // 合法,改的是数组内容不是引用
这个技巧我不推荐用,多线程下 counter[0]++ 本身也不是原子的。
一个容易踩的坑:this 的指向变了
在匿名内部类里,this 指向内部类实例自己;在 Lambda 里,this 指向的是外层对象,因为 Lambda 编译出来就是外层类的一个方法。
public class ThisDemo {
private String name = "outer";
public void anonymous() {
Runnable r = new Runnable() {
private String name = "inner";
@Override
public void run() {
System.out.println(this.name); // 输出 inner
}
};
r.run();
}
public void lambda() {
Runnable r = () -> System.out.println(this.name); // 输出 outer
r.run();
}
}
把匿名内部类改成 Lambda 时,如果代码里用到了 this,行为会变。我们项目改造时踩过一次,一个回调里用 this 引用了内部类的字段,改成 Lambda 之后空指针。
常用函数式接口和方法引用
java.util.function 包里 40 多个接口,日常用的就这几个:
| 接口 | 方法 | 用途 |
|---|---|---|
Predicate<T> | boolean test(T) | 判断,filter 用 |
Function<T,R> | R apply(T) | 转换,map 用 |
Consumer<T> | void accept(T) | 消费,forEach 用 |
Supplier<T> | T get() | 提供,延迟创建 |
UnaryOperator<T> | T apply(T) | 同类型转换,继承 Function |
BiFunction<T,U,R> | R apply(T,U) | 两个入参,reduce 用 |
方法引用有四种形式,本质是"把已有方法包装成函数式接口的实例":
// 1. 静态方法
Function<String, Long> f1 = Long::valueOf;
// 2. 特定对象的实例方法
List<String> list = new ArrayList<>();
Consumer<String> f2 = list::add;
// 3. 任意对象的实例方法(第一个参数作为调用者)
Function<String, String> f3 = String::toUpperCase; // 等价 s -> s.toUpperCase()
// 4. 构造器
Supplier<Order> f4 = Order::new;
Function<Long, Order> f5 = Order::new; // 按参数匹配构造器
第 3 种最容易看不懂。String::toUpperCase 作为 Function<String, String> 时,第一个参数 String 就是调用者。写成 Lambda 是 s -> s.toUpperCase()。
重构前后
最后贴一下那段被评审的代码,改动本身没什么技术含量,但可读性差距很明显:
// 改之前:34 行
List<OrderVO> vos = new ArrayList<>();
for (Order order : orders) {
if (order.getStatus() == OrderStatus.PAID
&& order.getAmount().compareTo(MIN_AMOUNT) > 0) {
OrderVO vo = new OrderVO();
vo.setOrderNo(order.getOrderNo());
vo.setAmount(order.getAmount());
vo.setCreateTime(format(order.getCreateTime()));
vos.add(vo);
}
}
// 改之后:12 行
List<OrderVO> vos = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.filter(o -> o.getAmount().compareTo(MIN_AMOUNT) > 0)
.map(this::toVO)
.collect(Collectors.toList());
小结
- Lambda 不是匿名内部类的语法糖。它在编译期把方法体变成所在类的私有方法,运行时通过 invokedynamic + LambdaMetafactory 动态生成实现类。
- 变量捕获的是值副本,所以要求 effectively final。想改外面的变量本身就是错误的设计,应该绕开而不是用数组 hack。
- Lambda 里的
this指向外层对象,从匿名内部类迁移过来时要检查。 - 性能上稳态几乎没差别,代价是第一次执行的一次性链接开销(约 10 毫秒量级)。
还有一点我后来才知道:Lambda 默认是不可序列化的。如果要把 Lambda 传到消息队列或者存到缓存里(比如一些分布式计算框架会这么做),得显式转成带 Serializable 的交集类型:
Runnable r = (Runnable & Serializable) () -> System.out.println("hi");
写业务代码基本用不上,但看到别人这么写的时候别觉得奇怪。