Java8 Lambda和Invokedynamic详情
作者:sofia
一、阐明lambda
Java8于2014年3月发布,并引入了lambda
表达式作为其旗舰功能。我们可能已经在代码库中使用它们来编写更简洁、更灵活的代码。例如,我们可以将lambda
表达式与新的Streams API
结合起来,以表达丰富的数据处理查询:
int total = invoices.stream() .filter(inv -> inv.getMonth() == Month.JULY) .mapToInt(Invoice::getAmount) .sum();
此示例显示如何从发票集合中计算7月份到期的总金额。传递lambda
表达式以查找月份为7月的发票,并传递方法引用以从发票中提取金额。
您可能想知道Java编译器如何在幕后实现lambda表达式和方法引用,以及Java虚拟机(JVM)如何处理它们。例如,lambda表达式只是匿名内部类的语法糖吗?毕竟,可以通过将lambda表达式的主体复制到匿名类的相应方法的主体中来翻译上面的代码
int total = invoices.stream() .filter(new Predicate<Invoice>() { @Override public boolean test(Invoice inv) { return inv.getMonth() == Month.JULY; } }) .mapToInt(new ToIntFunction<Invoice>() { @Override public int applyAsInt(Invoice inv) { return inv.getAmount(); } }) .sum();
本文将解释为什么Java编译器不遵循这种机制,并将阐明lambda
表达式和方法引用是如何实现的。我们将研究字节码生成,并在实验室中简要分析lambda
性能。最后,我们将讨论现实世界中的性能影响。
二、匿名内部类
匿名内部类具有可能影响应用程序性能的不良特征。
首先,编译器为每个匿名内部类生成一个新的类文件。文件名通常看起来像ClassName$1
,其中ClassName
是定义匿名内部类的类的名称,后跟一个美元符号和一个数字。生成许多类文件是不可取的,因为每个类文件在使用之前都需要加载和验证,这会影响应用程序的启动性能。加载可能是一项昂贵的操作,包括磁盘I/O和解压缩JAR文件本身。
如果将lambda
转换为匿名内部类,则每个lambda
都会有一个新的类文件。由于每个匿名内部类都将被加载,因此它将占用JVM元空间的空间(这是永久生成的Java8
替代品)。如果JVM将每个匿名内部类中的代码编译成机器代码,那么它将存储在代码缓存中。此外,这些匿名内部类将被实例化为单独的对象。因此,匿名内部类会增加应用程序的内存消耗。引入缓存机制以减少所有这些内存开销可能会有所帮助,这促使引入某种抽象层。
最重要的是,从第一天起选择使用匿名内部类实现lambda
将限制未来lambda
实现更改的范围,以及它们根据未来JVM改进而发展的能力。
让我们看一下以下代码:
import java.util.function.Function; public class AnonymousClassExample { Function<String, String> format = new Function<String, String>() { public String apply(String input){ return Character.toUpperCase(input.charAt(0)) + input.substring(1); } }; }
我们可以使用命令检查为任何类文件生成的字节码
javap -c -v ClassName
为作为匿名内部类创建的函数生成的相应字节码如下所示:
0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: aload_0 5: new #2 // class AnonymousClassExample$1 8: dup 9: aload_0 10: invokespecial #3 // Method AnonymousClass$1."<init>":(LAnonymousClassExample;)V 13: putfield #4 // Field format:Ljava/util/function/Function; 16: return
此代码显示以下内容:
- 5:使用字节码操作new实例化匿名类示例$1类型的对象。同时在堆栈上推送对新创建对象的引用。
- 8:dup操作在堆栈上复制该引用。
- 10:然后,该值由
invokespecial
指令使用,该指令初始化匿名内部类实例。 - 13:堆栈顶部现在仍然包含对对象的引用,该引用使用
putfield
指令存储在AnonymousClassExample
类的format
字段中。
AnonymousClassExample$1
是编译器为匿名内部类生成的名称。如果您想让自己放心,还可以检查AnonymousClassExample$1
类文件,您将找到函数接口实现的代码。
将lambda
表达式转换为匿名内部类将限制未来可能的优化(例如缓存),因为它们与匿名内部类字节码生成机制相关联。因此,语言和JVM工程师需要一个稳定的二进制表示,该表示提供了足够的信息,同时允许JVM在将来使用其他可能的实现策略。下一节将解释这是如何实现的!
三、Lambdas和Invokedynamic
为了解决上一节中解释的问题,Java语言和JVM工程师决定将转换策略的选择推迟到运行时。Java7引入的新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表达式是非捕获(lambda
不访问在其主体外部定义的任何变量)还是捕获(lambda访问在其主体外部定义的变量)。
非捕获lambda
被简单地分解为一个静态方法,该方法具有与lambda
表达式完全相同的签名,并在使用lambda
表达式的同一类中声明。例如,可以将上面lambda
类中声明的lambda
表达式分解为如下方法:
static Integer lambda$1(String s) { return Integer.parseInt(s); }
注意:$1
不是一个内部类,它只是我们表示编译器生成代码的方式
捕获lambda
表达式的情况稍微复杂一些,因为捕获的变量必须与lambda
的形式参数一起传递给实现lambda
表达式主体的方法。在这种情况下,常见的转换策略是在lambda
表达式的参数前面加上每个捕获变量的附加参数。让我们看一个实际的例子:
int offset = 100; Function<String, Integer> f = s -> Integer.parseInt(s) + offset;
相应的方法实现可以通过asy生成:
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的路径),则性能与类加载一致。另一方面,如果是冷路径,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表达式)。
当然,理解整体性能模型是很好的,但是在实践中,事情是如何叠加的呢?我们已经在一些软件项目中使用了Java8,并取得了积极的成果。自动优化非捕获lambda
可以提供很好的好处。这里有一个特别的例子,它提出了一些关于未来优化方向的有趣问题。
所讨论的示例发生在处理某些代码以供系统使用时,该系统需要特别低的GC暂停,理想情况下没有。因此,希望避免分配太多的对象。该项目广泛使用lambdas来实现回调处理程序。不幸的是,我们仍然有相当多的回调,其中我们没有捕获局部变量,但希望引用当前类的字段,甚至只调用当前类上的方法。目前,这似乎仍然需要分配。下面是一个代码示例,旨在阐明我们所讨论的内容:
public MessageProcessor() {} public int processMessages() { return queue.read(obj -> { if (obj instanceof NewClient) { this.processNewClient((NewClient) obj); } ... }); }
这个问题有一个简单的解决办法。我们将代码提升到构造函数中,并将其分配给一个字段,然后在调用站点直接引用该字段。下面是我们之前重写的代码示例:
private final Consumer<Msg> handler; public MessageProcessor() { handler = obj -> { if (obj instanceof NewClient) { this.processNewClient((NewClient) obj); } ... }; } public int processMessages() { return queue.read(handler); }
在所讨论的项目中,这是一个严重的问题:内存分析显示,此模式负责前八个对象分配站点中的六个,以及应用程序总分配的60%以上。
与任何潜在的优化一样,无论环境如何,应用这种方法都可能会带来其他问题。
您选择编写非惯用代码纯粹是出于性能原因。因此有一个可读性权衡
这也关系到分配的权衡。您正在向MessageProcessor
添加一个字段,使其更大,以便分配。相关lambda
的创建和捕获也会减慢对MessageProcessor
的构造函数调用。
我们不是通过寻找场景,而是通过内存分析发现了这种情况,并且有一个很好的业务用例证明了优化的合理性。我们还处于这样一个位置:对象只分配一次,大量重用lambda
表达式,因此缓存非常有益。与任何性能调整练习一样,通常推荐使用科学方法。
这也是任何其他最终用户寻求优化其lambda
表达式使用的方法。尝试编写干净、简单且功能强大的代码始终是最好的第一步。任何优化,如本次吊装,应仅针对真正的问题进行。编写捕获分配对象的lambda
表达式本身并不坏——正如编写调用'new Foo()
'的Java
代码本身也不坏一样。
这一经验也确实表明,要充分利用lambda
表达式,重要的是要习惯地使用它们。如果lambda
表达式用于表示小的纯函数,则它们几乎不需要从其周围范围捕获任何内容。和大多数事情一样,如果你保持简单,事情就会表现得很好。
结论
在本文中,我们解释了lambda不仅仅是隐藏的匿名内部类,以及为什么匿名内部类不是lambda
表达式的合适实现方法。通过lambda
表达式实现方法,已经进行了大量的工作。目前,对于大多数任务,它们都比匿名内部类快,但当前的状态并不完美;测量驱动的手动优化仍有一定的空间。
Java8中使用的方法不仅仅局限于Java本身。Scala历来通过生成匿名内部类来实现其lambda
表达式。在Scala2.12中,我们已经开始使用Java8中引入的lambda
元工厂机制。随着时间的推移,JVM上的其他语言也可能采用这种机制。
到此这篇关于Java8 Lambda
和Invokedynamic
详情的文章就介绍到这了,更多相关Java8 Lambda
和Invokedynamic
内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!