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

23次阅读

共计 10156 个字符,预计需要花费 26 分钟才能阅读完成。

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

编译 javac fileName.java

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

思路二:运行阶段保留 jvm 生成的类 java -Djdk.internal.lambda.dumpProxyClasses fileName.class
不错的博客:https://blog.csdn.net/zxhoo/a…

本人旨在探讨匿名内部类、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$1 和 Strategize$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
匿名内部类具有可能影响应用程序性能的不受欢迎的特性。

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

基于以上 4 点,lambda 表达式的实现不能直接在编译阶段就用匿名内部类实现,而是需要一个稳定的二进制表示,它提供足够的信息,同时允许 JVM 在未来采用其他可能的实现策略。解决上述解释的问题,Java 语言和 JVM 工程师决定将翻译策略的选择推迟到运行时。Java 7 中引入的新的 invokedynamic 字节码指令为他们提供了一种高效实现这一目标的机制。将 lambda 表达式转换为字节码需要两个步骤:

生成 invokedynamic 调用站点 (称为 lambda 工厂),当调用该站点时,返回一个函数接口实例,lambda 将被转换到该接口;
将 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 上的其他语言也可能采用这种机制。

正文完
 0