Java 8 于 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 表达式和方法引用,以及 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 是定义匿名内部类的类名称。生成很多类文件是不可取的,因为每个类文件在使用前都需要加载和验证,这会影响应用程序的启动性能。
如果将 lambda 转换为匿名内部类,则每个 lambda 都会有一个新的类文件。由于每个匿名内部类都会被加载,它会占用 JVM 的元空间。此外,这些匿名内部类将被实例化为单独的对象。因此,匿名内部类会增加应用程序的内存消耗。
最重要的是,从一开始就选择使用匿名内部类来实现 lambdas 将限制未来 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 实例化 AnonymousClassExample$1 类型的对象。新创建对象的引用同时被压入堆栈。
- 8:操作 dup 复制堆栈上的引用。
- 10:该值随后被 invokespecial 指令使用,该指令初始化匿名内部类实例。
- 13:堆栈顶部现在仍然包含对对象的引用,该对象使用 putfield 指令存储在 AnonymousClassExample 类的格式字段中。
将 lambda 表达式转换为匿名内部类将限制未来可能的优化(例如缓存),因为它们将与匿名内部类字节码生成机制相关联。
Lambdas 和 invokedynamic
为了解决上一节中解释的问题,Java将翻译策略的选择推迟到运行时。 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
第二步如何执行取决于 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;
}
lambda的性能特征
这种方法的主要优点是性能特征。
第一部分是链接步骤,对应上面提到的 lambda 工厂步骤。 如果我们将性能与匿名内部类进行比较,那么等效的操作将是匿名内部类的类加载。当有足够多的调用时,性能与类加载一致。如果调用较少,则 lambda 工厂方法可以快 100 倍。
第二步是捕获变量。如果没有要捕获的变量,那么可以自动优化 lambda 工厂,避免分配新对象。 而在匿名内部类方法中,我们将实例化一个新对象。
第三步是调用实际方法。目前匿名内部类和 lambda 表达式执行完全相同的操作,因此这里的性能没有差异。
我们在本节中看到的是,从广义上讲,lambda 表达式的实现表现良好。
lambda性能优化
非捕获 lambda 的自动优化可以提供很多优点。下面的例子提出了一些关于未来优化的方向。该示例发生在需要特别低的 GC 的系统中。 因此希望避免分配太多对象。 该项目广泛使用 lambda 来实现回调处理程序。 不幸的是,仍然有很多回调没有捕获局部变量,而是想要引用当前类的字段。下面是一个代码示例,:
public MessageProcessor() {}
public int processMessages() {
return queue.read(obj -> {
if (obj instanceof NewClient) {
this.processNewClient((NewClient) obj);
}
...
});
}
这个问题有一个简单的解决方案。 我们将handler提升到构造函数中并将其分配给一个字段,然后直接在调用点引用该字段:
private final Consumer<Msg> handler;
public MessageProcessor() {
handler = obj -> {
if (obj instanceof NewClient) {
this.processNewClient((NewClient) obj);
}
...
};
}
public int processMessages() {
return queue.read(handler);
}
与任何潜在的优化一样,应用这种方法可能会引入其他问题:
- 非常规的写法,引发可读性权衡;
- MessageProcessor 添加一个字段,其对象分配更大,也会减慢构造函数的调用。
进行这种优化的时机不是通过寻找场景,而是通过内存分析。并且还需有一个合适的业务场景来证明优化的合理性。例如,上面的例子中,我们只分配了一次对象,却大量复用 lambda 表达式,因此缓存非常有用。
这是任何其他寻求优化 lambda 表达式的用户应该采用的方法。编写干净、实用的代码始终是最好的第一步。任何优化都应该只针对真正的问题进行。这一经验也表明,要以惯用方式使用 lambda 表达式。如果 lambda 表达式用于表示小的函数,则几乎不需要从周围捕获任何内容。
Conclusions
在本文中,我们解释了 lambda 不仅仅是底层的匿名内部类,以及为什么匿名内部类不是 lambda 表达式的合适实现方法。 通过 lambda 表达式实现方法已经进行了大量工作。 目前它们在大多数任务上都比匿名内部类快,但目前的情况并不完美; 手动优化仍有一定的空间。