Java Lambda表达式的翻译

Lambda 表达式的翻译

原文: Translation of Lambda Expressions

MethodType 以参数类型与返回值类型的Class对象表示
MethodHandle

  1. invoke时检查MethodType
  2. 与反射不同的是,MethodHandle创建时才检查访问权限,之后使用就不再检查权限

函数式接口是一个有非继承自Object的一个方法的接口,例如Runnable,Comparator等,Java库使用这样的接口来表示回调已经很多年了。

编译器生成的用来捕获这些Lambda表达式的代码取决于Lambda表达式自身和它被赋予的函数式接口类型。

翻译需要用到JSR 292中的几个特性,包括invokedynamic,method handles和method handles与method types的增强的LDC字节码形式。因为这些无法表示在Java的源码中,所以我们的例子中将会用一种伪语法来表示这些特性:

  • 对于method handle常量: MH([refKind] class-name.method-name)
  • 对于method type常量: MT(method-signature)
  • 对于invokedynamic: INDY((bootstrap, static args...)(dynamic args...))

其中还涉及到JSR-292专家组为Java 8制定标准的一种对于constant method handles的反射API。

翻译策略

我们有很多种将lambda表达式用字节码表示的方式,例如内部类,方法句柄(method handles)以及动态代理等等。这些方式各有优缺点。选择策略之时,有两个相互竞争的目标:一是不确定具体的策略来为未来的优化提供最大化的灵活性,二是提供稳定的类文件表示。

我们可以通过使用JSR 292中的invokedynamic特性来分离字节码中lambda创建的二进制表示与运行时对lambda表达式求值的机制。

我们描述一个构造lambda的配方,并将实际的组装过程代理给语言的运行时,而不是通过生成字节码来创建一个实现了lambda表达式的对象(例如调用一个内部类的构造器)。这个配方编码在invokedynamic指令的静态和动态参数列表中。

对invokedynamic的使用让我们得以将对翻译策略的选择推迟至运行时。运行时实现可以自由地动态地选择一种策略来对lambda表达式进行求值。

运行时实现的选择隐藏在一个标准化(换句话说,涉及部分平台标准)的Lambda构建API之后,JRE实现可以选择它们更偏爱的实现策略。invokedynamic机制使得这一切得以实现却又不用承担付出延迟绑定的性能损失。

当编译器遇到一个lambda表达式时,它首先对lambda进行脱糖化以得到一个参数列表和返回类型与lambda表达式匹配的方法,这个方法可能带有一些额外的参数(从词法作用域中捕获到的值)。

捕获到lambda表达式后,它会生成一个invokedynamic的调用点(call site),这个调用点被调用时会返回一个由lambda转化的函数式接口的实例。这个调用点叫做对应lambda的lambda工厂。从词法作用域中捕获到的值会以动态参数的形式传递给lambda工厂。

lambda工厂中的引导方法是一个Java语言运行时库中的标准方法,叫做lambda元工厂(metafactory)

SAM, single abstract method.
SAM type refers to interfaces like Runnable, Callable, etc.

静态的引导参数包含lambda表达式在编译时知道的信息(它将要转化的函数式接口,脱糖化后的方法句柄和SAM类型是否可以序列化等)。

编译器对方法引用与lambda表达式一视同仁,除了大多数方法引用不需要脱糖化成一个新的方法;我们可以简单加载一个被引用方法的方法句柄常量然后将它传递给元工厂。

Lambda 脱糖化

将字节码翻译成字节码的第一步就是,将lambda的函数体脱糖化为一个方法。

围绕着脱糖化,我们需要做出几个选择:

  • 脱糖成静态方法还是实例方法?
  • 脱糖后的方法应该放在哪个类?
  • 脱糖后的方法的访问权限是怎样的?
  • 脱糖后的方法名称是什么?
  • 如果需要改变一些东西来缩小lambda函数体签名和函数式接口方法签名的差距(例如装箱,拆箱,原始类型的扩大或缩小转换,varargs的转换等等),脱糖方法应该遵循lambda函数体、函数式接口方法还是两者之间的签名?谁应该负责做出这个改变?
  • 如果lambda从上层作用域捕获到参数,应该如何表示在脱糖方法的签名中?(以独立的参数形式添加在参数列表的首部或尾部,或者编译器可以将这些参数收集并放入一个”frame”参数中)

与脱糖化lambda函数体相关的问题是,方法引用是否需要生成一个适配器或是“桥接”方法。

编译器会为lambda表达式推测一个方法签名,包含参数类型,返回值类型以及抛出的异常; 我们把这个叫做“自然签名”(natural signature)。 Lambda表达式也有一个指向函数式接口的目标类型; 我们把目标类型被擦除后的描述符的方法签名叫做“lambda描述符”(we will call the lambda descriptor the method signature for the descriptor of the erasure of the target type.)

lambda工厂返回的,实现了函数式接口并且捕获lambda行为的值,叫做“lambda对象”(lambda object)

在所有条件都相同的情况下,编译器更偏爱私有方法,静态方法。

最好的情况是,lambda函数体在lambda表达式出现的最内层类中被脱糖,生成的方法签名匹配lambda的签名,额外的参数应该添加在捕获值的参数之前以及编译器根本不需要对方法引用进行脱糖。

然而,一些特殊情况下,我们可能要偏离这一基本策略。

脱糖示例 — “无状态的”lambda

没有从上层作用域捕获状态的lambda表达式是最简单的形式(无状态的lambda):

1
2
3
4
5
6
class A {
public void foo() {
List<String> list = ...
list.forEach( s -> { System.out.println(s); } );
}
}

这里的Block应该是类似于Consumer的。

这个lambda的自然签名是(String)V;forEach方法接受一个Block<String>,它的lambda描述符是(Object)V.

编译器将lambda函数体脱糖到一个签名是lambda自然签名的静态方法中,并为它生成一个名字。

1
2
3
4
5
6
7
8
9
10
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表达式涉及到对上一层作用域中final(或是等效final)的局部变量,和/或上一层实例中的字段(我们可以当做是对上层this的final引用)的捕获。

1
2
3
4
5
6
7
class B {
public void foo() {
List<Person> list = ...
final int bottom = ..., top = ...;
list.removeIf( p -> (p.size >= bottom && p.size <= top) );
}
}

这里,我们的lambda从上层作用域中捕获了final的局部变量bottomtop

脱糖方法的签名会是自然签名(Person)Z以及一些添加在参数列表首部之前的额外参数。

编译器可以选择如何表示这些额外的参数; 作为独立的参数被添加到前面,封装在一个frame类中,封装到一个数组中,等等。

最简单的方法就是将它们作为独立的参数添加到前面:

1
2
3
4
5
6
7
8
9
10
11
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 元工厂

调用点(call site)这个词可能在链接中用到了?现在还不是很懂,只知道Java中有CallSite这个类

通过invokedynamic调用点实现对Lambda的捕获,传递给调用点的静态参数描述了lambda函数体和lambda描述符的特性,传递给它的动态参数则描述了被捕获的值。

当调用点被调用时,它会返回一个对应lambda函数体和lambda描述符的lambda对象,同时将捕获的值都绑定给这个对象。

这个调用点的引导方法是一个叫做lambda元工厂的特殊平台方法。(我们可以对所有lambda形式都使用一个元工厂,或是对常见情况使用特殊版本的元工厂)。

对于每个捕获点,虚拟机只会调用元工厂一次; 它会链接这个调用点然后离开。

调用点是懒加载的,所以从没被调用的工厂点(factory sites)是永远不会被链接的。

基本元工厂的静态参数列表是这样的:

1
2
3
4
5
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

虚拟机在调用点链接阶段会自动将前三个参数(caller, invokedName, invokedType)入栈。

descriptor参数确定了lambda将被转化的函数式接口的方法。(通过一个方法句柄的反射API,元工厂可以获取函数式接口的名字和它初始方法的名字和方法签名。)

impl参数确定了lambda方法,要么是脱糖的函数体要么是方法引用中的方法名。

函数式接口方法和实现方法(implementation method)的方法签名之间可能有些差异。实现方法的方法签名中可能有对应捕获值的额外参数。剩下的参数也有可能不是完全匹配;特定的适配(子类型,包装)是被允许的。

lambda 捕获

现在我们已经准备好描述lambda表达式和方法引用向函数式接口转化的翻译过程。

我们可以将示例A翻译成:

1
2
3
4
5
6
7
8
9
10
11
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工厂点的动态参数列表是空的。

对于示例B,动态参数列表不是空的,因为我们必须想lambda工厂提供bottom和top的值:

1
2
3
4
5
6
7
8
9
10
11
12
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;
}
}

静态方法vs实例方法

之前的章节中的lambda因为没有以任何形式使用上层作用域的对象实例(没有引用this,super和上层实例的成员),所以可以被翻译成静态方法。总的来说,我们将使用了this,super或是捕获了上层实例的成员的lambda叫做实例捕获型lambda(instance-capturing lambdas)。

非实例捕获型的lambda会被翻译成私有的,静态的方法。实例捕获型的lambda会被翻译成私有的实例方法。这简化了实例捕获型lambda的脱糖化,因为lambda函数体中的名字将和脱糖方法中的名字含义一致,同时也能够很好地和现有的实现技术(绑定的方法句柄)进行合作。当捕获一个实例捕获型的lambda时,接受者(this)将被指定为第一个动态参数。

考虑一个捕获minSize字段的lambda:

list.filter(e -> e.getSize() < minSize )

我们将它脱糖为一个实例方法,然后将接受者作为第一个捕获的参数:

1
2
3
4
5
6
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;
}

invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)
invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
invokestatic指令用于调用类方法
invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

因为lambda函数体被翻译成私有方法,当方法句柄被传递给元工厂时,捕获点应该为实例方法加载一个引用类型为REF_invokeSpecial的方法句柄常量,为静态方法加载一个引用类型为REF_invokeStatic的方法句柄常量。

我们之所以能够脱糖为私有方法是因为捕获类有权限访问私有方法,因此也就可以获得稍后能被元工厂调用的私有方法的句柄。(如果元工厂通过生成字节码来实现目标函数式接口而不是直接调用方法句柄,它将会通过Unsafe.defineClass来加载这些类,而Unsafe.defineClass是免疫访问权限检查的。)

方法引用捕获

方法引用有很多种形式,与lambda类似地,也可以被分为实例捕获型和非实例捕获型。

非实例捕获型方法引用包括静态方法引用(Integer::parseInt,使用引用类型invokeStatic捕获),未绑定的实例方法引用(String::length,使用引用类型invokeVirtual捕获),以及顶层构造器引用(Foo::new,使用引用类型invoke_newSpecial捕获)。

当捕获非实例捕获型的方法引用时,捕获参数列表总是空的:

list.filter(String::isEmpty)

被翻译成

1
2
list.filter(indy(MH(metaFactory), MH(invokeVirtual Predicate.apply),
MH(invokeVirtual String.isEmpty))()))

实例捕获型方法引用包含绑定的实例方法引用(s::length,使用引用类型invokeVirtual捕获),父类方法引用(super::foo,使用引用类型invokeSpecial捕获),以及内部类构造器引用(Inner::new,使用引用类型InvokeNewSpecial捕获)。

捕获一个实例捕获型的方法引用时,捕获参数列表总是有单个参数,在父类或内部类构造器方法引用的情况下是this,在绑定的实例方法引用的情况下是对应的接受者(receiver)。

Varags 可变参数

如果指向可变参数方法的方法引用被转化为一个非可变参数方法的函数式接口,那么编译器必须生成一个桥接方法并捕获桥接方法而不是目标方法自身的方法句柄。

桥接方法必须处理好参数类型的改变,例如从可变参数转为不可变参数。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
interface IIS {
void foo(Integer a1, Integer a2, String a3);
}

class Foo {
static void m(Number a1, Object... rest) { ... }
}

class Bar {
void bar() {
SIS x = Foo::m;
}
}

这里编译器需要生成一个桥接方法来将第一个参数类型从Number转为Integer,然后将剩下的参数加入一个Object数组:

1
2
3
4
5
6
7
8
9
10
class Bar {
void bar() {
SIS x = indy((MH(metafactory), MH(invokeVirtual IIS.foo),
MH(invokeStatic m$bridge))( ))
}

static private void m$bridge(Integer a1, Integer a2, String a3) {
Foo.m(a1, a2, a3);
}
}

改变(Adaptations)

脱糖方法有一个参数列表和一个返回值类型:(A1..An) -> Ra (如果脱糖方法是一个实例方法,第一个参数会是接受者)。类似的,函数式接口方法也有一个参数列表和一个返回值类型: (F1..Fm) -> Rf。传递给工厂点的动态参数列表有类型 (D1..Dk)。如果lambda是实例捕获型的,第一个动态参数一定是接受者。

长度上必须满足: k+m == n。也就是说lambda函数体的参数列表应该与动态参数列表加上函数式接口方法参数列表的长度相同。

把lambda参数列表A1..An分为 (D1..Dk H1..Hm),D参数对应额外(动态的)参数,H参数对应函数式接口的参数。

我们要求Hi能够改变为Fi以及这对于i从1到m都成立。类似的,我们也要求Ra能够改变为Rf。当满足以下情形时,我们说类型T能够改变为U:

  • T == U
  • T是原始类型,U是引用类型,能够通过装箱将T转化为U
  • T是引用类型,U是原始类型,能够通过拆箱将T转化为U
  • T和U都是原始类型,能够通过向上转型(primitive widening conversion)将T转化为U
  • T和U都是引用类型,能够通过强制转型将T转化为U

元工厂会在连接时验证改变,在捕获时执行改变。

元工厂变量

对于所有lambda形式都使用单个元工厂是很实用的。然而,将元工厂分为多个版本似乎更好:

  • “fast path”版本,支持不可序列化lambda、静态或未绑定实例的方法引用。
  • “serializable”版本,支持可序列化lambda和所有种类的方法引用。
  • 如果必要的话,提供一个”kitchen sink”版本,支持各种转化的任意组合。

kitchen sink版本可能会接受一个额外的flags参数来选择可选项,也可以会接受其他受可选项影响的参数。

可序列化版本可能会接受与系列化相关的额外的参数。

Since the metafactories are not invoked directly by the user, there is no confusion introduced by having multiple ways to do the same thing. By eliminating arguments where they are not needed, classfiles become smaller. And the fast path option lowers the bar for the VM to intrinsify the lambda conversion operation, enabling it to be treated as a “boxing” operation and faciliating unbox optimizations.

查看生成的类

That’s where we would start following the call and dig in, and then search for keywords like “dump”, “trace”, “debug”, etc, to see if there’s any debugging switch/flags embedded in the implementation that would give us more information for debugging.

在java.lang.invoke.InnerClassLambdaMetafactory里找到了这一段代码:

1
2
3
4
5
6
7
8
9
10
// For dumping generated classes to disk, for debugging purposes
private static final ProxyClassesDumper dumper;

static {
final String key = "jdk.internal.lambda.dumpProxyClasses";
String path = AccessController.doPrivileged(
new GetPropertyAction(key), null,
new PropertyPermission(key , "read"));
dumper = (null == path) ? null : ProxyClassesDumper.getInstance(path);
}

所以运行Java时加入-Djdk.internal.lambda.dumpProxyClasses=<path_to_your_dump_directory>选项就可以让JDK把lambda表达式对应的运行时生成的类给dump下来了。

自己翻译了一部分文章后,才发现已经有人翻译过这篇文章了。
https://lowzj.com/notes/java/translation-of-lambda-expressions.html