Lambda表达式是如何设计的
转载自布赖恩·戈茨的Translation of lambda expressions in javac
引言
阅读本文需要对invokedynamic指令知识有所了解:Invokedynamic
转换策略
在字节码中表示Lambda表达式有多种方案,例如内部类、方法句柄、动态代理等,这些方案各有利弊。如何选择转换策略,有两个关键的衡量指标:
- 是不引入特定策略,以期为将来的优化提供最大的灵活性;
- 是保持类文件格式的稳定。
而invokedynamic指令可以同时满足这两个要求,即将Lambda在二进制字节码中的表达方式和其运行时的评估机制分开进行,而不是通过生成字节码的方式去创建一个实现了Lambda表达式的对象(例如为一个内部类调用构造方法),通过这样的方式在编译的时候将方法需要的静态参数列表和动态参数列表与invokedynamic指令绑定,然后在运行的时候链接到指定的方法即可(此部分可以对比lambda$lambda$1()是如何被调用的)。
这么做的好处是invokedynamic指令使我们可以一直到运行时再去选择转换策略。运行时实现的方式是可以自由地选择转换策略,并且可以动态评估Lambda表达式。Invokedynamic允许这样做,且不需要付出为后续绑定方法可能强加的性能消耗。
具体的的转换策略是:当编译器遇到Lambda表达式的时候,它首先会将Lambda方法体内容脱糖到一个方法中(例如lambda$lambda$1()这样的方法),此方法的参数列表和返回值类型与Lambda表达式的匹配,可能还会附加一些额外的参数(附加的参数来自外部作用域范围)。同时在遇到Lambda表达式的地方会生成一个invokedynamic调用点(CallSite对象),当调用点执行的时候会返回一个函数式接口的实例,这个转换后函数式接口的实现包含Lambda的内容(例如实现类LambdaMain$$Lambda$2)。
方法引用也会按照Lambda表达式一样的方式进行处理,但是大部分方法引用不需要被脱糖进到一个新方法中;我们可以简单地为一个引用的方法加载一个常量方法句柄,然后将其传给metafactory。
Lambda脱糖将Lambda表达式转换成字节码的第一步是将Lambda方法体脱糖到一个方法中。对于脱糖有以下几个问题需要考虑。
- 将Lambda方法体脱糖到一个静态方法中还是一个实例方法中?
- 脱糖之后生成的方法应该放在哪一个类中?
- 脱糖之后生成的方法的可访问性应该是怎样的?
- 脱糖之后生成的方法的命名应该是怎样的?
- 如果需要一个适配器去桥接Lambda方法体的签名和函数式接口的签名(例如装箱、拆箱、基础类型的扩大和缩小转变、动态参数转换等),那么脱糖的方法是遵循Lambda方法体的签名还是函数式接口的签名,又或者是两者的结合呢?以及谁负责适配呢?
- 如果Lambda从外部作用域(enclosing scope)中获取参数,这些参数应该如何在脱糖的方法体的签名中表示呢?难道是将它们追加到参数列表的前面、后面,或者编译器可以将它们整合在一起,统一放到一个Bean对象里面?
跟脱糖Lambda方法体时需要考虑的问题一样,我们也需要考虑方法引用是否需要一个适配器或者桥接方法。
对于以上问题,一般来说,在同等条件下,私有方法优于非私有方法,静态方法优于实例方法,最好的结果是Lambda方法体被脱糖在它所在的类里面,脱糖后的签名应该匹配Lambda方法体的签名,需要的额外参数应该被添加在参数列表的前面,而且完全不对方法引用进行脱糖。这些准则也不是一成不变的,在某些情况下,我们也不得不偏离这些基准策略。
接下来是关于Lambda脱糖的例子。
首先是无状态(stateless)Lambda,所谓无状态指的是Lambda方法体没有从外部作用域中捕捉任何状态,例如:
class A {
public void foo() {
List<String> list = …
list.forEach( s -> { System.out.println(s); } );
}
}
这个Lambda表达式对应的函数式接口的真实签名是 (String)V[其实这个Consumer接口中accept(T t)的真实签名,这种情况的签名通常称为naturesignature],编译器会将Lambda方法体脱糖到一个静态方法,静态方法的签名与Lambda表达式的nature signature相同,然后为脱糖体生成一个方法,脱糖后的结果类似如下。
class A {
public void foo() {
List<String> list = …
list.forEach( [lambda for lambda$1 as Block] );
}
//这个就是脱糖产生的方法
static void lambda$1(String s) {
System.out.println(s);
}
}
相比无状态Lambda,另外一种形式称为有状态Lambda,所谓有状态指的是Lambda方法体中使用了外部作用域的final局部变量、隐式是final的局部变量,或者外部实例(enclosing instance)的字段(这里可以看作捕获了外部作用域的this.xx字段),例如:
class B {
public void foo() {
List<Person> list = …
final int bottom = …, top = …;
list.removeIf( p -> (p.size >= bottom && p.size <= top) );
}
}
上面这个例子,Lambda使用了外部作用域中final类型的局部变量bottom和top。脱糖之后的方法将使用的natural signature为 (Person)Z,并且在参数列表前面追加额外的参数。编译器有权决定这些额外的参数如何表示:参数可以逐个添加到参数列表的前面,或放在一个frame class中,或放在一个数组中。当然,最简单的方式是将参数逐个添加到参数列表的前面,如下面的例子所示。
class B {
public void foo() {
List<Person> list = …
final int bottom = …, top = …;
list.removeIf( [ lambda for lambda$1 as Predicate capturing (bottom, top) ]);
}
//关注这个方法的签名
static boolean lambda$1(int bottom, int top, Person p) {
return (p.size >= bottom && p.size <= top;
}
}
以上展示了Lambda如何脱糖,那么如何调用脱糖后的方法呢?关于这一部分的内容在前一节已经介绍了,接下来我们主要关注invokedynamic指令和脱糖方法之间参数是如何设定的。
Lambda Metafactory
先来看看前面提到的例子。
//lambda()方法
public void lambda() {
Consumer<String> consumer2 = o -> {
Object tmpObj = this.obj;
System.out.println("lambda2");
};
}
//对应的字节码
public void lambda();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=3, args_size=1
…
6: aload_0
7: invokedynamic #7, 0 // InvokeDynamic #1:accept:\
(Lcn/sensorsdata/lambda/LambdaMain;)\
Ljava/util/function/Consumer;
12: astore_2
13: return
上述代码的lambda()方法中使用了LambdaMain中的成员变量obj,观察其对应的字节码可以发现在执行invokedynamic指令之前先执行了aload_0,即this,该值作为参数会在前面提到的LambdaMetafactory.metafactory()方法中使用,下面我们看看这部分内容在Lambda设计参考中是如何介绍的。
首先看看什么是lambda metafactory:对给定的Lambda来说,这个调用点被称为lambda factory,lambda factory的动态参数是从外部作用域中捕获的,lambdafactory的引导方法是一个标准的方法,被称为lambda metafactory。虚拟机对每个invokedynamic只会调用一次这个metafactory,之后它会链接这个调用点然后退出。调用点的链接是懒加载的,所以factory sites不执行就不会被链接。基本的metafactory的静态参数如下。
metaFactory(MethodHandles.Lookup caller, // provided by VM
String invokedName, // provided by VM
MethodType invokedType, // provided by VM
MethodHandle descriptor, // lambda descriptor
MethodHandle impl) // lambda body
前3个参数(caller、invokedName、invokedType)是在虚拟机调用链接的时候自动生成的。descripter参数确定了被转化的Lambda对应的函数式接口方法。impl参数确定了Lambda方法,要么是脱糖的Lambda方法体,要么是方法引用中的方法名。函数式接口方法的方法签名和实现方法有一些不同,实现方法可以有额外的参数,其余参数也可能不完全匹配。为方便展示,约定用一些符号来替换MethodHandle、MethodType与invokedynamic。
- method handle常量简写为MH(引用类型class-name.method-name)。
- method type常量简写为MT(method-signature)。
- invokedynamic简写为INDY((bootstrap, static args…)(dynamic args…)),注意这里的参数设定。
对于前面脱糖的类A,可以使用如下方式来表示。
class A {
public void foo() {
List<String> list = …
list.forEach(indy((MH(metaFactory), MH(invokeVirtual Block.apply),
MH(invokeStatic A.lambda$1)( )));//注意此处的参数
}
private static void lambda$1(String s) {
System.out.println(s);
}
}
因为A中的Lambda是无状态的,所以lambda factory调用点的动态参数是空的。对于例子中的类B,动态参数并不为空,因为我们必须把bottom和top的值添加到lambda factory中。
class B {
public void foo() {
List<Person> list = …
final int bottom = …, top = …;
list.removeIf(indy((MH(metaFactory), MH(invokeVirtual Predicate.apply),
MH(invokeStatic B.lambda$1))( bottom, top ))));
//注意此处的参数
}
private static boolean lambda$1(int bottom, int top, Person p) {
return (p.size >= bottom && p.size <= top;
}
}
这就是LambdaMain的lambda()方法中会有6: aload_0的原因。
静态方法还是实例方法
脱糖方法到底是静态方法还是实例方法呢?观察LambdaMain.class中的lambda$lambda$0()和lambda$lambda$1()两个方法,lambda$lambda$1()是实例方法的原因似乎与Lambda使用了LambdaMain中的字段obj有关,事实上确实如此。总体来说,我们将在Lambda中使用this、super或者外部实例的成员的情况称为instance-capturing lambdas,与其相对的是non-instance-capturinglambdas。
non-instance-capturing lambdas被脱糖成静态方法,instance-capturinglambdas被脱糖成实例的私有方法,当捕获instance-capturing lambdas的时候,this会被声明为第一个动态参数。
举个例子,考虑如下Lambda表达式中使用了一个minSize字段。
list.filter(e -> e.getSize() < minSize )
我们首先将上面的示例脱糖成一个实例方法,然后把接收者(this)作为第一个捕获的参数。结果如下:
list.forEach(INDY((MH(metaFactory), MH(invokeVirtual Predicate.apply),
MH(invokeVirtual B.lambda$1))( this ))));
private boolean lambda$1(Element e) {
return e.getSize() < minSize;
}
因为Lambda方法体被转换成一个私有方法,所以metafactory中的调用点会加载一个常量池中的方法句柄。对示例方法来说,这个方法句柄的类型是REF_invokeSpecial(CONSTANT_MethodHandle_info结构的reference_index对应的值),而对静态方法来说,这个方法句柄的类型是REF_invokeStatic。脱糖成为一个私有方法是因为私有方法可以使用所在类的成员。
方法引用
方法引用有多种写法,跟lambdas类似,也可以分成instance-capturing和non-instance-capturing两种。non-instance-capturing类型方法引用包括静态方法引用(Integer:: parseInt)、未绑定实例的方法引用(String::length)和构造方法引用(Foo::new)。当使用non-instance-capturing类型的方法引用时,动态参数列表总是空的,例如:
list.filter(String::isEmpty)
上面的例子会被转换成:
list.filter(indy(MH(metaFactory), MH(invokeVirtual Predicate.apply),
MH(invokeVirtual String.isEmpty))()))
instance-capturing类型的方法引用形式包括绑定实例方法引用(s::length)、super()方法引用(super::foo)和内部类构造方法引用(Inner::new)。当捕获instance-capturing类型的方法引用,被捕获的参数列表总是有一个参数,就是this。