ASE'21:Java语言中Lambda表达式的误用分析
JDK8被认为是近年来Java最重要的一次更新。其引入的一系列函数式的编程特性,特别是lambda表达式,使这一典型的面向对象编程的语言具有了函数式编程范式。Java lambda表达式由三部分构成:参数列表、箭头字符和lambda body。参数列表类似常规方法中的形参列表,lambda body是一个表达式。在Java中,lambda表达式可以赋值给变量,也可以作为参数传递给方法(对应的类为函数接口)。图1展示了lambda表达式的语法结构以及和其对应的函数接口。
图1 Lambda表达式的句法结构和对应的函数接口
一些研究人员发现从2014年JDK8更新至今,Java开发人员开始越来越多的使用lambda表达式。其被使用的原因主要包括了以下几个方面:(i) 使现有代码更加简洁和可读; (ii) 避免代码重复,以及 (iii) 模拟函数的延迟计算特性等。此外,研究人员也发现有开发人员常常会低效地使用 Java 的内置函数式接口,即他们更喜欢使用通用的函数式接口而不是专用的接口,却忽略了其可能带来的性能开销。类似的,我们也在开源项目中发现了越来越多由于误用lambda表达式而引发的漏洞,缺陷,以及兼容性等问题。针对这些问题,我们开展了大量的实证研究,并取得了重要发现。
该成果“Why Do Developers Remove Lambda Expressions in Java?”已在国际顶级会议ASE'21发表。相关实验分析数据和代码已开源。
- 论文链接:
- https://ieeexplore.ieee.org/document/9678600
- 开源链接:
- https://github.com/CGCL-codes/LambdaMisuse
背景与动机
图2展示了在大型开源项目Lucene中,由于lambda表达式误用而引入的一个真实内存泄露问题。在该示例中,nextSeq是函数advanceQueue中的一个final变量,并且是此函数唯一引用的外部变量。由于此lambda表达式仅引用了一个final的外部变量,因此被称为无状态的lambda表达式。在运行时该lambda表达式和静态内部类非常类似,即在JVM运行开始时加载到JVM,JVM运行结束之后该对象会被回收。因此,这个函数每次调用时都会引用该lambda表达式中的nextSeqNo变量,并且这个引用会一直维持到JVM运行结束,而不会被JVM的Garbage Collector回收,这样就会导致内存泄漏和大量的内存开销,甚至可能导致内存溢出异常。为了修复该问题,开发人员删除了该lambda表达式,并将其替换成了普通的函数调用。
图2 由lambda表达式导致的内存泄露问题及其修复
我们观察到,在开源项目中引入 lambda 表达式后,由于其引入的各种问题,开发人员将 其修改回传统的代码实现是非常普遍的。然而,目前还没有研究工作系统地研究过使用lambda表达式带来的问题及其严重性,以及lambda表达式不适用的场景。为了填补这个空白,我们对大型Java开源项目中的海量被删除的lambda表达式的进行定量分析、定性分析以及面向开发者的实证研究调查,最终发现了由于误用lambda表达式而导致的九种常见严重负面影响以及一系列不适宜使用lambda表达式的复杂场景。我们的工作为未来这方面的研究指明了方向,并且能够帮助Java开发人员更好的利用lambda表达式。
定量研究
图3 Java开源项目中被删除的lambda表达式数量统计
我们观察到在Java开源项目中,lambda表达式被删除是非常常见的现象,并且呈不断上升的趋势,如图3所示。更糟糕的是,我们观察到 lambda 表达式经常被误用,在这种情况下,lambda 会导致缺陷漏洞或副作用,例如效率问题或内存泄漏。因此,研究开发人员不当使用和删除的 lambda 表达式的特征引起了我们的极大兴趣。我们通过对比被删除的lambda表达式和高质量的lambda表达式的句法特征和语义特征,尝试发现哪些lambda更可能被误用以及删除。具体来说,我们选取了103个大型的Apache的Java开源项目(至少1000个commit,10个committer)进行定量分析,从中提取了3662个被删除的lambda表达式和31,228个被保留的lambda表达式。定量分析结果如图4所示。通过对比这两类lambda表达式的特征,我们有以下几个主要发现:1)实现自定义函数接口的lambda表达式更容易被删除;2)表达式主体更复杂的lambda表达式更容易被删除;3)被自定义的函数调用的lambda表达式更容易被删除。
图4 被删除lambda表达式与高质量lambda表达式的定量分析
定性研究
我们也对lambda表达式的误用进行了定性分析。具体来说,我们从Apache JIRA,GitHub Commits和GitHub Issues等来源收集了错误使用lambda表达式的实例,以及其导致的缺陷或漏洞问题。通过仔细研究,我们将这些lambda表达式引入的问题总结成了以下主要几类:
1) 性能下降。误用lambda表达式可能导致内存泄漏,如图2所示,lambda表达式可能在短时间内造成较大的内存开销,导致程序性能下降。或者在频繁调用的代码语句中使用有状态的lambda表达式,造成过量的对象内存分配,给Garbage Collector带来巨大压力,也会导致程序性能下降。
2) 较差的可读性。Lambda表达式的引入的动机之一是解决匿名内部类的“高度问题”。比如图5左边所示的匿名内部类5行代码其实只有1行发挥了作用。转化为lambda表达式之后能用1行代码实现相同功能。但是,我们发现一些lambda表达式可能会引入“宽度问题”,即lambda表达式过于冗长从而影响了代码的可读性,如图5所示。
图5 可转换为lambda表达式的匿名内部类以及lambda引入的“宽度问题”
3) 序列化问题。序列化是Java语言非常重要的功能,然而序列化lambda表达式是不被推荐的。因为序列化一个合成类(编译lambda表达式时会产生lambda表达式对应的合成类)的方式在不同的JVM可能不同,因此可能存在兼容性问题。因此,在一个JVM上序列化一个lambda表达式然后在另一个JVM上反序列化这个lambda表达式可能会带来兼容性问题。
4) 较差的可扩展性。Java lambda表达式本质上对应一个函数接口(只含有一个抽象方法的接口)。但是在开发过程中,可能需要增加一些方法,而增加方法之后接口就不再是函数接口了,不能被lambda表达式实现,因此必须改成匿名内部类等。
5) 类型推断失败。Lambda表达式的类型信息有时可以省略,而由编译器来负责推断。但是如果下上下文信息不足,会让编译器推断类型失败,从而引发错误。
6) 较差的可维护性。由于lambda表达式编译后产生一个合成类,并且合成类是没有明确命名的(lambda表达式本身就相当于匿名类)。如果这个合成类运行时抛出异常,那么其所对应的Stack Trace可读性就很差,如图6所示,非常不利于程序的开发与维护。
图6 Lambda表达式抛出异常之后的Stack Trace
7)延迟计算。Lambda表达式的一个关键特性就是延迟计算。延迟计算就是指在需要的时候再对表达式进行求值。然而,开发人员往往在使用时忽略了这一特性,导致实现的功能出错或引入其他问题。
通过进一步对搜集数据的定性分析,我们总结了7种关于lambda表达式误用的迁移模式,即开发人员是如何修复使用错误的lambda表达式的(具体请参见原文)。同时,我们也分析了在Java语言中lambda表达式的误用情况(如上文总结所示)与迁移模式之间的关系,如图7所示。最终,我们结合对lambda表达式误用实例的理解、代码迁移模式和开源项目中开发者的反馈,总结了一些使用lambda表达式的建议。例如不要在影响性能关键的代码片段中使用lambda表达式;不要在条件分支处理的时候使用Java 8引入的API和lambda表达式;尽量在使用lambda表达式时指明变量与表达式类型;如果需要抛出检查型异常,则不要使用lambda表达式;尽量避免使用太复杂的lambda表达式等。我们的工作不仅为未来的相关研究指明了方向,同时也能够帮助Java开发人员更好的利用lambda表达式这一函数式特性。
图7 Lambda表达式的误用情况与迁移模式之间的关系
详细内容参见:
Zheng, Mingwei, Jun Yang, Ming Wen, Hengcheng Zhu, Yepang Liu, and Hai Jin. "Why Do Developers Remove Lambda Expressions in Java?" In 2021 36th IEEE/ACM International Conference on Automated Software Engineering (ASE), pp. 67-78. IEEE, 2021.
