Lambda 设计参考

412 阅读9分钟

一、导读

本文是对 Brian Goetz 的《Translation of Lambda Expressions》文章的翻译以及添加了一些个人的理解,本篇翻译并不包含原文中的全部章节,只是将一些重要的内容翻译分享给大家。本篇翻译将为后续推出的 ASM 处理 Lambda 表达式以及方法引用的文章提供理论依据。

二、关于本文

本文概括了将 Lambda 表达式和方法引用从 Java 源码到字节码的转换策略。Java 的 Lambda 表达式由 JSR 335 规范制定,然后由 Lambda Project 项目实现。语言特性概览可以在 State of the Lambda 中找到。

本文主要是介绍编译器遇到 Lambda 表达式的时候如何生成字节码以及 Java 语言在运行时如何参与评估 Lambda 表达式。本文大部分内容是介绍处理函数式接口的转换机制。

在 Java 中函数式接口是 Lambda 表达式的一个核心知识点。函数式接口是只有一个抽象方法的接口,例如 Runnable, Comparator 等。Lambda 表达式只支持函数式接口,例如下面的两个例子:

Runnable r = () -> { System.out.println("hello"); };
Collections.sort(strings, (String a, String b) -> a.compareTo(b));

编译器捕捉到 Lambda 表达式的时候生成的代码,既取决于 Lambda 表达式本身,又取决于函数式接口的类型。

三、依赖和符号

关于 Lambda 表达式的设计依赖多个在 JSR 292 中描述的特性,这些特性包括 invokedynamic、method handles 以及对 mehtod handles 和 method types 增强的 LDC 字节码形式。因为这些特性并不会表现在 Java 源码中,我们将会使用一些伪代码来表示这些特性:

  • 对 method handle 常量简写:MH (引用类型 class-name.method-name)
  • 对 method type 常量简写:MT (method-signature)
  • 对 invokedynamic 简写:INDY ((bootstrap, static args...)(dynamic args...))

读者应该对 JSR 292 中的知识有一定的了解。

四、转换策略

有多种方案可以在字节码中表示 Lambda 表达式,例如内部类、方法句柄(method handles)、动态代理等,这些方案各有利弊。在选择策略上,有两个关键的衡量指标:一是不引入特定策略,以期为将来的优化提供最大的灵活性;二是保持类文件格式的稳定。

我们可以使用 JSR 292 中的 invokedynamic 指令,同时满足这两个指标,通过这个指令将 Lambda 在二进制字节码中的表达和 Lambda 表达式的运行时评估机制分开,而不是通过生成字节码的方式去创建一个实现了 Lambda 表达式的对象(例如为一个内部类调用构造方法)。

我们描述了构造 Lambda 的方法,并在运行时链接到实际的方法,这个方法在编译的时候被编码在 invokedynamic 指令的静态和动态参数列表中。

使用 invokedynamic 指令使我们可以一直到运行时再去选择转换策略。运行时实现这个方式可以自由的选择转换策略以动态的去评估 Lambda 表达式。

选择的运行时实现隐藏在构造 Lambda 的标准 API 后面,从而使得静态编译器可以调用这个 API,而且 JRE 实现可以选择他们自己期望的实现策略。Invokedynamic 允许这样做,且不需要付出为后续绑定方法可能强加的性能消耗。

当编译器遇到 Lambda 表达式的时候, 它首先会将 Lambda 方法体内容脱糖(desugar) 到一个方法中,此方法的参数列表和返回类型与 Lambda 表达式匹配, 可能还会附加一些额外的参数(附加的参数来自外围作用域范围)。

在遇到 Lambda 表达式的地方会生成一个 invokedynamic 调用点(call site),当调用点执行的时候会返回一个函数式接口的实例,这个转换后函数式接口的实现包含了 Lambda 的内容。 对于给定的 Lambda 来说这个调用点被称为 lambda factory,lambda factory 的动态参数是从外围作用域中捕获的,lambda factory 的引导方法(bootstrap method)是一个标准的方法,被称为 lambda metafactory。

静态的引导参数在编译的时候捕获有关的 Lambda 信息(包括需要被转换成的函数式接口、脱糖 Lambda 方法体的方法句柄以及是否序列化 SAM 类型的信息等)。 方法引用也会按照 Lambda 表达式一样的方式进行处理,但是大部分方法引用不需要被脱糖进到一个新方法中;我们可以简单的为一个为引用的方法加载一个常量方法句柄然后将其传给 metafactory。

五、Lambda 方法体脱糖

将 Lambda 表达式转换成字节码的第一步是将 Lambda 方法体脱糖到一个方法中。

对于脱糖有以下几个问题需要考虑:

  • 将 Lambda 方法体脱糖到一个静态方法中还是一个实例方法中?
  • 脱糖之后生成的方法应该放在哪一个类中?
  • 脱糖之后生成的方法的可访问性应该是怎样的?
  • 脱糖之后生成的方法的命名应该是怎样的?
  • 如果需要一个适配器去桥接 Lambda 方法体签名和函数式接口方法签名(例如装箱、拆箱、基础类型的扩大和缩小转变、动态参数转换等),那么脱糖的方法是遵循 Lambda 方法体的签名还是函数式接口的签名,又或者是两者的结合呢?以及谁负责这个适配呢?
  • 如果 Lambda 从外部作用域(enclosing scope)中获取参数,这些参数应该如何在脱糖的方法签名中表示呢?(例如它们可以被加到参数列表的前面或者后面,或者编译期可以将他们整合到一个 “frame”参数里面)

跟脱糖 Lambda 方法体时需要考虑的问题一样,我们也需要考虑方法引用是否需要一个适配器或者桥接方法。

编译器会推断 Lambda 表达式的方法签名,包括参数类型、返回值类型和异常信息,我们把这些称为 natural signature。Lambda 表达式也有一个目标类型,这个类型是一个函数式接口,我们将 Lambda 描述符称为删除了目标类型的方法签名。从 lambda factory 返回的实现了函数式接口和捕获了 Lambda 行为的值被称为 lambda object。

在同等条件下,私有方法优于非私有方法,静态方法优于实例方法,最好的结果是 Lambda 方法体被脱糖在它所在的类里面,脱糖后的签名应该匹配 Lambda 方法体的签名,需要的额外的参数应该被添加在参数列表的前面,而且完全不对方法引用进行脱糖。可是在某些情况下,我们不得不偏离这条基准策略。

5.1 脱糖例子之 “无状态” lambdas

简单的 Lambda 表达式的形式是 Lambda 方法体中没有从外部作用域(enclosing scope)中捕捉任何状态(a stateless lambda):

class A {
    public void foo() {
        List<String> list = ...
        list.forEach( s -> { System.out.println(s); } );
    }
}

这个 Lambda 表达式的 natural signature 是 (String)V;(注:其实这个就是 forEach 匿名类的方法签名)。编译器会将 Lambda 方法体脱糖到一个静态方法,静态方法的签名与 Lambda 表达式的 natural 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);
    }
}

5.2 脱糖例子之 lambdas 捕获不变的值

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;
    }
}

另外,也可以将使用到的参数(bottom 和 top)封装在一个 frame 或者数组中;关键点是约定好脱糖的方法中额外参数的类型以及这些类型作为动态参数在 lambda factory 中的位置。因为编译期需要控制这两点,而且是同时生成的,所以编译器在如何封装参数具有一定的灵活性。

六、The Lambdas Metafactory

Lambda 的捕获将由 invokedynamic 的调用点来实现,调用点的静态参数包含了 Lambda 方法体(可以理解为函数式接口的描述符)和 Lambda 描述符(可以理解为脱糖的描述信息)的特征,调用点的动态参数(如果有)就是被捕获的值。

当调用的时候,这个调用点为这个相关的 Lambda 方法体和描述符返回一个 Lambda 对象,并且绑定捕获到的值。

调用点的引导方法是一个指定平台的方法叫做 lambda metafactory。虚拟机对每个捕获点只会调用一次这个 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

前三个参数(caller, invokedName, invokedType)是在虚拟机调用链接的时候自动生成的。 descripter 参数确定了被转化的 Lambda 对应的函数式接口方法。 impl 参数确定了 Lambda 方法,要么是脱糖的 Lambda 方法体,要么是方法引用中的方法名。 在函数式接口方法的方法签名和实现方法有一些不同。实现方法可以有额外的参数。其余参数也可能不完全匹配。

6.1 Lambda 捕获

我们现在已经准备好了 Lambda 表达式的函数式接口和方法引用的转换工作。我们可以将例子中类 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;
    }
}

6.2 静态方法还是实例方法

像前面章节中的 Lambda 可以被转换成一个静态方法,因为他们没有使用外部对象的实例(没有使用到 this、super 或者外部实例的成员)。总的来说,我们将在 Lambda 中使用 this、super 或者外部实例的成员(这种情况称为 instance-capturing lambdas,与其相对的是 non-instance-capturing lambdas)。 Non-instance-capturing lambdas,脱糖成 private static 方法;Instance-capturing lambdas,被脱糖成 private 实例方法。当捕获 instance-capturing lambda 的时候,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,而对于静态方法是说这个方法句柄的类型是 REF_invokeStatic。我们脱糖成为一个 private 方法是因为 private 方法可以使用所在类的成员。

6.3 捕获方法引用

方法引用有多种写法,跟 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。

6.4可变参数

如果一个方法引用表达式引用的是一个可变参数的方法,但是对应的函数式接口不是可变参数,编译器必须生成一个桥接方法并且使用桥接方法而不是使用它自己的目标方法。这个桥接方法必须处理所需的任何参数类型的适配以及将参数从可变转换成不可变。例子如下:

interface IIS {
    void foo(Integer a1, Integer a2, String a3);
}
  
class Foo {
    static void m(Number a1, Object... rest) { ... }//第一个参数是 Number,后面的可变参数是 Object
}
  
class Bar {
    void bar() {
        SIS x = Foo::m;
    }
}

这里编译器需要生成一个桥接方法去适配,适配器的第一个参数类型从 Number 被转变成 Integer,然后其余参数被放在了 Object 数组里。

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);
    }
}

6.5 参数适配

脱糖的 Lambda 方法有一个参数列表和一个返回值:(A1..An) ->Ra(如果这个脱糖方法是一个实例方法,那么接收者 this 被考虑为第一个参数)。相似的,函数式接口方法也有一个参数列表和一个返回值:(F1..Fm)→Rf(无接收者 this 参数),factory 调用点的动态参数类型类型是(D1..Dk)。如果这个 Lambda 是一个 instance-capturing 类型, 那么第一个动态参数必须是接收者 this。

所有参数的长度必须按照这个公式相加:k+m == n,就是说 Lambda 方法体的参数列表长度应该是动态参数列表长度加上函数式接口方法的参数列表长度。

我们分割 Lambda 方法体的参数列表 A1..An 进一步分成(D1..DK H1..Hm),这里的 D 参数对应的是动态参数,以及 H 参数对应的是函数式接口方法的参数。

我们需要 Hi 参数类型能够适配 Fi,相似的,我们需要 Ra 类型能够适配 Rf。当满足下面的场景时,类型 T 适配类型 U:

  • T == U
  • T 是基础类型,U 是引用类型,且 T 可以被转换成 U 通过装箱
  • T 是引用类型,U 是基础类型,且 R 可以转换成 U 通过拆箱
  • T 和 U 都是基础类型,T 可以扩展转换成 U(注:例如 int 可以转换成 long)
  • T 和 U 都是引用类型,且 T 可以强制转换成 U

这个适配会在 metafactory 链接的时候验证。

七、总结

原文后面还有一些章节主要是使用序列化的思想通过伪代码的方式去描述 metafactory,这里就不去多做介绍了,核心还是前面章节的内容,里面的很多细节很重要。例如,前面提到的当方法引用在使用可变参数时候的处理逻辑,如果不了解这个知识点,那么就可能忘记对这种情况的适配操作。

另外,下一篇博客会为大家分享「ASM Hook Lambda 和方法引用」方面的技术知识,敬请期待。

参考文献: twitter.com/BrianGoetz cr.openjdk.java.net/~briangoetz… jcp.org/en/jsr/deta… openjdk.java.net/projects/la… cr.openjdk.java.net/~briangoetz… jcp.org/en/jsr/deta…