虽然Java 8已经出了好几年了,但是很多朋友可能对于其中的一些特性还是不太了解。甚至对lambda表达式这个特性可能会产生误解,误认为lambda表达式会影响程序的速度。其中也不乏很多误人子弟的自媒体传播这些错误的观点。
今天我看到一篇自媒体推送的文章,号称用Java字节码分析为什么lambda表达式速度慢,但是其中漏洞百出,搞得我忍不住写了这么一篇文章,为一些受到误导的朋友纠正一个概念:lambda表达式和普通的循环一样,不会影响到程序速度,大家可以放心使用。
因为头条压缩图片的缘故,所以对于小段代码,我用高亮代码图片的形式贴出。对于大段代码,直接贴代码,可能会影响大家的阅读体验。也希望头条能够允许上传高清图片,让大家的阅读体验更好一下。
lambda表达式是什么
可能还有一些朋友对lambda表达式还是不太清楚,所以我先介绍一下lambda表达式的概念。简单来说lambda表达式就是匿名函数,在一些支持匿名函数的语言中,用不用lambda表达式其实不是那么重要。但是因为Java不支持匿名函数,所以lambda表达式可以极大的简化这些场合的代码。
先来看看一个例子。假如我们需要在一个新线程中运行代码,可能需要创建一个新的Runnable对象。此处使用了Java的一项特性匿名内部类,创建了一个新的临时的Runnable对象。但是代码如你所见非常难看,大段的缩进和方括号,非常影响阅读。
如果换成了lambda表达式的实现,如你所见,代码非常干净整洁。
这种形如(a,b)->{ ..... }的表达式就是lambda表达式。上面已经提到过了,lambda表达式其实就是匿名函数,箭头前面的括号内部的就是函数的参数列表;箭头后面的括号内部的就是方法体,假如方法体只有一行语句或者表达式,方法体的括号可以省略。
lambda表达式参数的类型也不需要写明,编译器会自动从前面的类型中推断。在上面的例子中,因为Runnable中的run函数没有参数,所以lambda表达式自然也不需要参数。你可能会想到,假如类型中有多个函数怎么办呢?这时候编译器无法推断,程序就会报错。这也是Java lambda表达式的一个限制,前面的接口类型中只能有一个函数声明。
很多古板的程序员不喜欢这个特性,认为它会影响到程序的可读性。但是实际情况恰恰相反,合理的利用lambda表达式,不仅不会污染代码的可读性,反而会大大加强可读性。lambda表达式这个特性,已经被现在很多新的编程语言吸收,足见其流行程度。
错误的测试方法
很多朋友可能对lambda表达式的运行速度产生疑问,会不会用了这种写法,程序的运行速度就会变慢呢?这种担心也是完全多余的,Java作为一门经典的企业级应用开发语言,Oracle对每个新添加的特性都是小心翼翼的。既然这个特性被添加到Java语言中,那么足以说明Oracle对其进行了深刻的优化,运行速度绝对是有保证的,就算比普通循环慢一点,也不会慢到哪里去。
可能有些人用了错误的测试方法对lambda表达式进行了测试,发现速度不如普通的for循环,然后就得出结论:lambda表达式运行速度慢。这种测试是完全不负责任的。下面的代码就是一种错误的测试方法,测试结果:lambda表达式用时150毫秒,而普通循环用时7毫秒。因此得出结论:lambda表达式慢。大家可以看看代码,然后想想问题在哪里。
public class LambdaTest { public static int N = 1_0000_0000; static List list = IntStream.range(0, N).boxed().collect(Collectors.toList()); public static void main(String[] args) { long start, end; start = System.currentTimeMillis(); lambdaTest(); end = System.currentTimeMillis(); System.out.println("lambda:" + (end - start)); start = System.currentTimeMillis(); loopTest(); end = System.currentTimeMillis(); System.out.println("loop:" + (end - start)); } static void lambdaTest() { list.forEach(i -> { }); } static void loopTest() { for (int i = 0; i < list.size(); i++) { } }}
好了不卖关子了,直接说结论吧。上面测试方法的问题在于,两种测试方法实际上根本不对等。lambda表达式的测试中,虽然方法体是空的,但是程序执行的时候,仍然会取出每一个元素,然后再应用空的方法。而循环测试中,真的只是执行了一个空循环,什么也没干。因此这种方法测出来的结论,完全不能证明lambda表达式比空循环慢。
公平的测试方法应该是怎么样的呢?对于循环,一样要加上取元素和应用空方法的操作。为此在空循环中增加了一部分代码。这样测出来的结果,lambda表达式和普通循环一样都是150毫秒左右,存在几毫秒的误差。这次的结果可以反映真实情况了,那就是两者没有什么速度差别。大家可以自己运行代码试试。
更加实际的测试
不管怎么说,用空的方法来测试lambda表达式和普通循环并不具有实际意义。所以我换了一种更加实际的方法,来看看lambda相较于普通的循环有没有优势所在。
首先准备一个用户类,这里用到了lombok自动生成各种工具方法,为我们节约时间。
然后准备一个随机类,准备用来生成10万个随机用户,来进行下一步的操作。
接下来就是测试代码了。测试代码其实也很简单,随机生成一千万个用户,然后进行简单的筛选操作,选出来所有ID大于1000且为偶数,用户名以字母a开头的用户。两种测试结果输出各自的筛选结果数量,以保证结果是相同的。因为这次的测试比较复杂,所以可以看出实际的差异。在我的电脑上,lambda表达式耗时100毫秒左右,而循环耗时80毫秒左右。可见lambda表达式虽然比循环慢一点,但是差距很小,在千万次循环的级别仅差几十毫秒,对程序的运行基本没有什么影响。
public class LambdaTest { public static int N = 1000_0000; static List list; public static void main(String[] args) { init(); long start, end; start = System.currentTimeMillis(); lambdaTest(); end = System.currentTimeMillis(); System.out.println("lambda:" + (end - start)); start = System.currentTimeMillis(); loopTest(); end = System.currentTimeMillis(); System.out.println("loop:" + (end - start)); } static void init() { list = new ArrayList<>(); for (int i = 0; i < N; i++) { list.add(new User(MyRandom.randomId(), MyRandom.randomUsername())); } } static void lambdaTest() { List r = list.stream() .filter(e -> e.getName().startsWith("a")) .filter(e -> e.getId() % 2 == 0) .filter(e -> e.getId() > 1000) .collect(Collectors.toList()); System.out.println(r.size()); } static void loopTest() { List r = new ArrayList<>(); for (User user : list) { if (user.getName().startsWith("a") && user.getId() % 2 == 0 && user.getId() > 1000) { r.add(user); } } System.out.println(r.size()); }}
这次的测试算是一个比较实际的测试了,生成一千万个用户并对其属性进行检查,过滤出符合条件的用户。测试的数量是一千万,但是测试结果相差并不大。可见其实lambda表达式并不怎么影响程序的运行速度。值得注意的是,这个测试数据完全是保存在内存上的,而一般情况下数据都是从数据库中加载出来的。这时候程序的瓶颈在数据库的IO上,就算程序本身速度相差几十毫秒,相较于数据库的延迟完全可以忽略不计。
我们的原则是不进行过早的优化。写程序的时候,该怎么写就怎么写,lambda这种好用的新特性,该用的时候就应该用,不要害怕它影响性能。等到程序写完,需要优化的时候,老老实实的跑profile,查看程序的瓶颈究竟在哪里。一般情况下程序问题都在数据库IO、算法不够高效或者是内存泄露上,我还真没听说过哪个程序写的非常完美,就是被lambda表达式的速度拖后腿的。实际上,虽然很多程序员都担心lambda表达式的速度,但是他们的程序完全优秀到需要扣lambda表达式细节的这种程度。
反过来说适当的时候应用这些新特性,反而会增加代码的可读性。就拿上面这个例子来说,通过三次filter方法过滤程序,最后用collect方法得到结果,这种流式函数调用不仅非常简单易读,而且十分优雅。反观循环版本中的查找操作,只能通过if判断简单粗暴的进行。这还是一个简单的例子,假如查找操作比较复杂,带了十几个查询条件的话,那么循环版本的代码就会变成可读性的灾难。
这里还有一个细节值得注意。为了最高效的运行,循环版本的代码只能在一个if中不断的增加判断条件。而lambda表达式版本则是流式调用了三次filter语句,但是它们的运行结果相差不大。相信你应该也猜到原因了:lambda表达式和流类库内部做了特殊的优化,就算是多个过滤条件,也会保证仅仅循环一次。因此放心大胆的使用lambda表达式吧!它是编写代码的利器!
lambda表达式,更加强大
写到这里,本文的内容应该是差不多了。但是我猜很多朋友看了以后,会说“你说了这么多,lambda表达式不还是比循环慢嘛。说来说去,我还是要继续用循环”。在这里我想说明一下,我的观点是:lambda表达式虽然比循环慢那么一点点,但是带来的便利性和优化空间,远远不是普通循环可以比拟的。
上面的例子用了一千万次的循环,才得到了几十毫秒的差距。而实际情况中,几千次或者几万次的循环,差距便会忽略不计。而且如果加上数据库等外部数据源的读写延迟,程序的这点运行速度完全就不值一提了。所有担心lambda表达式的朋友基本都是杞人忧天。而lambda表达式带来的方便确实实实在在的。更重要的是,普通循环的优化非常困难,基本要重写整个代码,在这之中很容易发生错误。而lambda表达式的优化则简单许多。
上面的例子恰好是一个适合并行化的例子,优化方法很简单,多加一行parallel()方法调用即可。并行化是另外一个非常复杂的主题,但是在这个例子中,第一数据量大(一千万之多),第二数据易于分割和和合并(ArrayList可以用下标直接定位中间的元素),第三操作都是只读的(不会影响到数据集本身),所以正好适合并行化。并行化之后,lambda表达式的运行速度已经和循环相差无几了(仅差几毫秒左右)。而普通代码的并行化,我想这就不是一般程序员可以轻松写出来的东西了。
好了,本文终于写完了,其实本来准备反驳一下错误观点就结束的,结果不知不觉写了这么多。如果大家觉得本文不错的话,欢迎点赞、评论、转发,创作不易,还请大家多多支持!在此先谢谢各位了。