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表达式到字节码的转换分两步执行:
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表达式是非捕获(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上的其他语言也可能采用这种机制。
原文地址:https://www.infoq.com/articles/Java-8-Lambdas-A-Peek-Under-the-Hood/
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2339.html
暂无评论