Java 8 Lambda函数编程入门(四)

本文探讨了Java8中利用Lambda表达式和流实现数据并行化的原理与实践。介绍了并行流操作的优势及影响其性能的因素,并展示了如何通过简单的API调用实现数组的并行处理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

数据并行化

在Java 8中,编写并行化的程序很容易。这都多亏了前面介绍的 Lambda 表达式和流,我们完全不必理会串行或并行,只要告诉程序该做什么就行了。这听起来和长久以来使用 Java 编程的方式并无区别,但告诉计算机做什么和怎么做是完全不同的。因此本文主要内容并不在于如何更改代码,而是讲述为什么需要并行化和什么时候会带来性能的提升。

并行和并发

并发是两个任务共享时间段,并行则是两个任务在同一时间发生,比如运行在多核 CPU 上。如果一个程序要运行两个任务,并且只有一个 CPU 给它们分配了不同的时间片,那 么这就是并发,而不是并行。两者之间的区别如图所示。

并发并行

并行化是指为缩短任务执行时间,将一个任务分解成几部分,然后并行执行。这和顺序执 行的任务量是一样的,区别就像用更多的马来拉车,花费的时间自然减少了。实际上,和 顺序执行相比,并行化执行任务时,CPU 承载的工作量更大。

本章会讨论一种特殊形式的并行化:数据并行化。数据并行化是指将数据分成块,为每块 数据分配单独的处理单元。还是拿马拉车那个例子打比方,就像从车里取出一些货物,放 到另一辆车上,两辆马车都沿着同样的路径到达目的地。
当需要在大量数据上执行同样的操作时,数据并行化很管用。它将问题分解为可在多块数 据上求解的形式,然后对每块数据执行运算,最后将各数据块上得到的结果汇总,从而获得最终答案。

并行化流操作

并行化操作流只需改变一个方法调用。如果已经有一个 Stream 对象,调用它的 parallel 方法就能让其拥有并行操作的能力。如果想从一个集合类创建一个流,调用 parallelStream 就能立即获得一个拥有并行能力的流。串行化代码的速度不一定比并行化代码的速度慢,后面在进行分析。

性能

影响并行流性能的主要因素有 5 个。

  1. 数据大小

    输入数据的大小会影响并行化处理对性能的提升。将问题分解之后并行化处理,再将结 果合并会带来额外的开销。因此只有数据足够大、每个数据处理管道花费的时间足够多 时,并行化处理才有意义。

  2. 源数据结构

    每个管道的操作都基于一些初始数据源,通常是集合。将不同的数据源分割相对容易, 这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。

  3. 装箱

    处理基本类型比处理装箱类型要快。

  4. 核的数量

    极端情况下,只有一个核,因此完全没必要并行化。显然,拥有的核越多,获得潜在性 能提升的幅度就越大。在实践中,核的数量不单指你的机器上有多少核,更是指运行时 你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或 CPU 上运行)会影响性能。

  5. 单元处理开销

    比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中 每个元素身上的时间越长,并行操作带来的性能提升越明显。

我们可以根据性能的好坏,将核心类库提供的通用数据结构分成以下 3 组。

  1. 性能好

    ArrayList、数组或 IntStream.range,这些数据结构支持随机读取,也就是说它们能轻 而易举地被任意分解。

  2. 性能一般

    HashSet、TreeSet,这些数据结构不易公平地被分解,但是大多数时候分解是可能的。

  3. 性能差

    有些数据结构难于分解,比如,可能要花 O(N) 的时间复杂度来分解问题。其中包括 LinkedList,对半分解太难了。还有 Streams.iterate 和 BufferedReader.lines,它们长度未知,因此很难预测该在哪里分解。

初始的数据结构影响巨大。举一个极端的例子,比较对 10000 个整数并行求和,使用 ArrayList 要比使用 LinkedList 快 10 倍。这不是说业务逻辑的性能情况也会如此,只是说明了数据结构 对于性能的影响之大。使用形如 LinkedList 这样难于分解的数据结构并行运行可能更慢。

理想情况下,一旦流框架将问题分解成小块,就可以在每个线程里单独处理每一小块,线程之间不再需要进一步通信。无奈现实不总遂人愿!

在讨论流中单独操作每一块的种类时,可以分成两种不同的操作:无状态的和有状态的。 无状态操作整个过程中不必维护状态,有状态操作则有维护状态所需的开销和限制。

如果能避开有状态,选用无状态操作,就能获得更好的并行性能。无状态操作包括 map、 filter 和 flatMap,有状态操作包括 sorted、distinct 和 limit。

并行化数组操作

Java 8还引入了一些针对数组的并行操作,脱离流框架也可以使用Lambda表达式。像流框架上的操作一样,这些操作也都是针对数据的并行化操作。

这些操作都在工具类 Arrays 中,该类还包括 Java 以前版本中提供的和数组相关的有用方 法,下表总结了新增的并行化操作。

数组上的并行化操作:

方法名操作
parallelPrefix任意给定一个函数,计算数组的和
parallelSetAll使用 Lambda 表达式更新数组元素
parallelSort并行化对数组元素排序

测试、调试和重构

重构、测试驱动开发(TDD)和持续集成(CI)越来越流行,如果我们需要将 Lambda 表达式应用于日常编程工作中,就得学会如何为它编写单元测试。

重构候选项

使用 Lambda 表达式重构代码有个时髦的称呼:Lambda 化(读作 lambda-fi-cation,执行重构的程序员叫作lamb-di-fiers或者有责任心的程序员)。

  1. 进进出出、摇摇晃晃(In, Out, In, Out, Shake It All About)
  2. 孤独的覆盖(The Lonely Override)
  3. 同样的东西写两遍(Behavioral Write Everything Twice)

Lambda表达式的单元测试

通常,在编写单元测试时,怎么在应用中调用该方法,就怎么在测试中调用。给定一些输入或测试替身,调用这些方法,然后验证结果是否和预期的行为一致。

Lambda 表达式给单元测试带来了一些麻烦,Lambda 表达式没有名字,无法直接在测试代码中调用。

你可以在测试代码中复制 Lambda 表达式来测试,但这种方式的副作用是测试的不是真正的实现。假设你修改了实现代码,测试仍然通过,而实现可能早已在做另一件事了。

解决该问题有两种方式。第一种是将 Lambda 表达式放入一个方法测试,这种方式要测那个方法,而不是 Lambda 表达式本身。第二种测试的重点放在方法的行为上。

在测试替身时使用Lambda表达式

编写单元测试的常用方式之一是使用测试替身描述系统中其他模块的期望行为。这种方式
很有用,因为单元测试可以脱离其他模块来测试你的类或方法,测试替身让你能用单元测试来实现这种隔离。【测试替身也常被称为模拟,事实上测试存根和模拟都属于测试替身。区别是模 拟可以验证代码的行为。】

惰性求值和调试

调试时通常会设置断点,单步跟踪程序的每一步。使用流时,调试可能会变得更加复杂,
因为迭代已交由类库控制,而且很多流操作是惰性求值的。

日志和打印消息

假设你要在集合上进行大量操作,你要调试代码,你希望看到每一步操作的结果是什么。
可以在每一步打印出集合中的值,这在流中很难做到,因为一些中间步骤是惰性求值的。

解决方案:peak:流有一个方法让你能查看每个值,同时能继续操作流。这就是 peek 方法。

在流中间设置断点:为了像调试循环那样一步一步跟踪,可在 peek 方法
中加入断点,这样就能逐个调试流中的元素了。


参考资料:
Java 8函数式编程 作者:(英)沃伯顿著
备注:
转载请注明出处:http://blog.youkuaiyun.com/wsyw126/article/details/52722938
作者:WSYW126

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值