Java 8 的迭代器与流

Iterator versus Stream of Java 8

为了利用 Jdk 8 的 java.util.stream 中包含的广泛查询方法,我尝试设计领域模型,其中 getters 的关系与 * 多重性(零或更多实例)return 一个 Stream<T>,而不是 Iterable<T>Iterator<T>.

我怀疑 Stream<T>Iterator<T> 相比是否会产生额外的开销?

那么,用 Stream<T> 破坏我的领域模型有什么缺点吗?

或者,我是否应该始终 return Iterator<T>Iterable<T>,并将选择是否使用流的决定留给最终用户,通过使用 StreamUtils?

转换该迭代器

注意 returning a Collection 不是一个有效的选项,因为在这种情况下,大多数关系都是惰性的并且大小未知。

让我们比较一下遍历所有元素的常见操作,假设源是一个ArrayList。然后,有三种标准方法可以实现这一点:

  • Collection.forEach

    final E[] elementData = (E[]) this.elementData;
    final int size = this.size;
    for (int i=0; modCount == expectedModCount && i < size; i++) {
        action.accept(elementData[i]);
    }
    
  • Iterator.forEachRemaining

    final Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length) {
        throw new ConcurrentModificationException();
    }
    while (i != size && modCount == expectedModCount) {
        consumer.accept((E) elementData[i++]);
    }
    
  • Stream.forEach 最终会调用 Spliterator.forEachRemaining

    if ((i = index) >= 0 && (index = hi) <= a.length) {
       for (; i < hi; ++i) {
           @SuppressWarnings("unchecked") E e = (E) a[i];
           action.accept(e);
       }
       if (lst.modCount == mc)
           return;
    }
    

如您所见,实现代码的内部循环(这些操作结束的地方)基本相同,遍历索引并直接读取数组并将元素传递给 Consumer

类似的事情适用于 JRE 的所有标准集合,即使您使用的是只读包装器,它们也都针对所有方法进行了调整实现。在后一种情况下,Stream API 甚至会稍微获胜,必须在只读视图上调用 Collection.forEach 才能委托给原始集合的 forEach。同样,必须包装迭代器以防止尝试调用 remove() 方法。相比之下,spliterator()可以直接return原始集合的Spliterator,因为它不支持修改。因此,只读视图的流与原始集合的流完全相同。

虽然所有这些差异在衡量现实生活中的性能时都很难注意到,正如所说,内循环,这是与性能最相关的东西,在所有方面都是一样的案例。

问题是从中得出什么结论。您仍然可以 return 原始集合的只读包装器视图,因为调用者仍然可以调用 stream().forEach(…) 直接在原始集合的上下文中迭代。

由于性能并没有真正的不同,您应该像 “Should I return a Collection or a Stream?”

中讨论的那样专注于更高层次的设计

这里有很多性能建议,但遗憾的是,其中大部分都是猜测,几乎没有指出真正的性能考虑因素。

@Holger gets it right 指出我们应该抵制看似势不可挡的趋势,让性能尾巴摇摆 API 设计狗。

虽然在任何给定情况下有无数的考虑因素可以使流比其他形式的遍历慢、相同或快,但有一些因素表明流在性能上具有优势计数——在大数据集上。

与创建 Iterator 相比,创建 一个 Stream 有一些额外的固定启动开销 -- 更多 object在你开始计算之前。如果你的数据集很大,没关系;这是通过大量计算分摊的小启动成本。 (如果你的数据集很小,它可能也无关紧要——因为如果你的程序在小数据集上运行,性能通常也不是你的第一要务。) 问题是平行的时候;任何花费在建立管道上的时间都会进入 Amdahl 定律的连续部分;如果你看一下实现,我们会努力在流设置期间保持 object 倒计时,但我很乐意找到减少它的方法,因为这对并行的盈亏平衡数据集大小有直接影响开始胜过顺序。

但是,比固定启动成本更重要的是 per-element 访问成本。在这里,流实际上赢了——而且经常赢大钱——这可能会让一些人感到惊讶。 (在我们的性能测试中,我们经常看到流管道的性能优于 for-loop 而不是 Collection。)而且,对此有一个简单的解释:Spliterator 从根本上低于 per-element 访问成本比 Iterator,甚至顺序。有几个原因。

  1. 迭代器协议从根本上来说效率较低。它需要调用两个方法来获取每个元素。此外,因为迭代器必须对诸如在没有 hasNext() 的情况下调用 next() 或在没有 next() 的情况下多次调用 hasNext() 之类的事情具有鲁棒性,这两种方法通常都必须进行一些防御性编码(通常更多的状态和分支),这增加了效率低下。另一方面,即使是遍历拆分器 (tryAdvance) 的缓慢方式也没有这种负担。 (对于并发数据结构来说更糟,因为 next/hasNext 对偶性从根本上说是活泼的,并且 Iterator 实现必须比 [=13= 做更多的工作来抵御并发修改] 实现。)

  2. Spliterator 进一步提供了一个 "fast-path" 迭代 - forEachRemaining - 大多数时候都可以使用(reduction,forEach),进一步减少调节对数据结构内部的访问的迭代代码的开销。这也倾向于很好地内联,这反过来又增加了其他优化的有效性,例如代码移动、边界检查消除等。

  3. 此外,通过 Spliterator 遍历的堆写入次数往往比 Iterator 少得多。对于 Iterator,每个元素都会导致一个或多个堆写入(除非 Iterator 可以通过逃逸分析进行标量化并将其字段提升到寄存器中。)除其他问题外,这会导致 GC 卡标记 activity,导致对卡标记的缓存行争用。另一方面,Spliterators 倾向于拥有更少的状态,而 industrial-strength forEachRemaining 实现倾向于将任何内容写入堆直到遍历结束,而不是将其迭代状态存储在局部变量中这自然映射到寄存器,导致内存总线减少 activity.

总结:别担心,要开心。 Spliterator 是更好的 Iterator,即使没有并行性。 (它们通常也更容易编写并且更难出错。)