迭代器与 Java 流 8

为了利用 Jdk 8 中包含的各种查询方法,我试图设计域模型,其中具有多重性(具有零个或多个实例)的关系的获取器返回一个 ,而不是 or 。java.util.stream*Stream<T>Iterable<T>Iterator<T>

我的疑问是,与 ?Stream<T>Iterator<T>

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

或者,我是否应该始终返回 or ,并将选择是否使用流的决定留给最终用户,方法是将该迭代器与 ?Iterator<T>Iterable<T>StreamUtils

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


答案 1

这里有很多性能建议,但可悲的是,其中大部分都是猜测,很少指向真正的性能考虑因素。

@Holger通过指出我们应该抵制看似压倒性的趋势来让性能尾随API设计狗而变得正确

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

与在开始计算之前创建一个 -- 几个对象相比,创建 有一些额外的固定启动开销。如果您的数据集很大,则无关紧要;这是一个很小的启动成本,在大量的计算中摊销。(如果你的数据集很小,那可能也无关紧要 - 因为如果你的程序在小数据集上运行,性能通常也不是你最关心的问题。这确实很重要的地方是并行时;建立管道所花费的任何时间都会进入阿姆达尔定律的序列部分;如果你看一下实现,我们努力在流设置期间保持对象计数,但我很乐意找到减少它的方法,因为这对盈亏平衡数据集的大小有直接影响,其中并行开始赢得顺序。StreamIterator

但是,比固定启动成本更重要的是每个元素的访问成本。在这里,流实际上赢了 - 而且经常赢了大 - 有些人可能会感到惊讶。(在我们的性能测试中,我们经常看到流管道的性能可能优于其 for 循环。而且,对此有一个简单的解释:每个元素的访问成本从根本上低于 ,甚至按顺序。这有几个原因。CollectionSpliteratorIterator

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

  2. Spliterator进一步提供了一个“快速路径”迭代-- --可以在大多数时间使用(reduce,forEach),进一步减少了调解访问数据结构内部的迭代代码的开销。这也倾向于很好地内联,这反过来又提高了其他优化的有效性,例如代码运动,边界检查消除等。forEachRemaining

  3. 此外,遍历 via 的堆写入次数往往比 使用 的堆写入次数少得多。使用 ,每个元素都会导致一个或多个堆写入(除非可以通过转义分析将其字段提升到寄存器中来标量。除其他问题外,这会导致GC卡标记活动,从而导致卡标记的缓存行争用。另一方面,往往具有较少的状态,并且工业级实现倾向于将任何内容写入堆,直到遍历结束,而不是将其迭代状态存储在自然映射到寄存器的局部变量中,从而减少内存总线活动。SpliteratorIteratorIteratorIteratorSpliteratorsforEachRemaining

总结:别担心,要快乐。 是一个更好的,即使没有并行性。(它们通常也更容易写,更难出错。SpliteratorIterator


答案 2

让我们比较一下迭代所有元素的常见操作,假设源是 .然后,有三种标准方法可以实现此目的: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的所有标准集合,所有这些集合都针对所有方法进行了调整,即使您使用的是只读包装器。在后一种情况下,API甚至会稍微获胜,必须在只读视图上调用才能委派给原始集合。同样,必须包装迭代器以防止尝试调用该方法。相反,可以直接返回原始集合,因为它没有修改支持。因此,只读视图的流与原始集合的流完全相同。StreamCollection.forEachforEachremove()spliterator()Spliterator

虽然在测量现实生活中的性能时几乎不会注意到所有这些差异,因为如前所述,内循环(与性能最相关的东西)在所有情况下都是相同的。

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

由于性能并没有真正的不同,您应该专注于更高级别的设计,如“我应该返回集合还是流?”中讨论的那样。


推荐