Stream 是用函数式编程方式在集合类上进行复杂操作的工具。
allArtists.stream().filter(artist -> artist.isFrom("London"));
这行代码并未做什么实际性的工作,filter 只刻画出了 Stream,但没有产生新的集合。
像filter 这样只描述 Stream,最终不产生新集合的方法叫作惰性求值方法;而像 count 这样最终会从 Stream 产生值的方法叫作及早求值方法。
判断一个操作是惰性求值还是及早求值很简单:只需看它的返回值。如果返回值是 Stream,那么是惰性求值;如果返回值是另一个值或为空,那么就是及早求值。使用这些操作的理想方式就是形成一个惰性求值的链,最后用一个及早求值的操作返回想要的结果,这正是它的合理之处。
collect(toList()) 方法由 Stream 里的值生成一个列表,是一个及早求值操作。
下面是使用 collect 方法的一个例子:
List collected = Stream.of("a", "b", "c").collect(Collectors.toList());
assertEquals(Arrays.asList("a", "b", "c"), collected);
这段程序展示了如何使用 collect(toList()) 方法从 Stream 中生成一个列表。如上文所述,由于很多 Stream 操作都是惰性求值,因此调用 Stream 上一系列方法之后,还需要最后再调用一个类似 collect 的及早求值方法。
如果有一个函数可以将一种类型的值转换成另外一种类型,map 操作就可以使用该函数,将一个流中的值转换成一个新的流。
使用 map 操作将字符串转换为大写形式示例:
List collected = Stream.of("a", "b", "hello")
.map(string -> string.toUpperCase())
.collect(toList());
传给 map 的 Lambda 表达式只接受一个 String 类型的参数,返回一个新的 String。参数和返回值不必属于同一种类型,但是 Lambda 表达式必须是 Function 接口的一个实例,Function 接口是只包含一个参数的普通函数接口。
遍历数据并检查其中的元素时,可尝试使用 Stream 中提供的新方法 filter。
List beginningWithNumbers = Stream.of("a", "1abc", "abc1")
.filter(value -> isDigit(value.charAt(0)))
.collect(toList());
和 map 很像,filter 接受一个函数作为参数,该函数用 Lambda 表达式表示。该函数和前面示例中 if 条件判断语句的功能一样,如果字符串首字母为数字,则返回 true。若要重构遗留代码,for 循环中的 if 条件语句就是一个很强的信号,可用 filter 方法替代。
flatMap 方法可用 Stream 替换值,然后将多个 Stream 连接成一个 Stream。
前面已介绍过 map 操作,它可用一个新的值代替 Stream 中的值。但有时,用户希望让 map操作有点变化,生成一个新的 Stream 对象取而代之。用户通常不希望结果是一连串的流,此时 flatMap 最能派上用场。
我们看一个简单的例子。假设有一个包含多个列表的流,现在希望得到所有数字的序列。该问题的一个解法如例所示。
List together = Stream.of(asList(1, 2), asList(3, 4))
.flatMap(numbers -> numbers.stream())
.collect(toList());
调用 stream 方法, 将每个列表转换成 Stream 对象, 其余部分由 flatMap 方法处理。flatMap 方法的相关函数接口和 map 方法的一样,都是 Function 接口,只是方法的返回值限定为 Stream 类型罢了。
Stream 上常用的操作之一是求最大值和最小值。Stream API 中的 max 和 min 操作足以解决这一问题。
下例是查找专辑中最短曲目所用的代码,展示了如何使用 max 和 min 操作。
List
查找 Stream 中的最大或最小元素,首先要考虑的是用什么作为排序的指标。以查找专辑中的最短曲目为例,排序的指标就是曲目的长度。
为了让 Stream 对象按照曲目长度进行排序,需要传给它一个 Comparator 对象。Java 8 提供了一个新的静态方法 comparing,使用它可以方便地实现一个比较器。放在以前,我们需要比较两个对象的某项属性的值,现在只需要提供一个存取方法就够了。本例中使用getLength 方法。
reduce 操作可以实现从一组值中生成一个值。
使用reduce求和示例:
int count = Stream.of(1, 2, 3).reduce(0, (acc, element) -> acc + element);
Lambda 表达式的返回值是最新的 acc,是上一轮 acc 的值和当前元素相加的结果。
为了进一步阐释如何重构遗留代码,本节将举例说明如何将一段使用循环进行集合操作的代码,重构成基于 Stream 的操作。重构过程中的每一步都能确保代码通过单元测试,当然你也可以自行实际操作一遍,体验并验证。
假定选定一组专辑,找出其中所有长度大于 1 分钟的曲目名称。例是遗留代码,首先初始化一个 Set 对象,用来保存找到的曲目名称。然后使用 for 循环遍历所有专辑,每次循环中再使用一个 for 循环遍历每张专辑上的每首曲目,检查其长度是否大于 60 秒,如果是,则将该曲目名称加入 Set 对象。
遗留代码:找出长度大于 1 分钟的曲目
public Set findLongTracks(List albums) {
Set trackNames = new HashSet<>();
for(Album album : albums) {
for (Track track : album.getTrackList()) {
if (track.getLength() > 60) {
String name = track.getName();
trackNames.add(name);
}
}
}
return trackNames;
}
如果仔细阅读上面的这段代码,就会发现几组嵌套的循环。仅通过阅读这段代码很难看出它的编写目的,那就来重构一下(使用流来重构该段代码的方式很多,下面介绍的只是其中一种。事实上,对 Stream API 越熟悉,就越不需要细分步骤。之所以在示例中一步一步地重构,完全是出于帮助大家学习的目的,在工作中无需这样做)。
第一步要修改的是 for 循环。首先使用 Stream 的 forEach 方法替换掉 for 循环,但还是暂时保留原来循环体中的代码,这是在重构时非常方便的一个技巧。调用 stream 方法从专辑列表中生成第一个 Stream,同时不要忘了在上一节已介绍过,getTracks 方法本身就返回一个 Stream 对象。经过第一步重构后,代码如例所示。
public Set findLongTracks(List albums) {
Set trackNames = new HashSet<>();
albums.stream()
.forEach(album -> {
album.getTracks()
.forEach(track -> {
if (track.getLength() > 60) {
String name = track.getName();
trackNames.add(name);
}
});
});
return trackNames;
}
在重构的第一步中,虽然使用了流,但是并没有充分发挥它的作用。事实上,重构后的代码还不如原来的代码好——天哪!因此,是时候引入一些更符合流风格的代码了,最内层的 forEach 方法正是主要突破口。
最内层的 forEach 方法有三个功用:找出长度大于 1 分钟的曲目,得到符合条件的曲目名称,将曲目名称加入集合 Set。这就意味着需要三项 Stream 操作:找出满足某种条件的曲目是 filter 的功能,得到曲目名称则可用 map 达成,终结操作可使用 forEach 方法将曲目名称加入一个集合。用以上三项 Stream 操作将内部的 forEach 方法拆分后,代码如例所示。
public Set findLongTracks(List albums) {
Set trackNames = new HashSet<>();
albums.stream()
.forEach(album -> {
album.getTracks()
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.forEach(name -> trackNames.add(name));
});
return trackNames;
}
现在用更符合流风格的操作替换了内层的循环,但代码看起来还是冗长繁琐。将各种流嵌套起来并不理想,最好还是用干净整洁的顺序调用一些方法。
理想的操作莫过于找到一种方法,将专辑转化成一个曲目的 Stream。众所周知,任何时候想转化或替代代码,都该使用 map 操作。这里将使用比 map 更复杂的 flatMap 操作,把多个Stream 合并成一个 Stream 并返回。将 forEach 方法替换成 flatMap 后,代码如例所示。
public Set findLongTracks(List albums) {
Set trackNames = new HashSet<>();
albums.stream()
.flatMap(album -> album.getTracks())
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.forEach(name -> trackNames.add(name));
return trackNames;
}
上面的代码中使用一组简洁的方法调用替换掉两个嵌套的 for 循环,看起来清晰很多。然而至此并未结束,仍需手动创建一个 Set 对象并将元素加入其中,但我们希望看到的是整个计算任务由一连串的 Stream 操作完成。
到目前为止,虽然还未展示转换的方法,但已有类似的操作。就像使用 collect(Collectors.toList()) 可以将 Stream 中的值转换成一个列表,使用 collect(Collectors.toSet()) 可以将Stream 中的值转换成一个集合。因此,将最后的 forEach 方法替换为 collect,并删掉变量trackNames,代码如例所示。
public Set findLongTracks(List albums) {
return albums.stream()
.flatMap(album -> album.getTracks())
.filter(track -> track.getLength() > 60)
.map(track -> track.getName())
.collect(toSet());
}
简而言之,选取一段遗留代码进行重构,转换成使用流风格的代码。最初只是简单地使用流,但没有引入任何有用的流操作。随后通过一系列重构,最终使代码更符合使用流的风格。在上述步骤中我们没有提到一个重点,即编写示例代码的每一步都要进行单元测试,保证代码能够正常工作。重构遗留代码时,这样做很有帮助。
每个用作函数接口的接口都应该添加这个注释。
这究竟是什么意思呢? Java 中有一些接口,虽然只含一个方法,但并不是为了使用Lambda 表达式来实现的。比如,有些对象内部可能保存着某种状态,使用带有一个方法的接口可能纯属巧合。java.lang.Comparable 和 java.io.Closeable 就属于这样的情况。
该注释会强制 javac 检查一个接口是否符合函数接口的标准。如果该注释添加给一个枚举类型、类或另一个注释,或者接口包含不止一个抽象方法,javac 就会报错。重构代码时,使用它能很容易发现问题。
reduce 方法的一个重点尚未提及:reduce 方法有两种形式,一种如前面出现的需要有一个初始值,另一种变式则不需要有初始值。没有初始值的情况下,reduce 的第一步使用Stream 中的前两个元素。有时,reduce 操作不存在有意义的初始值,这样做就是有意义的,此时,reduce 方法返回一个 Optional 对象。
Optional 是为核心类库新设计的一个数据类型,用来替换 null 值。人们对原有的 null 值有很多抱怨,甚至连发明这一概念的 Tony Hoare 也是如此,他曾说这是自己的一个“价值连城的错误”。作为一名有影响力的计算机科学家就是这样:虽然连一毛钱也见不到,却也可以犯一个“价值连城的错误”。
人们常常使用 null 值表示值不存在,Optional 对象能更好地表达这个概念。使用 null 代表值不存在的最大问题在于 NullPointerException。一旦引用一个存储 null 值的变量,程序会立即崩溃。使用 Optional 对象有两个目的:首先,Optional 对象鼓励程序员适时检查变量是否为空,以避免代码缺陷;其次,它将一个类的 API 中可能为空的值文档化,这比阅读实现代码要简单很多。
下面我们举例说明 Optional 对象的 API,从而切身体会一下它的使用方法。使用工厂方法of,可以从某个值创建出一个 Optional 对象。Optional 对象相当于值的容器,而该值可以通过 get 方法提取。如例所示。
Optional a = Optional.of("a");
assertEquals("a", a.get());
Optional 对象也可能为空,因此还有一个对应的工厂方法 empty,另外一个工厂方法ofNullable 则可将一个空值转换成 Optional 对象。例 4-23 展示了这两个方法,同时展示了第三个方法 isPresent 的用法(该方法表示一个 Optional 对象里是否有值)。
创建一个空的 Optional 对象,并检查其是否有值:
Optional emptyOptional = Optional.empty();
Optional alsoEmpty = Optional.ofNullable(null);
assertFalse(emptyOptional.isPresent());
assertTrue(a.isPresent());
使用 Optional 对象的方式之一是在调用 get() 方法前,先使用 isPresent 检查 Optional对象是否有值。使用 orElse 方法则更简洁,当 Optional 对象为空时,该方法提供了一个备选值。如果计算备选值在计算上太过繁琐,即可使用 orElseGet 方法。该方法接受一个Supplier 对象,只有在 Optional 对象真正为空时才会调用。例 4-24 展示了这两个方法。
assertEquals("b", emptyOptional.orElse("b"));
assertEquals("c", emptyOptional.orElseGet(() -> "c"));
Optional 对象不仅可以用于新的 Java 8 API,也可用于具体领域类中,和普通的类别无二致。当试图避免空值相关的缺陷,如未捕获的异常时,可以考虑一下是否可使用 Optional对象。
Lambda 表达式有一个常见的用法:Lambda 表达式经常调用参数。比如想得到艺术家的姓名,Lambda 的表达式如下:
artist -> artist.getName()
这种用法如此普遍,因此 Java 8 为其提供了一个简写语法,叫作方法引用,帮助程序员重用已有方法。用方法引用重写上面的 Lambda 表达式,代码如下:
Artist::getName
标准语法为 Classname::methodName。需要注意的是,虽然这是一个方法,但不需要在后面加括号,因为这里并不调用该方法。我们只是提供了和 Lambda 表达式等价的一种结构,在需要时才会调用。凡是使用 Lambda 表达式的地方,就可以使用方法引用。
构造函数也有同样的缩写形式,如果你想使用 Lambda 表达式创建一个 Artist 对象,可能会写出如下代码:
(name, nationality) -> new Artist(name, nationality)
使用方法引用,上述代码可写为:
Artist::new
另外一个尚未提及的关于集合类的内容是流中的元素以何种顺序排列。读者可能知道,一些集合类型中的元素是按顺序排列的,比如 List;而另一些则是无序的,比如 HashSet。增加了流操作后,顺序问题变得更加复杂。
直观上看,流是有序的,因为流中的元素都是按顺序处理的。这种顺序称为出现顺序。出现顺序的定义依赖于数据源和对流的操作。
在一个有序集合中创建一个流时,流中的元素就按出现顺序排列:
List numbers = asList(1, 2, 3, 4);
List sameOrder = numbers.stream()
.collect(toList());
assertEquals(numbers, sameOrder);
前面我们使用过 collect(toList()),在流中生成列表。显然,List 是能想到的从流中生成的最自然的数据结构,但是有时人们还希望从流生成其他值,比如 Map 或 Set,或者你希望定制一个类将你想要的东西抽象出来。
前面已经讲过,仅凭流上方法的签名,就能判断出这是否是一个及早求值的操作。reduce操作就是一个很好的例子,但有时人们希望能做得更多。
这就是收集器,一种通用的、从流生成复杂值的结构。只要将它传给 collect 方法,所有的流就都可以使用它了。
标准类库已经提供了一些有用的收集器,让我们先来看看。本章示例代码中的收集器都是从 java.util.stream.Collectors 类中静态导入的。
有一些收集器可以生成其他集合。比如前面已经见过的 toList,生成了 java.util.List 类的实例。还有 toSet 和 toCollection,分别生成 Set 和 Collection 类的实例。
总有一些时候,需要最终生成一个集合——比如:
通常情况下,创建集合时需要调用适当的构造函数指明集合的具体类型:
List artists = new ArrayList<>();
但是调用 toList 或者 toSet 方法时,不需要指定具体的类型。Stream 类库在背后自动为你挑选出了合适的类型。后面会讲述如何使用 Stream 类库并行处理数据,收集并行操作的结果需要的Set,和对线程安全没有要求的 Set 类是完全不同的。
可能还会有这样的情况,你希望使用一个特定的集合收集值,而且你可以稍后指定该集合的类型。比如,你可能希望使用 TreeSet,而不是由框架在背后自动为你指定一种类型的Set。此时就可以使用 toCollection,它接受一个函数作为参数,来创建集合:
stream.collect(toCollection(TreeSet::new));
还可以利用收集器让流生成一个值。maxBy 和 minBy 允许用户按某种特定的顺序生成一个值。例子展示了如何找出成员最多的乐队。它使用一个 Lambda 表达式,将艺术家映射
为成员数量,然后定义了一个比较器,并将比较器传入 maxBy 收集器。
public Optional biggestGroup(Stream artists) {
Function getCount = artist -> artist.getMembers().count();
return artists.collect(maxBy(comparing(getCount)));
}
minBy 就如它的方法名,是用来找出最小值的。
还有些收集器实现了一些常用的数值运算。让我们通过一个计算专辑曲目平均数的例子来看看,如例所示。
public double averageNumberOfTracks(List albums) {
return albums.stream()
.collect(averagingInt(album -> album.getTrackList().size()));
}
和以前一样,通过调用 stream 方法让集合生成流,然后调用 collect 方法收集结果。averagingInt 方法接受一个 Lambda 表达式作参数,将流中的元素转换成一个整数,然后再计算平均数。还有和 double 和 long 类型对应的重载方法,帮助程序员将元素转换成相应类型的值。
另外一个常用的流操作是将其分解成两个集合。假设有一个艺术家组成的流,你可能希望将其分成两个部分,一部分是独唱歌手,另一部分是由多人组成的乐队。可以使用两次过滤操作,分别过滤出上述两种艺术家。
但是这样操作起来有问题。首先,为了执行两次过滤操作,需要有两个流。其次,如果过滤操作复杂,每个流上都要执行这样的操作,代码也会变得冗余。
幸好我们有这样一个收集器 partitioningBy,它接受一个流,并将其分成两部分。它使用 Predicate 对象判断一个元素应该属于哪个部分,并根据布尔值返回一个 Map 到列表。因此,对于 true List 中的元素,Predicate 返回 true;对其他 List 中的元素,Predicate 返回 false。
使用它,我们就可以将乐队(有多个成员)和独唱歌手分开了。在本例中,分块函数指明艺术家是否为独唱歌手。实现如例所示。
public Map> bandsAndSolo(Stream artists) {
return artists.collect(partitioningBy(artist -> artist.isSolo()));
}
也可以使用方法引用代替 Lambda 表达式,如例所示。
public Map> bandsAndSoloRef(Stream artists) {
return artists.collect(partitioningBy(Artist::isSolo));
}
数据分组是一种更自然的分割数据操作,与将数据分成 ture 和 false 两部分不同,可以使用任意值对数据分组。比如现在有一个由专辑组成的流,可以按专辑当中的主唱对专辑分组。代码如例所示(使用主唱对专辑分组)。
public Map> albumsByArtist(Stream albums) {
return albums.collect(groupingBy(album -> album.getMainMusician()));
}
和其他例子一样,调用流的 collect 方法,传入一个收集器。groupingBy 收集器接受一个分类函数,用来对数据分组,就像 partitioningBy 一样,接受一个Predicate 对象将数据分成 ture 和 false 两部分。我们使用的分类器是一个 Function 对象,和 map 操作用到的一样。
很多时候,收集流中的数据都是为了在最后生成一个字符串。假设我们想将参与制作一张专辑的所有艺术家的名字输出为一个格式化好的列表,以专辑 Let It Be 为例,期望的输出为:"[George Harrison, John Lennon, Paul McCartney, Ringo Starr, The Beatles]"。
在 Java 8 还未发布前,实现该功能的代码可能通过不断迭代列表,使用一个 StringBuilder 对象来记录结果。每一步都取出一个艺术家的名字,追加到 StringBuilder对象。
Java 8 提供的流和收集器就能写出更清晰的代码:
String result =
artists.stream()
.map(Artist::getName)
.collect(Collectors.joining(", ", "[", "]"));
这里使用 map 操作提取出艺术家的姓名,然后使用 Collectors.joining 收集流中的值,该方法可以方便地从一个流得到一个字符串,允许用户提供分隔符(用以分隔元素)、前缀和后缀。
现在看到的各种收集器已经很强大了,但如果将它们组合起来,会变得更强大。
之前我们使用主唱将专辑分组,现在来考虑如何计算一个艺术家的专辑数量。一个简单的方案是使用前面的方法对专辑先分组后计数,如例所示。
Map> albumsByArtist
= albums.collect(groupingBy(album -> album.getMainMusician()));
Map numberOfAlbums = new HashMap<>();
for(Entry> entry : albumsByArtist.entrySet()) {
numberOfAlbums.put(entry.getKey(), entry.getValue().size());
}
这种方式看起来简单,但却有点杂乱无章。这段代码也是命令式的代码,不能自动适应并行化操作。
这里实际上需要另外一个收集器,告诉 groupingBy 不用为每一个艺术家生成一个专辑列表,只需要对专辑计数就可以了。幸好,核心类库已经提供了一个这样的收集器:counting。使用它,可将上述代码重写为例 所示的样子。
public Map numberOfAlbums(Stream albums) {
return albums.collect(groupingBy(album -> album.getMainMusician(),
counting()));
}
groupingBy 先将元素分成块,每块都与分类函数 getMainMusician 提供的键值相关联,然后使用下游的另一个收集器收集每块中的元素,最好将结果映射为一个 Map。
mapping 允许在收集器的容器上执行类似 map 的操作。但是需要指明使用什么样的集合类存储结果,比如 toList。这些收集器就像乌龟叠罗汉,龟龟相驮以至无穷。
mapping 收集器和 map 方法一样,接受一个 Function 对象作为参数,经过重构后的代码如例:
使用收集器求每个艺术家的专辑名:
public Map> nameOfAlbums(Stream albums) {
return albums.collect(groupingBy(Album::getMainMusician,
mapping(Album::getName, toList())));
}
这两个例子中我们都用到了第二个收集器,用以收集最终结果的一个子集。这些收集器叫作下游收集器。收集器是生成最终结果的一剂配方,下游收集器则是生成部分结果的配方,主收集器中会用到下游收集器。这种组合使用收集器的方式,使得它们在 Stream 类库中的作用更加强大。
假设使用 Map
public Artist getArtist(String name) {
Artist artist = artistCache.get(name);
if (artist == null) {
artist = readArtistFromDB(name);
artistCache.put(name, artist);
}
return artist;
}
Java 8 引入了一个新方法 computeIfAbsent,该方法接受一个 Lambda 表达式,值不存在时使用该 Lambda 表达式计算新值。使用该方法,可将上述代码重写为例所示的形式。
public Artist getArtist(String name) {
return artistCache.computeIfAbsent(name, this::readArtistFromDB);
}
并行化是指为缩短任务执行时间,将一个任务分解成几部分,然后并行执行。这和顺序执行的任务量是一样的,区别就像用更多的马来拉车,花费的时间自然减少了。实际上,和顺序执行相比,并行化执行任务时,CPU 承载的工作量更大。
一种特殊形式的并行化:数据并行化。数据并行化是指将数据分成块,为每块数据分配单独的处理单元。还是拿马拉车那个例子打比方,就像从车里取出一些货物,放到另一辆车上,两辆马车都沿着同样的路径到达目的地。
并 行 化 操 作 流 只 需 改 变 一 个 方 法 调 用。 如 果 已 经 有 一 个 Stream 对 象, 调 用 它 的parallel 方法就能让其拥有并行操作的能力。如果想从一个集合类创建一个流,调用parallelStream 就能立即获得一个拥有并行能力的流。
让我们先来看一个具体的例子,例子计算了一组专辑的曲目总长度。它拿到每张专辑的曲目信息,然后得到曲目长度,最后相加得出曲目总长度。
串行化计算专辑曲目长度:
public int serialArraySum() {
return albums.stream()
.flatMap(Album::getTracks)
.mapToInt(Track::getLength)
.sum();
}
调用 parallelStream 方法即能并行处理,如下例所示,剩余代码都是一样的,并行化就是这么简单!
并行化计算专辑曲目长度:
public int parallelArraySum() {
return albums.parallelStream()
.flatMap(Album::getTracks)
.mapToInt(Track::getLength)
.sum();
}
读到这里,大家的第一反应可能是立即将手头代码中的 stream 方法替换为 parallelStream方法,因为这样做简直太简单了!先别忙,为了将硬件物尽其用,利用好并行化非常重要,但流类库提供的数据并行化只是其中的一种形式。
我们先要问自己一个问题:并行化运行基于流的代码是否比串行化运行更快?这不是一个简单的问题。回到前面的例子,哪种方式花的时间更多取决于串行或并行化运行时的环境。
之前提到过使用并行流能工作,但这样说有点无耻。虽然只需一点改动,就能让已有代码并行化运行,但前提是代码写得符合约定。为了发挥并行流框架的优势,写代码时必须遵守一些规则和限制。
之前调用 reduce 方法,初始值可以为任意值,为了让其在并行化时能工作正常,初值必须为组合函数的恒等值。拿恒等值和其他值做 reduce 操作时,其他值保持不变。比如,使用reduce 操作求和,组合函数为 (acc, element) -> acc + element,则其初值必须为 0, 因为任何数字加 0,值不变。
reduce 操作的另一个限制是组合操作必须符合结合律。这意味着只要序列的值不变,组合操作的顺序不重要。有点疑惑?别担心!请看例子,我们可以改变加法和乘法的顺序,但结果是一样的。
加法和乘法满足结合律:
(4 + 2) + 1 = 4 + (2 + 1) = 7
(4 * 2) * 1 = 4 * (2 * 1) = 8
要避免的是持有锁。流框架会在需要时,自己处理同步操作,因此程序员没有必要为自己的数据结构加锁。如果你执意为流中要使用的数据结构加锁,比如操作的原始集合,那么有可能是自找麻烦。
在前面我还解释过,使用 parallel 方法能轻易将流转换为并行流。如果读者在阅读本书的同时,还查看了相应的 API,那么可能会发现还有一个叫 sequential 的方法。在要对流求值时,不能同时处于两种模式,要么是并行的,要么是串行的。如果同时调用了 parallel和 sequential 方法,最后调用的那个方法起效。
影响并行流性能的主要因素有 5 个:
输入数据的大小会影响并行化处理对性能的提升。将问题分解之后并行化处理,再将结果合并会带来额外的开销。因此只有数据足够大、每个数据处理管道花费的时间足够多时,并行化处理才有意义。
每个管道的操作都基于一些初始数据源,通常是集合。将不同的数据源分割相对容易,这里的开销影响了在管道中并行处理数据时到底能带来多少性能上的提升。
处理基本类型比处理装箱类型要快。
极端情况下,只有一个核,因此完全没必要并行化。显然,拥有的核越多,获得潜在性能提升的幅度就越大。在实践中,核的数量不单指你的机器上有多少核,更是指运行时你的机器能使用多少核。这也就是说同时运行的其他进程,或者线程关联性(强制线程在某些核或 CPU 上运行)会影响性能。
比如数据大小,这是一场并行执行花费时间和分解合并操作开销之间的战争。花在流中每个元素身上的时间越长,并行操作带来的性能提升越明显。
在底层,并行流还是沿用了 fork/join 框架。fork 递归式地分解问题,然后每段并行执行,最终由 join 合并结果,返回最后的值。
遗憾的是,流有一个方法让你能查看每个值,同时能继续操作流。这就是 peek 方法。例使用peek 方法重写了前面的例子,输出流中的值,同时避免了重复的流操作。
使用 peek 方法记录中间值:
Set nationalities
= album.getMusicians()
.filter(artist -> artist.getName().startsWith("The"))
.map(artist -> artist.getNationality())
.peek(nation -> System.out.println("Found nationality: " + nation))
.collect(Collectors.toSet());
记录日志这是 peek 方法的用途之一。为了像调试循环那样一步一步跟踪,可在 peek 方法中加入断点,这样就能逐个调试流中的元素了。
此时,peek 方法可知包含一个空的方法体,只要能设置断点就行。有一些调试器不允许在空的方法体中设置断点,此时,我将值简单地映射为其本身,这样就有地方设置断点了,虽然这样做不够完美,但只要能工作就行。