2014 年 3 月,Java 8 发布,Lambda 表达式作为一项重要的特性随之而来。或许现在你已经在使用 Lambda 表达式来书写简洁灵活的代码。比如,你可以使用 Lambda 表达式和新增的流相关的 API,完成如下的大量数据的查询处理:
int total = invoices<span>.stream</span>() <span>.filter</span>(inv -> inv<span>.getMonth</span>() == Month<span>.JULY</span>) <span>.mapToInt</span>(Invoice::getAmount) <span>.sum</span>()
上面的示例代码描述了如何从一打发票中计算出 7 月份的应付款总额。其中我们使用 Lambda 表达式过滤出 7 月份的发票,使用方法引用来提取出发票的金额。
到这里,你可能会对 Java 编译器和 JVM 内部如何处理 Lambda 表达式和方法引用比较好奇。可能会提出这样的问题,Lambda 表达式会不会就是匿名内部类的语法糖呢?毕竟上面的示例代码可以使用匿名内部类实现,将 Lambda 表达式的方法体实现移到匿名内部类对应的方法中即可,但是我们并不赞成这样做。如下为匿名内部类实现版本:
重要通知:接下来 InfoQ 将会选择性地将部分优秀内容首发在微信公众号中,欢迎关注 InfoQ 微信公众号第一时间阅读精品内容。
<span>int</span> total = invoices.stream() .filter(<span>new</span> Predicate<Invoice>() { <span>@Override</span> <span>public</span> <span>boolean</span> <span>test</span>(Invoice inv) { <span>return</span> inv.getMonth() == Month.JULY; } }) .mapToInt(<span>new</span> ToIntFunction<Invoice>() { <span>@Override</span> <span>public</span> <span>int</span> <span>applyAsInt</span>(Invoice inv) { <span>return</span> inv.getAmount(); } }) .sum();
本文将会介绍为什么 Java 编译器没有采用内部类的形式处理 Lambda 表达式,并解密 Lambda 表达式和方法引用的内部实现。接着介绍字节码生成并简略分析 Lambda 表达式理论上的性能。最后,我们将讨论一下实践中 Lambda 表达式的性能问题。
为什么匿名内部类不好?
实际上,匿名内部类存在着影响应用性能的问题。
首先,编译器会为每一个匿名内部类创建一个类文件。创建出来的类文件的名称通常按照这样的规则 ClassName 符合和数字。生成如此多的文件就会带来问题,因为类在使用之前需要加载类文件并进行验证,这个过程则会影响应用的启动性能。类文件的加载很有可能是一个耗时的操作,这其中包含了磁盘 IO 和解压 JAR 文件。
假设 Lambda 表达式翻译成匿名内部类,那么每一个 Lambda 表达式都会有一个对应的类文件。随着匿名内部类进行加载,其必然要占用 JVM 中的元空间(从 Java 8 开始永久代的一种替代实现)。如果匿名内部类的方法被 JIT 编译成机器代码,则会存储到代码缓存中。同时,匿名内部类都需要实例化成独立的对象。以上关于匿名内部类的种种会使得应用的内存占用增加。因此我们有必要引入新的缓存机制减少过多的内存占用,这也就意味着我们需要引入某种抽象层。
最重要的,一旦 Lambda 表达式使用了匿名内部类实现,就会限制了后续 Lambda 表达式实现的更改,降低了其随着 JVM 改进而改进的能力。
我们看一下下面的这段代码:
import java.util.<span>function</span>.<span>Function</span>; <span>public</span> <span>class</span> AnonymousClassExample { <span>Function</span><<span>String</span>, <span>String</span>> format = <span>new</span> <span>Function</span><<span>String</span>, <span>String</span>>() { <span>public</span> <span>String</span> apply(<span>String</span> input){ return Character.toUpperCase(input.charAt(<span>0</span>)) + input.substring(<span>1</span>); } }; }
使用这个命令我们可以检查任何类文件生成的字节码
javap <span>-c</span> <span>-v</span> ClassName
示例中使用 Function 创建的匿名内部类对应的字节码如下:
<span>0</span>: <span>aload_0 </span> <span>1</span>: <span>invokespecial #1 // Method java/lang/Object."<init>":()V</span> <span>4</span>: <span>aload_0 </span> <span>5</span>: <span>new #2 // class AnonymousClassExample$1</span> <span>8</span>: <span>dup </span> <span>9</span>: <span>aload_0 </span> <span>10</span>: <span>invokespecial #3 // Method AnonymousClass$1."<init>":(LAnonymousClassExample;)V</span> <span>13</span>: <span>putfield #4 // Field format:Ljava/util/function/Function;</span> <span>16</span>: <span>return </span>
上述字节码的含义如下:
- 第 5 行,使用字节码操作 new 创建了类型 AnonymousClassExample$1 的一个对象,同时将新创建的对象的的引用压入栈中。
- 第 8 行,使用 dup 操作复制栈上的引用。
- 第 10 行,上面的复制的引用被指令 invokespecial 消耗使用,用来初始化匿名内部类实例。
- 第 13 行,栈顶依旧是创建的对象的引用,这个引用通过 putfield 指令保存到 AnonymousClassExample 类的 format 属性中。
AnonymousClassExample1 这个类文件,你会发现这个类就是 Function 接口的实现。
将 Lambda 表达式翻译成匿名内部类会限制以后可能进行的优化(比如缓存)。因为一旦使用了翻译成匿名内部类形式,那么 Lambda 表达式则和匿名内部类的字节码生成机制绑定。因而,Java 语言和 JVM 工程师需要设计一个稳定并且具有足够信息的二进制表示形式来支持以后的 JVM 实现策略。下面的部分将介绍不使用匿名内部类机制,Lambda 表达式是如何工作的。
Lambdas 表达式和 invokedynamic
为了解决前面提到的担心,Java 语言和 JVM 工程师决定将翻译策略推迟到运行时。利用 Java 7 引入的 invokedynamic 字节码指令我们可以高效地完成这一实现。将 Lambda 表达式转化成字节码只需要如下两步:
1. 生成一个 invokedynamic 调用点,也叫做 Lambda 工厂。当调用时返回一个 Lambda 表达式转化成的函数式接口实例。
2. 将 Lambda 表达式的方法体转换成方法供 invokedynamic 指令调用。
为了阐明上述的第一步,我们这里举一个包含 Lambda 表达式的简单类:
import java.util.function.<span><span>Function</span>;</span> <span>public</span> <span><span>class</span> <span>Lambda</span> {</span> <span><span>Function</span><<span>String</span>, <span>Integer</span>> <span>f</span> = <span>s</span> -> <span>Integer</span>.<span>parseInt</span><span>(s)</span>;</span> }
查看上面的类经过编译之后生成的字节码:
<span>0</span>: aload_0 <span>1</span>: invokespecial <span>#1</span> <span>4</span>: aload_0 <span>5</span>: invokedynamic <span>#2</span>, <span>0</span> <span>#0</span>:apply:()Ljava/util/<span><span>function</span>/<span>Function</span>;</span> <span>10</span>: putfield <span>#3</span> <span>13</span>: return
需要注意的是,方法引用的编译稍微有点不同,因为 javac 不需要创建一个合成的方法,javac 可以直接访问该方法。
Lambda 表达式转化成字节码的第二步取决于 Lambda 表达式是否为对变量捕获。Lambda 表达式方法体需要访问外部的变量则为对变量捕获,反之则为对变量不捕获。
对于不进行变量捕获的 Lambda 表达式,其方法体实现会被提取到一个与之具有相同签名的静态方法中,这个静态方法和 Lambda 表达式位于同一个类中。比如上面的那段 Lambda 表达式会被提取成类似这样的方法:
static <span>Integer</span> lambda$<span>1</span>(<span>String</span> s) { <span>return</span> <span>Integer</span><span>.</span>parseInt(s); }
需要注意的是,这里的 $1 并不是代表内部类,这里仅仅是为了展示编译后的代码而已。
对于捕获变量的 Lambda 表达式情况有点复杂,同前面一样 Lambda 表达式依然会被提取到一个静态方法中,不同的是被捕获的变量同正常的参数一样传入到这个方法中。在本例中,采用通用的翻译策略预先将被捕获的变量作为额外的参数传入方法中。比如下面的示例代码:
int offset = <span>100</span>; <span>Function</span><<span>String</span>, <span>Integer</span>> f = s -> <span>Integer</span>.parseInt(s) + offset;
对应的翻译后的实现方法为:
static <span>Integer</span> lambda$<span>1</span>(int offset, <span>String</span> s) { <span>return</span> <span>Integer</span><span>.</span>parseInt(s) <span>+</span> offset; }
需要注意的是编译器对于 Lambda 表达式的翻译策略并非固定的,因为这样 invokedynamic 可以使编译器在后期使用不同的翻译实现策略。比如,被捕获的变量可以放入数组中。如果 Lambda 表达式用到了类的实例的属性,其对应生成的方法可以是实例方法,而不是静态方法,这样可以避免传入多余的参数。
性能分析
Lambda 表达式最主要的优势表现在性能方面,虽然使用它很轻松的将很多行代码缩减成一句,但是其内部实现却不这么简单。下面对内部实现的每一步进行性能分析。
第一步就是连接,对应的就是我们上面提到的 Lambda 工厂。这一步相当于匿名内部类的类加载过程。来自 Oracle 的 Sergey Kuksenko 发布过相关的性能报告,并且他也在2013 JVM 语言大会就该话题做过分享。报告表明,Lambda 工厂的预热准备需要消耗时间,并且这个过程比较慢。伴随着更多的调用点连接,代码被频繁调用后(比如被 JIT 编译优化)性能会提升。另一方面如果连接处于不频繁调用的情况,那么 Lambda 工厂方式也会比匿名内部类加载要快,最高可达 100 倍。
第二步就是捕获变量。正如我们前面提到的,如果是不进行捕获变量,这一步会自动进行优化,避免在基于 Lambda 工厂实现下额外创建对象。对于匿名内部类而言,这一步对应的是创建外部类的实例,为了优化内部类这一步的问题,我们需要手动的修改代码,如创建一个对象,并将它设置给一个静态的属性。如下述代码:
<span>public</span> <span>static</span> <span>final</span> Function<String, Integer> parseInt = <span>new</span> Function<String, Integer>() { <span>public</span> Integer <span>apply</span>(String arg) { <span>return</span> Integer.parseInt(arg); } }; <span>int</span> result = parseInt.apply(“<span>123</span>”);
第三部就是真实方法的调用。在这一步中匿名内部类和 Lambda 表达式执行的操作相同,因此没有性能上的差别。不进行捕获的 Lambda 表达式要比进行 static 优化过的匿名内部类较优。进行变量捕获的 Lambda 表达式和匿名内部类表达式性能大致相同。
在这一节中,我们明显可以看到 Lambda 表达式的实现表现良好,匿名内部类通常需要我们手动的进行优化来避免额外对象生成,而对于不进行变量捕获的 Lambda 表达式,JVM 已经为我们做好了优化。
实践中的性能分析
理解了 Lambda 的性能模型很是重要,但是实际应用中的总体性能如何呢?我们在使用 Java 8 编写了一些软件项目,一般都取得了很好的效果。非变量捕获的 Lambda 表达式给我们带来了很大的帮助。这里有一个很特殊的例子描述了关于优化方向的一些有趣的问题。
这个例子的场景是代码需要运行在一个要求 GC 暂定时间越少越好的系统上。因而我们需要避免创建大量的对象。在这个工程中,我们使用了大量的 Lambda 表达式来实现回调处理。然而在这些使用 Lambda 实现的回调中很多并没有捕获局部变量,而是需要引用当前类的变量或者调用当前类的方法。然而目前仍需要对象分配。下面就是我们提到的例子的代码:
<span>public</span> <span>MessageProcessor</span>() {} <span>public</span> <span>int</span> <span>processMessages</span>() { <span>return</span> queue.read(obj -> { <span>if</span> (obj <span>instanceof</span> NewClient) { <span>this</span>.processNewClient((NewClient) obj); } ... }); }
有一个简单的办法解决这个问题,我们将 Lambda 表达式的代码提前到构造方法中,并将其赋值给一个成员属性。在调用点我们直接引用这个属性即可。下面就是修改后的代码:
<span>private</span> <span>final</span> Consumer<Msg> handler; <span>public</span> <span>MessageProcessor</span>() { handler = obj -> { <span>if</span> (obj <span>instanceof</span> NewClient) { <span>this</span>.processNewClient((NewClient) obj); } ... }; } <span>public</span> <span>int</span> <span>processMessages</span>() { <span>return</span> queue.read(handler); }
然而上面的修改后代码给却给整个工程带来了一个严重的问题:性能分析表明,这种修改产生很大的对象申请,其产生的内存申请在总应用的 60% 以上。
类似这种无关上下文的优化可能带来其他问题。
- 纯粹为了优化的目的,使用了非惯用的代码写法,可读性会稍差一些。
- 内存分配方面的问题,示例中为 MessageProcessor 增加了一个成员属性,使得 MessageProcessor 对象需要申请更大的内存空间。Lambda 表达式的创建和捕获位于构造方式中,使得 MessageProcessor 的构造方法调用缓慢一些。
我们遇到这种情况,需要进行内存分析,结合合理的业务用例来进行优化。有些情况下,我们使用成员属性确保为经常调用的 Lambda 表达式只申请一个对象,这样的缓存策略大有裨益。任何性能调优的科学的方法都可以进行尝试。
上述的方法也是其他程序员对 Lambda 表达式进行优化应该使用的。书写整洁,简单,函数式的代码永远是第一步。任何优化,如上面的提前代码作为成员属性,都必须结合真实的具体问题进行处理。变量捕获并申请对象的 Lambda 表达式并非不好,就像我们我们写出new Foo()
代码并非一无是处一样。
除此之外,我们想要写出最优的 Lambda 表达式,常规书写很重要。如果一个 Lambda 表达式用来表示一个简单的方法,并且没有必要对上下文进行捕获,大多数情况下,一切以简单可读即可。
总结
在这片文章中,我们研究了 Lambda 表达式不是简单的匿名内部类的语法糖,为什么匿名内部类不是 Lambda 表达式的内部实现机制以及 Lambda 表达式的具体实现机制。对于大多数情况来说,Lambda 表达式要比匿名内部类性能更优。然而现状并非完美,基于测量驱动优化,我们仍然有很大的提升空间。
Lambda 表达式的这种实现形式并非 Java 8 所有。Scala 曾经通过生成匿名内部类的形式支持 Lambda 表达式。在 Scala 2.12 版本,Lambda 的实现形式替换为 Java 8 中的 Lambda 工厂机制。后续其他可以在 JVM 上运行的语言也可能支持 Lambda 的这种机制。
关于作者
Richard Warburton是一位资深专家,善于技术攻坚。最近,他写了一个关于 Java 8 Lambda 表达式的书,由 O’Reilly 出版,同时他也在 java8training 网站为 Java 程序员教授函数式编程。他涉猎的领域相当广泛,如数据分析,静态分析,编译器和网络协议等领域。他是伦敦 Java 协会的 Leader,并举办 OpenJdk hack 活动。他进行了多次演讲,曾在 Devoxx, JavaOne, JFokus, Devoxx UK, Geecon, Oredev, JAX London 和 Codemotion 等会议做分享。除上述之外,他还是 Warwick 大学的计算机科学博士。
Raoul-Gabriel Urma是剑桥大学计算机科学的博士生。他也是 Manning 出版社出版的 Java 8 in Action: Lambdas, streams, and functional-style programming 的联合作者。他发表过 10 多篇论文,也在国际会议做过 20 多场分享。他既在诸如 Google,eBay,Oracle 和 Goldman Sachs 这样的大公司工作过,也参与过小的创业公司。Raoul 也是皇家艺术协会的一员。他的 Twitter 是 @raoulUK。
Mario Fusco是来自 Red Hat 的高级软件工程师,他的工作是开发 Drools 核心开发和 JBoss 规则引擎。他有着相当丰富的 Java 经验,参与并领导了很多业界企业级的项目。他的兴趣是函数式编程和领域专用语言。由于对着两项的热爱,他创建了一个叫做 lambdaj 的开源库,目的是提供一个管理集合的 Java DSL 实现,使得使用更加函数式编码化。他的 Twitter ID 是 @mariofusco。
查看英文原文: Java 8 Lambdas - A Peek Under the Hood
感谢张龙对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群(已满),InfoQ 读者交流群(#2))。
评论