Loading

Java幕后:Lambda表达式是如何在Java中运行的

原文Behind the scenes: How do lambda expressions really work in Java?
作者:Ben Evans

通过字节码查看Java如何处理lambda

lambda在Java代码中和JVM中式什么样的?显然,它是某种类型的值,Java允许两种类型的值:基本类型和对象引用。lambda显然不是基本类型,所以一个lambda表达式一定是某种返回对象引用的表达式。

我们看一个例子:

public class LambdaExample {
    private static final String HELLO = "Hello World!";

    public static void main(String[] args) throws Exception {
        Runnable r = () -> System.out.println(HELLO);
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}

一些熟悉内部类的程序员可能会猜测上面的lambda表达式只是Runnable类的匿名实现,然而,编译上面的代码仅仅会生成一个单独的LambdaExample.class,并没有出现附加的类。(因为Java中的内部类最终会编译成单独的class文件)。

这说明lambda并不是匿名内部类,相反,它一定是一些其它的机制。事实上,通过javap -c -p反编译字节码可以揭示两个事实,第一个是lambda的执行体已经被编译到出现在主类的一个私有静态方法中:

private static void lambda$main$0();
  Code:
      0: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
      3: ldc           #30                 // String Hello World!
      5: invokevirtual #32                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      8: return

你可能会猜测到这个私有静态方法(原文private body method)的签名将和lambda的签名一致,确实如此,比如如下所示的Lambda:

import java.util.function.Function;

public class StringFunction {
    public static final Function<String, Integer> fn = s -> s.length();
}

将产生一个具有String类型参数并返回Integer的方法,与之前的接口方法的签名匹配:

private static java.lang.Integer lambda$static$0(java.lang.String);
    Code:
       0: aload_0
       1: invokevirtual #2                  // Method java/lang/String.length:()I
       4: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       7: areturn

第二个要注意的是Thread那个例子中main方法的字节码:

public static void main(java.lang.String[]) throws java.lang.Exception;
  Code:
      0: invokedynamic #7,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
      5: astore_1
      6: new           #11                 // class java/lang/Thread
      9: dup
    10: aload_1
    11: invokespecial #13                 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
    14: astore_2
    15: aload_2
    16: invokevirtual #16                 // Method java/lang/Thread.start:()V
    19: aload_2
    20: invokevirtual #19                 // Method java/lang/Thread.join:()V
    23: return

注意从invokedynamic调用开始的字节码,这个操作码是在Java7中被添加的(并且这是Java唯一一次向JVM字节码中添加操作码)。我在Real-world bytecode Handling with ASMUnderstanding Java method invocation with invokedynamic两篇文章中讨论了关于方法调用的内容,可以将其和本文配套阅读。

理解上面代码中invokedynamic最直接的方式就是把它想成对某种特殊形式的工厂方法的调用,这个方法调用返回一个某种实现了Runnable的类型实例,Bytecode中并没有指定确切的类型,而且这也不重要。

实际的类型在编译时并不存在,而是在运行时按需创建,为了更好地解释这一点,我将讨论三个机制,它们的协同工作带来了这种(运行时按需创建的)能力,它们就是:Call site(调用点)、Method Handles(方法句柄)和Bootstrap(引导)。

译者:后文中为了简单,使用以上三种对象的中文名进行描述

Call sites

在字节码中发生方法调用指令的地方就是调用点

Java字节码具有四个传统的操作码来处理不同情况下的方法调用:静态方法、普通调用(一个卷入方法重写的虚调用)、接口查找和特定调用(比如不需要重写解析的地方,比如父类调用和私有方法)。

译者:上面在描述JVM中传统的四个方法调用字节码指令,如果对它们不熟悉可以去了解一下

动态调用比上面的四种机制更近异步,它提供一种由程序员决定在每个调用点该调用哪个方法的机制。

invokedynamic的调用点在Java堆中被表示为CallSite对象,这并不奇怪,Java从1.1开始已经在反射API中做了很多类似的事情了,比如Method和Class类型。Java在运行时具有很多动态行为,所以Java现在对调用点以及其它运行时类型信息进行建模的想法不足为奇。

当到达invokedynamic指令时,JVM寻找对应的调用点对象(或者如果这个调用点之前从未被访问过,就由JVM创建一个)。调用点对象包含一个方法句柄,该句柄是一个代表实际想要调用的方法的对象。

这个调用点对象很有必要是间接级别的,允许它关联的调用目标(方法句柄)随时间变化。

目前有三个CallSite(是一个抽象类)的子类:ConstantCallSiteMutableCallSiteVoliatileCallSite。基类只具有一个包级私有构造方法,三个子类具有公有构造器。这代表CallSite不能被被用户代码直接继承,但是这在它的子类上是可能的。例如,JRuby语言使用invokedynamic作为它实现的一部分,并使用MutableCallSite的子类。

注意:某些invokedynamic调用点实际上只是延迟计算,而且它们的目标方法(MethodHandle)在第一次执行后将永远不改变,这是一个ConstantCallSite的一个非常常见的用例,包括lambda表达式都是这样实现的。这也意味着一些非常量(nonconstant)的调用点在程序生命周期中可以有许多不同的方法句柄作为目标。

方法句柄

反射是一个强大的实现运行时技巧的技术,但是它有许多设计缺陷,并且现在它显然已经过时了。反射的一个关键问题是性能,特别是因为反射调用对于JIT(即使编译器)来说很难进行内联优化。

这是很糟糕的,因为内联在很多方面对JIT编译非常重要,不仅仅因为它通常是第一个应用的优化,而且它也打开了一些其它技术的大门(比如逃逸分析和死码删除分析)。

第二个问题是,每次遇到Method.invoke的调用点时都会连接反射调用,这意味着执行安全访问检查,这时非常浪费的,因为检查通常会在第一次调用时成功或失败,而且在整个程序执行周期中,它会继续保持这个成功或失败的状态,然而反射一遍遍的重复进行这种连接,因此,反射会浪费CPU时间产生大量不必要的成本。

为了解决这些问题,Java7引入了新的API,java.lang.invoke,由于它引入的主要类的名字,所以它经常被随意地称作“方法句柄”。

一个方法句柄(MH)是一个Java版本的类型安全的函数指针。它是一个引用到代码中可能想要调用的方法的途径,类似反射的Method对象,MH具有一个invoke方法去实际的执行底层方法,与反射的执行方式完全相同。

在某个层面上,MH实际上是一种更高效的反射机制。

原文:At one level, MHs are really just a more efficient reflection mechanism that’s closer to the metal;

任何来自反射API的对象表示的东西都可以转换为等效的MH,例如可以通过Lookup.unreflect()将一个反射Method对象转换成一个MH对象。MH通常是访问底层方法的更有效方式。

MH可以通过MethodHandles类的helper方法以多种方式被调整,例如通过组合和方法参数的部分绑定(currying)。

通常,方法链接需要一个精确的类型描述符的匹配。然而,MH上的invoke方法具有一个特殊的多态签名,它允许链接继续进行而不管被调用方法的签名是什么。

在运行时,invoke调用点上的签名看起来应该像是直接调用它所引用的方法,这样可以避免反射调用典型的类型转换和自动装箱的成本。

因为Java是一种静态类型语言,所以问题就出现了:当使用这种基本的动态机制时,类型安全性能够得到多大程度的保护。MH API使用一个名为MethodType的类型来解决这个问题,该类型是方法接收的参数的不可变表示形式,也就是方法的签名。

在Java8的生命周期内,MHs的内部实现被修改了。新的实现称作lambda forms,它提供了一个戏剧性的性能改进,MH现在在许多用例中都优于反射。

Bootstrapping

当字节码指令中第一次遇到每个特定的invokedynamic调用点时,JVM并不知道它的目标方法是哪个。实际上,并没有与该指令关联的调用点对象。

调用点需要引导(bootstrapped),并且JVM通过运行一个引导方法(BSM)来达到生成和返回一个调用点对象的目的。

每一个invokedynamic调用点与一个BSM关联,它存储在类文件的单独区域中,这些方法允许用户代码在运行时以可编程的方式确定链接。

译者:感觉有点饶了,我总结一下。

调用点就是字节码中的invoke...指令,invokedynamic指令用于以可编程的方式来调用一个方法,因为早期的四个字节码指令调用方法的逻辑都固化在JVM中,这导致Java很难实现动态类型语言的一些特性(如鸭子类型,高阶函数等),而JVM作为独立的虚拟机,在设计之初就想要让很多种语言运行在JVM上,而动态类型语言它自然也想支持,所以提供这个invokedynamic指令。

invokedynamic的方法调用逻辑(意思是该调用哪个方法)是在BSM(引导方法)中实现的,这部分是用户可编程的。这个我觉得JVM的神书《深入理解Java虚拟机》里说的挺清楚的,可以看看。

反编译一个invokedynamic调用,比如来自我最初的Runnable的例子,显示了它有这样的形式:

0: invokedynamic #7,  0

在这个类的常量池中,注意条目#7CONSTANT_invokedynamic类型的常量,常量池的相关部分是:

#7 = InvokeDynamic      #0:#8          // #0:run:()Ljava/lang/Runnable;
#8 = NameAndType        #9:#10         // run:()Ljava/lang/Runnable;
#9 = Utf8               run
#10 = Utf8               ()Ljava/lang/Runnable;

InvokeDynamic的第一个参数常量#0是一个线索(因为Class文件中不存在常量0),它提醒您实际的BSM位于类文件的另一部分,而不是常量池中。

对于Lambdas,NameAndType条目具有一种特殊的形式,name是任意的,但是类型签名会包含一些有用的信息。

返回类型对应于invokedynamic工厂的返回类型,它是lambda表达式的目标类型,此外,参数列表由lambda捕获的元素类型组成,对于无状态lambda,返回类型将总是空;只有Java闭包才具有参数。

译者:并没理解无状态lambda是啥意思,但是对于闭包,这里的方法签名确实具有参数,参数就是它捕获的外部变量。如下图Lambda捕获了外部int变量a,参数列表中就有一个整形变量。

一个BSM接收至少三个参数并返回一个CallSite:

  1. MethodHandles.Lookup:调用点所在类上的lookup对象
  2. String:NameAndType中提到的name
  3. MethodType:NameAndType中提到的类型说明

这些参数之后是BSM需要的任何其它参数,它们在文档中被称为附加的静态参数。

一般情况下BSMs允许一种非常灵活的机制,非Java语言实现者使用这种机制,但是Java语言没有提供用于生成任意invokedynamic调用点的语言级构造。

对于lambda表达式,BSM采用一种特殊形式,为了完全理解该机制的工作方式,我将更仔细地研究它。

反编译lambda的引导方法

使用javap的-v参数来观察引导方法。这是必要的因为引导方法在class文件的一个特定部分,并且该部分引用主常量池。对于这个简单的Runnable例子,只有一个引导方法:

BootstrapMethods:
  0: #52 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:
      #59 ()V
      #60 REF_invokeStatic LambdaExample.lambda$main$0:()V
      #59 ()V

这有点难以阅读,所以让我们来揭秘它。

这个调用点的引导方法是常量池的#52号条目,这是一个MethodHandle类型的条目(一种Java7中被添加的基本常量池类型)。现在让我们将它与字符串函数的例子进行比较。

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 (Ljava/lang/Object;)Ljava/lang/Object;
      #29 REF_invokeStatic StringFunction.lambda$static$0:(Ljava/lang/String;)Ljava/lang/Integer;
      #30 (Ljava/lang/String;)Ljava/lang/Integer;

它们使用相同的静态方法句柄LambdaMetaFactory.metafactory(...)

不同的部分是方法参数,它们是lambda表达式的附加静态参数,有三个,表示lambda的签名和实际最终调用目标的方法句柄,第三个静态参数是签名的擦除形式。

我们跟着java.lang.invoke的代码,然后看看平台如何使用metafactories来动态的转换(spin)那些实际lambda表达式实现目标类型的类。

Lambda metafactories

BSM调用这个静态方法,它最终返回一个调用点,当invokedynamic指令执行,这个调用点中包含的方法句柄将返回一个实现lambda目标类型的类的实例。

元工厂方法的源代码相对简单:

public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String invokedName,
                                       MethodType invokedType,
                                       MethodType samMethodType,
                                       MethodHandle implMethod,
                                       MethodType instantiatedMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                             invokedName, samMethodType,
                                             implMethod, instantiatedMethodType,
                                             false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
}

lookup对象对应于invokedynamic指令所在的上下文,在这种情况下,就是定义lambda表达式的同一个类,所以查找上下文具有正确的权限来访问编译lambdabody的私有方法。

被调用的名称和类型由VM提供,是实现详细信息,最终三个参数是BSM的额外静态参数。

In the current implementation, the metafactory delegates to code that uses an internal, shaded copy of the ASM bytecode libraries to spin up an inner class that implements the target type.

If the lambda does not capture any parameters from its enclosing scope, the resulting object is stateless, so the implementation optimizes by precomputing a single instance—effectively making the lambda’s implementation class a singleton:

posted @ 2022-03-21 21:12  yudoge  阅读(202)  评论(0编辑  收藏  举报