揭开Java|揭开Java 8 Lambda表达式的神秘面纱

英文原文

揭开Java|揭开Java 8 Lambda表达式的神秘面纱
文章图片
Java 8 Lambda
Java 8是在2014年3月发布的,其中一个标志性的特性就是lambda表达式。可能你已经开始使用这些特性来编写更加精简的代码了。举一个简单地例子,你可以组合使用lambda表达式和Stream API来完成丰富的数据处理:

int total = invoices.stream() .filter(inv -> inv.getMonth() == Month.JULY) .mapToInt(Invoice::getAmount) .sum();

这个例子展示了如何从一组发票里提取七月份的发票并统计总金额。直接通过一个lambda表达式来过滤出7月份的发票,然后再通过一个方法引用(method reference)来获得发票的金额,最后求和即可。
这时候,你可能会在想Java编译器是如何实现lambda表达式以及方法引用(method reference)的,以及Java虚拟机(JVM)是如何处理它们的。例如,lambda表达式只是针对匿名内部类(anonymous inner class)的语法糖吗?或者说,上面的代码只需要把lambda表达式里的代码拷贝到一个匿名内部来来就可以实现了(我不鼓励你这样去看待它!):
int total = invoices.stream() .filter(new Predicate() { @Override public boolean test(Invoice inv) { return inv.getMonth() == Month.JULY; } }) .mapToInt(new ToIntFunction() { @Override public int applyAsInt(Invoice inv) { return inv.getAmount(); } }) .sum();

这篇里文章我会讲解为什么Java编译器没有按照刚刚说的机制来实现lambda表达式,并且会简单讲解lambda表达式和方法引用(method referen)是如何实现的。然后,我们还会剖析最终生成的字节码并且简单分析它的性能。最后,还会讨论现实情况下的性能问题。
为什么不用匿名内部类? 匿名内部类最典型的一个问题就是对应用的性能有影响。
首先,编译器会为每个匿名内部类单独生成一个类文件。这个类文件的名字都是类似ClassName$1的格式,其中,ClassName是匿名内部类所在的类名,接着是个$符号加一个数字。生成很多匿名内部类的方式是很不现实的,因为每个匿名内部类在使用之前都要被加载和验证,这个会影响应用的启动性能。而且类加载本身也是个很费资源的操作,它会消耗磁盘I/O,同时还需要对JAR包进行解压。
如果lambda表达式都被翻译成匿名内部类来实现的话,那么对于每个lambda表达式都会生成一个新的类文件。每个匿名内部类都需要被加载,这将会消耗JVM meta-space(Java 8 里替代Permanent Generation)的空间。然后每个匿名内部类里的代码都被JVM编译成机器码,那么它们都要被存放在代码缓存区(code cache)里从而占用缓存。除此之外,这些匿名内部类都要被实例化成单独的对象。这样一来,匿名内部类的实现就会增加你的应用的内存消耗。如果在这中间加入一个缓存机制的话,是极有可能减少内存的消耗的,这就是我们想引入一个中间层来解决这个问题的动机。
我们来看看这段代码:
import java.util.function.Function; public class AnonymousClassExample { Function format = new Function() { public String apply(String input){ return Character.toUpperCase(input.charAt(0)) + input.substring(1); } }; }

你可以通过下面的命令来查看类文件的字节码
javap -c -v ClassName
对应Function所生成的字节码和下面的类似:
0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()V 4: aload_0 5: new#2 // class AnonymousClassExample$1 8: dup 9: aload_0 10: invokespecial #3 // Method AnonymousClass$1."":(LAnonymousClassExample; )V 13: putfield#4 // Field format:Ljava/util/function/Function; 16: return

上面的代码的逻辑大致如下:
  • 5: 一个AnonymousClassExample$1的实例通过new操作符进行了初始化。同时这个新创建实例的引用被放入到栈顶。
  • 8:dup操作符复制了栈顶的这个引用。
  • 10:然后这个引用值被invokeSpecial质量作为参数来初始化匿名内部类的实例。
  • 13:现在栈顶的值依旧是这个实例的引用,通过putfiled指令,这个引用被保存到AnonymousClassExample$1format成员里。
AnonymousClassExample$1 是编译器为匿名内部类所生成的类名。如果你想自己来验证的话,你可以自己打开AnonymousClassExample$1 的类文件,你会找到Function接口的实现代码。
把Lambda表达式翻译成匿名内部类的这种实现方式对于后期实现上的优化(例如缓存方面)会有影响,因为这个实现依赖于匿名内部类的字节码生成机制。因此,java语言本身以及JVM的工程师们都亟需一个稳定的二进制方案,这个方案也能需要能够为未来JVM采用新的实现策略提供做够的上下文信息。下一节,我们会讲述如何实现这一点。
Lambda和invokedynamic指令 为了解决前面提到的那个问题,Java语言本身以及JVM的工程师都采用把lambda表达式的翻译策略推迟到运行时再来决定的方案。Java 7里新引入的invokedynamic给了他们一个可以有效实现这种策略的途径。Lambda表达式翻译成字节码的步骤分为两步:
  • 生成一个invokedynamic调用点(call site)(也叫lambda工厂),当它被调用的时候的时候,它会返回一个由lambda转换成的Function Interface 实例。
  • 将lambda表达式的代码转换成一个可以通过invokespecial命令调用的函数。
为了展示上面的第一步,我们先来看看只包含一个lambda表达式的类所生成的字节码:
import java.util.function.Function; public class Lambda { Function f = s -> Integer.parseInt(s); }

上面的类会被翻译成:
0: aload_0 1: invokespecial #1 // Method java/lang/Object."":()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

值得注意的是方法引用(method reference)的编译结果有点不一样,因为javac并不需要生成一个可以直接引用的方法。
第二步的实现取决于lambda表达式是非捕获式(non-capturing)的还是捕获式的(capturing)。非捕获式的也就是说lambda表达式不会访问任何它外部的变量,捕获式的lambda会访问在lambda外部定义的变量。
【揭开Java|揭开Java 8 Lambda表达式的神秘面纱】非捕获式(non-capturing)的lambda会被去掉语法糖直接翻译成当前类里和lambda表达式有相同签名的静态函数。以上面的lambda表达式为例,它会被去糖替换成下面的方法:
static Integer lambda$1(String s) { return Integer.parseInt(s); }

注意:$1不是匿名内部类,它只是表示这段代码是由编译器生成的。
但是捕获式(capturing)的lambda就有点复杂了,因为被捕获的变量需要和lambda的参数一起传入到lambda表达式里去执行。这种情况下的转换策略是将捕获到的变量追加到lambda表达式的参数里。我们来看一个实际的例子:
int offset = 100; Function f = s -> Integer.parseInt(s) + offset;

对应的生成的代码大概如下所示:
static Integer lambda$1(int offset, String s) { return Integer.parseInt(s) + offset; }

不过,这个翻译策略也不一定是正确的,因为invokedynamic指令本身给编译器的策略提供了很大的选择空间。例如,捕获的变量也可以放在一个数组里,也或者如果lambda表达式读取了它所在类的变量,那么生成的方法也可以是实例方法,而不是静态方法,这样就可以不需要把这些变量作为额外的参数传递给lambda了。
实验情况下的性能 这种实现最大的优势就是性能有所提升。当然了,如果能够有个单一具体的数据来说明就最好了,但是这中间涉及到很多个阶段,每个阶段的耗时都不一样。
第一个阶段就是链接阶段,这个对应上面提到的lambda工厂。如果我们和匿名内部类来对比的话,这个阶段就对应到匿名内部类本身的加载了。Oracle发布了Sergey Kuksenko 编写的针对这个实现的性能分析(performance analysis),你也可以参考Kuksenko在2013年JVM语言峰会(JVM Language Summit)上的演讲deliver a talk on the topic。这个分析里说明了lambda工厂需要一定的时间来启动,第一次调用比较慢。当足够的调用点(call site)被链接起来代码成为热点(例如,代码调用足够频繁被JIT编译)之后,它的性能就能赶上类加载了。
第二个阶段是从上下文里捕获变量。正如我们已经提到过,如果没有变量被捕获的话,基于lambda工厂的实现可以进一步优化来避免创建新的对象。在匿名内部类里,我们就需要创建一个新的实例了。如果要达到相同的优化效果,你需要自己手动创建一个实例对象,然后用一个静态变量来引用它。例如:
// Hoisted Function public static final Function parseInt = new Function() { public Integer apply(String arg) { return Integer.parseInt(arg); } }; // Usage: int result = parseInt.apply(“123”);

第三部是调用实际的方法。这个阶段,无论是匿名内部类还是lambda表达式都是调用相同的代码,所以这个地方性能上没有差别。对于非捕获(non-capturing)的场景,lambda表达式已经优于匿名内部类的实现了。对于捕获式(capturing)的场景,lambda表达式的实现和创建一个匿名内部类来保存变量的性能大同小异。
我们这里看到的是一个大体上性能比较不错的lambda表达式的实现。对于匿名内部类方式需要手动优化避免对象创建的这种场景的场景(非捕获式的lambda表达式)已经被JVM进行优化了。
实际场景下的性能 如果只是想简单了解一下性能模型也是很不错的,但是有时候我们也会问实际上表现如何呢?我们在好几个软件项目上都已经使用了Java 8,并且效果都很不错。对于非捕获(non-capturing)lambda的自动优化也是一个很不错的功能。还有一个有趣的例子,它对于未来的优化方向提出了一些有趣的问题。
这个有问题的例子时出现在一个需要尽量减少GC的系统上,但是事实上确没有这样。这个实现原本是为了避免创建太多对象。它里面大量使用了lambda表达式来作为进行回调处理。不幸的是,我们有好几个回调虽然没有捕获局部变量,但是需要引用当前类的成员变量或者函数。目前来看,好像还是会导致对象的创建。下面是作为说明的实例代码:
public MessageProcessor() {} public int processMessages() { return queue.read(obj -> { if (obj instanceof NewClient) { this.processNewClient((NewClient) obj); } ... }); }

对于这个问题,我们有个很简单的解决方案。就是把这段代码抽取到构造函数里,然后用一个变量来引用调用点(call site)。下面是重写后的代码:
private final Consumer handler; public MessageProcessor() { handler = obj -> { if (obj instanceof NewClient) { this.processNewClient((NewClient) obj); } ... }; } public int processMessages() { return queue.read(handler); }

在这个有问题的项目里,内存诊断显示内存占用量排前八的地方有六个是出自这里这个模式产生的对象,占用应用总内存的60%。
但是使用这种方式来优化,也存在着其他问题。
  1. 这里纯粹是为了性能才写这样不符合规范的代码。所以会导致可读性降低。
  2. 这里也有其他内存分配的问题。你在MessageProcessor里添加了字段,导致它需要占用更多内存。同时,lambda的创建以及变量的捕获都会导致MessageProcessor的构造函数变慢。
我们之所以会有这样的方案,并不是实际有这样的场景,而是通过内存诊断才发现这个问题的,然后我们恰好有个合适的业务场景证实了这个优化的可行性。我们也会有只创建一次对象,然后频繁使用lambda表达式的场景,这样的话缓存就变得非常有用了。和其他所有内存调优实践一样,科学的方法往往都是最值得推荐的。
这个方法也适用于其他想要对lambda表达式进行调优的场景。首先尽量编写干净、简单以及函数式的代码。任何优化,例如这种抽取,都是尽量用来对付一些棘手的问题。编写需要捕获创建对象的lambda表达式并不是坏事 — 就像用Java代码来调用new Foo()本身就没有任何问题一样。
这个实践也向我们建议使用lambda表达式的最佳方案就是按照常规编码习惯来用。如果lambda表达式只是用来表示小的,纯函数式的功能,那么它完全没有必要去捕获上下文的变量。就像其他所有事情一样 — 越简单越高效。
结论 在这篇文章里,我们说明了lambda表达式不是由匿名内部类来实现的,同时也阐述了匿名内部类不是合适的方案的原因。对于lambda表达式的实现,目前已经有很多人投入了大量的工作。目前的实现,在很多情况下都是比匿名内部类要快的,尽管如此,目前的方案还不是完美的,还是存在很多需要手动去诊断调优的场景。
最后,Java 8里使用的方案也并不局限于Java自身。Scala也曾经通过生成匿名内部类来实现lambda表达式。在Scala 2.12版本里,已经改成使用Java 8 里引入的lambda工厂的方式了。随着时间的推移,其他的JVM语言也会慢慢都采用这种机制的。

    推荐阅读