java8 探讨与分析匿名内部类、lambda表达式、方法引用的底层实现

时间:2023-03-08 22:07:32

问题解决思路:查看编译生成的字节码文件

思路一:

  1. 编译 javac fileName.java
  2. 反编译 javap -v -p fileName.class ; 这一步可以看到字节码。

思路二:

运行阶段保留jvm生成的类

java -Djdk.internal.lambda.dumpProxyClasses fileName.class

不错的博客:https://blog.****.net/zxhoo/article/category/1800245


本人旨在探讨匿名内部类、lambda表达式(lambda expression),方法引用(method references )的底层实现,包括实现的阶段(第一次编译期还是第二次编译)和实现的原理。

测试匿名内部类的实现

建议去对照着完整的代码来看 源码链接

基于strategy类,使用匿名内部类,main函数的代码如下,称作test1

    Strategy strategy = new Strategy() {
@Override
public String approach(String msg) {
return "strategy changed : "+msg.toUpperCase() + "!";
}
};
Strategize s = new Strategize("Hello there");
s.communicate();
s.changeStrategy(strategy);
s.communicate();

第一步:现在对其使用javac编译,在Strategize.java的目录里,命令行运行javac Strategize.java,结果我们可以看到生成了5个.class文件,我们预先定义的只有4个class,而现在却多出了一个,说明编译期帮我们生成了一个class,其内容如下:

class Strategize$1 implements Strategy {
Strategize$1() {
} public String approach(String var1) {
return var1.toUpperCase();
}
}

第二部:对生成的 Strategize.class 进行反编译,运行javap -v -c Strategize.class,在输出的结尾可以看到下面信息:

NestMembers:
com/langdon/java/onjava8/functional/Strategize$1
InnerClasses:
#9; // class com/langdon/java/onjava8/functional/Strategize$1

说明,这个Strategize$1的确是Strategize的内部类。

这个类是命名是有规范的,作为Strategize的第一个内部类,所以命名为Strategize$1。如果我们在测试的时候多写一个匿名内部类,结果会怎样?

我们修改main()方法,多写一个匿名内部类,称做test2

    Strategy strategy1 = new Strategy() {
@Override
public String approach(String msg) {
return "strategy1 : "+msg.toUpperCase() + "!";
}
};
Strategy strategy2 = new Strategy() {
@Override
public String approach(String msg) {
return "strategy2 : "+msg.toUpperCase() + "!";
}
};
Strategize s = new Strategize("Hello there");
s.communicate();
s.changeStrategy(strategy1);
s.communicate();
s.changeStrategy(strategy2);
s.communicate();

继续使用javac编译一下;结果与预想的意义,多生成了2个类,分别是Strategize$1Strategize$2,两者是实现方式是相同的,都是实现了Strategy接口的class

小结

到此,可以说明匿名内部类的实现:第一次编译的时候通过字节码工具多生成一个class来实现的。

测试lambda表达式

第一步:修改test2的代码,把strategy1改用lambda表达式实现,称作test3

    Strategy strategy1 = msg -> "strategy1  : "+msg.toUpperCase() + "!";
Strategy strategy2 = new Strategy() {
@Override
public String approach(String msg) {
return "strategy2 : "+msg.toUpperCase() + "!";
}
};
Strategize s = new Strategize("Hello there");
s.communicate();
s.changeStrategy(strategy1);
s.communicate();
s.changeStrategy(strategy2);
s.communicate();

第二步:继续使用javac编译,结果只多出了一个class,名为Strategize$1,这是用匿名内部类产生的,但是lambda表达式的实现还看不到。但此时发现main()函数的代码在NetBeans中已经无法反编译出来,是NetBeans的反编译器不够强大?尝试使用在线反编译器,结果的部分如下

   public static void main(String[] param0) {
// $FF: Couldn't be decompiled
} // $FF: synthetic method
private static String lambda$main$0(String var0) {
return var0.toUpperCase();
}

第三步:使用javap反编译,可以看到在main()方法的后面多出了一个函数,如下描述

  private static java.lang.String lambda$main$0(java.lang.String);
descriptor: (Ljava/lang/String;)Ljava/lang/String;
flags: (0x100a) ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokevirtual #17 // Method java/lang/String.toUpperCase:()Ljava/lang/String;
4: invokedynamic #18, 0 // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
9: areturn
LineNumberTable:
line 48: 0

到此,我们只能见到,在第一次编译后仅仅是编译期多生成了一个函数,并没有为lambda表达式多生成一个class。

关于这个方法lambda$main$0的命名:以lambda开头,因为是在main()函数里使用了lambda表达式,所以带有$main表示,因为是第一个,所以$0。

第四步:运行Strategize,回到src目录,使用java 完整报名.Strategize,比如我使用的是java com.langdon.java.onjava8.functional.test3.Strategize,结果是直接运行的mian函数,类文件并没有发生任何变化。

第五步:加jvm启动属性,如果我们在启动JVM的时候设置系统属性"jdk.internal.lambda.dumpProxyClasses"的话,那么在启动的时候生成的class会保存下来。使用java命令如下

java -Djdk.internal.lambda.dumpProxyClasses com.langdon.java.onjava8.functional.test3.Strategize

此时,我看到了一个新的类,如下:

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class Strategize$$Lambda$1 implements Strategy {
private Strategize$$Lambda$1() {
} @Hidden
public String approach(String var1) {
return Strategize.lambda$main$0(var1);
}
}

synthetic class说明这个类是通过字节码工具自动生成的,注意到,这个类是final,实现了Strategy接口,接口是实现很简单,就是调用了第一次编译时候生产的Strategize.lambda$main$0()方法。从命名上可以看出这个类是实现lambda表达式的类和以及Strategize的内部类。

小结

lambda表达式与普通的匿名内部类的实现方式不一样,在第一次编译阶段只是多增了一个lambda方法,并通过invoke dynamic 指令指明了在第二次编译(运行)的时候需要执行的额外操作——第二次编译时通过java/lang/invoke/LambdaMetafactory.metafactory 这个工厂方法来生成一个class(其中参数传入的方法就是第一次编译时生成的lambda方法。)

这个操作最终还是会生成一个实现lambda表达式的内部类。

测试方法引用

为了测试方法引用(method reference),对上面的例子做了一些修改,具体看test4.

第一步:运行javac Strategize.java,并没有生产额外的.class文件,都是预定义的。这点与lambda表达式是一致的。但NetBeans对Strategize.class的mian()方法反编译失败,尝试使用上文提到的反编译器,结果也是一样。

第二步:尝试使用javap -v -p 反编译Strategize.class,发现与lambda表达式相似的地方

InnerClasses:
public static final #82= #81 of #87; // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #45 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:
#46 (Ljava/lang/String;)Ljava/lang/String;
#47 REF_invokeStatic com/langdon/java/onjava8/functional/test4/Unrelated.twice:(Ljava/lang/String;)Ljava/lang/String;
#46 (Ljava/lang/String;)Ljava/lang/String;
1: #45 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:
#46 (Ljava/lang/String;)Ljava/lang/String;
#52 REF_invokeVirtual com/langdon/java/onjava8/functional/test4/Unrelated.third:(Ljava/lang/String;)Ljava/lang/String;
#46 (Ljava/lang/String;)Ljava/lang/String;

从这里可以看出,方法引用的实现方式与lambda表达式是非常相似的,都是在第二次编译(运行)的时候调用java/lang/invoke/LambdaMetafactory.metafactory 这个工厂方法来生成一个class,其中方法引用不需要在第一次编译时生成额外的lambda方法。

第三步:使用jdk.internal.lambda.dumpProxyClasses参数运行。如下

java -Djdk.internal.lambda.dumpProxyClasses com.langdon.java.onjava8.functional.test4.Strategize

结果jvm额外生成了2个.class文件,Strategize$$Lambda$1 与 Strategize$$Lambda$2。从这点可以看出方法引用在第二次编译时的实现方式与lambda表达式是一样的,都是借助字节码工具生成相应的class。两个类的代码如下 (由NetBeans反编译得到)


//for Strategize$$Lambda$1
package com.langdon.java.onjava8.functional.test4; import java.lang.invoke.LambdaForm.Hidden; // $FF: synthetic class
final class Strategize$$Lambda$1 implements Strategy {
private Strategize$$Lambda$1() {
} @Hidden
public String approach(String var1) {
return Unrelated.twice(var1);
}
} // for Strategize$$Lambda$2
package com.langdon.java.onjava8.functional.test4; import java.lang.invoke.LambdaForm.Hidden; // $FF: synthetic class
final class Strategize$$Lambda$2 implements StrategyDev {
private final Unrelated arg$1; private Strategize$$Lambda$2(Unrelated var1) {
this.arg$1 = var1;
} private static StrategyDev get$Lambda(Unrelated var0) {
return new Strategize$$Lambda$2(var0);
} @Hidden
public String approach(String var1) {
return this.arg$1.third(var1);
}
}

小结

方法引用在第一次编译的时候并没有生产额外的class,也没有像lambda表达式那样生成一个static方法,而只是使用invoke dynamic标记了(这点与lambda表达式一样),在第二次编译(运行)时会调用java/lang/invoke/LambdaMetafactory.metafactory 这个工厂方法来生成一个class,其中参数传入的方法就是方法引用的实际方法。这个操作与lambda表达式一样都会生成一个匿名内部类。

三种实现方式的总结

方式 javac编译 javap反编译 jvm调参并第二次编译 (运行)
匿名内部类 额外生成class 未见invoke dynamic指令 无变化
lambda表达式 未生成class,但额外生成了一个static的方法 发现invoke dynamic 发现额外的class
方法引用 未额外生成 发现invoke dynamic 发现额外的class

对于lambda表达式,为什么java8要这样做?

下面的译本,原文Java-8-Lambdas-A-Peek-Under-the-Hood

匿名内部类具有可能影响应用程序性能的不受欢迎的特性。

  1. 编译器为每个匿名内部类生成一个新的类文件。生成许多类文件是不可取的,因为每个类文件在使用之前都需要加载和验证,这会影响应用程序的启动性能。加载可能是一个昂贵的操作,包括磁盘I/O和解压缩JAR文件本身。
  2. 如果lambdas被转换为匿名内部类,那么每个lambda都有一个新的类文件。由于每个匿名内部类都将被加载,它将占用JVM的元空间(这是Java 8对永久生成的替代)。如果JVM将每个此类匿名内部类中的代码编译为机器码,那么它将存储在代码缓存中。此外,这些匿名内部类将被实例化为单独的对象。因此,匿名内部类会增加应用程序的内存消耗。为了减少所有这些内存开销,引入一种缓存机制可能是有帮助的,这将促使引入某种抽象层。
  3. 最重要的是,从第一天开始就选择使用匿名内部类来实现lambdas,这将限制未来lambda实现更改的范围,以及它们根据未来JVM改进而演进的能力。
  4. 将lambda表达式转换为匿名内部类将限制未来可能的优化(例如缓存),因为它们将绑定到匿名内部类字节码生成机制。

基于以上4点,lambda表达式的实现不能直接在编译阶段就用匿名内部类实现

,而是需要一个稳定的二进制表示,它提供足够的信息,同时允许JVM在未来采用其他可能的实现策略。

解决上述解释的问题,Java语言和JVM工程师决定将翻译策略的选择推迟到运行时。Java 7 中引入的新的 invokedynamic 字节码指令为他们提供了一种高效实现这一目标的机制。将lambda表达式转换为字节码需要两个步骤:

  1. 生成 invokedynamic 调用站点 ( 称为lambda工厂 ),当调用该站点时,返回一个函数接口实例,lambda将被转换到该接口;
  2. 将lambda表达式的主体转换为将通过invokedynamic指令调用的方法。

为了演示第一步,让我们检查编译一个包含lambda表达式的简单类时生成的字节码,例如:

import java.util.function.Function;

public class Lambda {
Function<String, Integer> f = s -> Integer.parseInt(s);
}

这将转化为以下字节码:

 0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: invokedynamic #2, 0 // InvokeDynamic
#0:apply:()Ljava/util/function/Function;
10: putfield #3 // Field f:Ljava/util/function/Function;
13: return

注意,方法引用的编译略有不同,因为javac不需要生成合成方法,可以直接引用方法。

如何执行第二步取决于lambda表达式是非捕获 non-capturing (lambda不访问定义在其主体外部的任何变量) 还是捕获 capturing (lambda访问定义在其主体外部的变量),比如类成员变量。

非捕获 lambda简单地被描述为一个静态方法,该方法具有与lambda表达式完全相同的签名,并在使用lambda表达式的同一个类中声明。 例如,上面的Lambda类中声明的lambda表达式可以被描述为这样的方法,这个方法就在使用了lambda表达式的方法的下面生成。

static Integer lambda$1(String s) {
return Integer.parseInt(s);
}

捕获 lambda表达式的情况要复杂一些,因为捕获的变量必须与lambda的形式参数一起传递给实现lambda表达式主体的方法。在这种情况下,常见的转换策略是在lambda表达式的参数之前为每个捕获的变量添加一个额外的参数。让我们来看一个实际的例子:

int offset = 100;
Function<String, Integer> f = s -> Integer.parseInt(s) + offset;

可以生成相应的方法实现:

static Integer lambda$1(int offset, String s) {
return Integer.parseInt(s) + offset;
}

然而,这种翻译策略并不是一成不变的,因为使用invokedynamic指令可以让编译器在将来灵活地选择不同的实现策略。例如,可以将捕获的值封装在数组中,或者,如果lambda表达式读取使用它的类的某些字段,则生成的方法可以是实例方法,而不是声明为静态方法,从而避免了将这些字段作为附加参数传递的需要。

理论上的性能

第一步:是链接步骤,它对应于上面提到的lambda工厂步骤。如果我们将性能与匿名内部类进行比较,那么等效的操作将是装入匿名内部类。Oracle已经发布了Sergey Kuksenko关于这一权衡的性能分析,您可以看到Kuksenko在2013年JVM语言峰会[3]上发表了关于这个主题的演讲。分析表明,预热lambda工厂方法需要时间,在此期间,初始化速度较慢。当链接了足够多的调用站点时,如果代码处于热路径上(即,其中一个频繁调用,足以编译JIT)。另一方面,如果是冷路径 (cold path),lambda工厂方法可以快100倍。

第二步是:从周围范围捕获变量。正如我们已经提到的,如果没有要捕获的变量,那么可以自动优化此步骤,以避免使用基于lambda工厂的实现分配新对象。在匿名内部类方法中,我们将实例化一个新对象。为了优化相同的情况,您必须手动优化代码,方法是创建一个对象并将其提升到一个静态字段中。例如:

// Hoisted Function
public static final Function<String, Integer> parseInt = new Function<String, Integer>() {
public Integer apply(String arg) {
return Integer.parseInt(arg);
}
}; // Usage:
int result = parseInt.apply(“123”);

第三步:是调用实际的方法。目前,匿名内部类和lambda表达式都执行完全相同的操作,所以这里的性能没有区别。非捕获lambda表达式的开箱即用性能已经领先于提升的匿名内部类等效性能。捕获lambda表达式的实现与为捕获这些字段而分配匿名内部类的性能类似。

下文将讲述lambda表达式的实现在很大程度上执行得很好。虽然匿名内部类需要手工优化来避免分配,但是JVM已经为我们优化了这种最常见的情况(一个lambda表达式没有捕获它的参数)。

实测的性能

当然,很容易理解总体性能模型,但在实测中又会是怎样的?我们已经在一些软件项目中使用了Java 8,并取得了良好的效果。自动优化非捕获lambdas可以提供很好的好处。有一个特定的例子,它提出了一些关于未来优化方向的有趣问题。

所讨论的示例发生在处理系统中使用的一些代码时,这些代码需要特别低的GC暂停(理想情况下是没有暂停)。因此,最好避免分配太多的对象。该项目广泛使用lambdas来实现回调处理程序。不幸的是,我们仍然有相当多的回调,在这些回调中,我们没有捕获局部变量,而是希望引用当前类的一个字段,甚至只是调用当前类的一个方法。目前,这似乎仍然需要分配。

总结

在本文中,我们解释了lambdas不仅仅是底层的匿名内部类,以及为什么匿名内部类不是lambda表达式的合适实现方法。考虑lambda表达式实现方法已经做了大量工作。目前,对于大多数任务,它们都比匿名内部类更快,但目前的情况并不完美;测量驱动的手工优化仍有一定的空间。

不过,Java 8中使用的方法不仅限于Java本身。Scala历来通过生成匿名内部类来实现它的lambda表达式。在Scala 2.12中,虽然已经开始使用Java 8中引入的lambda元操作机制。随着时间的推移,JVM上的其他语言也可能采用这种机制。