目前,我正在研究Kotlin,并对序列与馆藏有疑问。
我读了一个有关该主题的blog post,在那里您可以找到以下代码片段:
列表实现:
val list = generateSequence(1) { it + 1 }
.take(50_000_000)
.toList()
measure {
list
.filter { it % 3 == 0 }
.average()
}
// 8644 ms
序列实施:
val sequence = generateSequence(1) { it + 1 }
.take(50_000_000)
measure {
sequence
.filter { it % 3 == 0 }
.average()
}
// 822 ms
这里的意思是Sequence实现的速度快了大约10倍。
但是,我不太了解为什么。我知道使用Sequence可以进行“惰性评估”,但是在此示例中,我找不到任何有助于减少处理量的原因。
但是,我在这里知道为什么序列通常更快:
val result = sequenceOf("a", "b", "c")
.map {
println("map: $it")
it.toUpperCase()
}
.any {
println("any: $it")
it.startsWith("B")
}
由于使用序列可以“垂直”处理数据,因此当第一个元素以“ B”开头时,不必为其余元素进行映射。在这里很有意义。
那么,为什么在第一个示例中它也更快?
答案 0 :(得分:6)
让我们看看这两种实现的实际作用:
List实现首先在具有5000万个元素的内存中创建一个List
。这将至少占用200MB的空间,因为整数需要4个字节。
(实际上,它可能远不止于此。<< Alexey Romanov指出,由于它是通用的List
实现而不是IntList
,因此不会可以直接存储整数,但可以将它们``装箱''-存储对Int
对象的引用。在JVM上,每个引用可以是8或16个字节,每个Int
可以占用16个字节,从而得到1 –2GB。此外,取决于创建List
的方式,它可能从一个小的数组开始,并随着列表的增长而不断创建越来越大的数组,每次都复制所有值,仍然使用更多的内存。)< / p>
然后,它必须从列表中读取所有值,对其进行过滤,然后在内存中创建另一个列表。
最后,它必须重新读回所有那些值,以计算平均值。
Sequence实现不需要存储任何内容!它只是简单地按顺序生成值,然后像每个值一样检查它是否可以被3整除,如果可以,则将其包括在平均值中。
(如果您是“手动”实施,这几乎就是您要做的。)
您可以看到,除了进行除数检查和平均计算外, List实现还进行了大量的内存访问,这将花费大量时间。比序列版本慢,没有!
看到这一点,您可能会问为什么我们不在所有地方都使用Sequences……但这是一个极端的例子。设置然后迭代Sequence本身会有一些开销,对于较小的列表,这些开销可能超过内存开销。因此,只有在列表很大,严格按顺序处理,有几个中间步骤和/或沿途过滤掉许多项的情况下(尤其是序列是无限的!),序列才具有明显的优势。 / p>
根据我的经验,这些情况很少发生。但是这个问题表明,在他们识别出它们时,识别它们有多么重要!
答案 1 :(得分:3)
利用惰性评估可以避免创建与最终目标无关的中间对象。
此外,上述文章中使用的基准测试方法也不是非常准确。尝试使用JMH重复该实验。
初始代码产生一个包含50_000_000个对象的列表:
val list = generateSequence(1) { it + 1 }
.take(50_000_000)
.toList()
然后遍历它,并创建另一个包含其元素子集的列表:
.filter { it % 3 == 0 }
...然后继续计算平均值:
.average()
使用序列可以避免执行所有这些中间步骤。下面的代码不会产生50_000_000个元素,而只是表示1 ... 50_000_000个序列:
val sequence = generateSequence(1) { it + 1 }
.take(50_000_000)
在其中添加过滤不会触发计算本身,而是从现有的序列(3、6、9 ...)中得出新的序列:
.filter { it % 3 == 0 }
最后,调用终端操作,该操作触发对序列的评估和实际计算:
.average()
一些相关的读物: