跳到主要内容

Kotlin 序列 Sequence 实战:用惰性求值告别中间集合的内存浪费

· 阅读需 6 分钟
重庆 Kotlin User Group

假设你手上有一个 100 万条的用户列表,需求特别简单:"筛出激活用户,然后取出他们的邮箱。"

你大概率会这么写:

val emails = users
.filter { it.isActive } // 第 1 步:新建一个 List,装 60 万个激活用户
.map { it.email } // 第 2 步:又新建一个 List,装 60 万个邮箱

代码两行,很清爽。但它背后发生的事一点都不清爽:filter 会完整遍历 100 万个元素,创建并填满一个新的 List;然后 map 再完整遍历这个 60 万个元素的中间 List,再创建一个新 List。

也就是说,为了最后那份结果,你临时养了两批"一次性"的大对象,遍历了整整两轮。数据量小的时候无所谓,一旦上到百万级,GC 就会开始疯狂回收这些转瞬即逝的中间集合,甚至在内存紧张的设备上直接 OOM。

Kotlin 给的解法优雅得有点不像话——加一个 asSequence():

val emails = users
.asSequence() // 切换到惰性模式
.filter { it.isActive }
.map { it.email }
.toList() // 终端操作,此时才真正开始算

结果一模一样,但全程只创建了一个最终 List,只遍历一轮,而且是一边筛一边转,绝不停下来攒中间结果。

眼见为实:执行顺序完全不同​

光说没感觉,我们用 println 把每一步打出来看。这是理解 Sequence 最好用的实验:

fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6)

println("===== List(及早求值)=====")
val listResult = numbers
.filter { println(" filter: $it"); it % 2 == 0 }
.map { println(" map: $it"); it * 10 }
println("结果: $listResult")

println("===== Sequence(惰性求值)=====")
val seqResult = numbers
.asSequence()
.filter { println(" filter: $it"); it % 2 == 0 }
.map { println(" map: $it"); it * 10 }
.toList()
println("结果: $seqResult")
}

List 版本的输出是"两波"——先把所有 filter 跑完,再统一跑 map:

===== List(及早求值)=====
filter: 1
filter: 2
filter: 3
filter: 4
filter: 5
filter: 6
map: 2
map: 4
map: 6
结果: [20, 40, 60]

Sequence 版本则是一个元素走完全程再来下一个:

===== Sequence(惰性求值)=====
filter: 1
filter: 2
map: 2
filter: 3
filter: 4
map: 4
filter: 5
filter: 6
map: 6
结果: [20, 40, 60]

看明白了吗?1 一进 filter 就被淘汰,不用等到后面;2 一通过 filter 就立刻被 map 处理掉。数据像水一样流过管道,而不是每一节管道都存一大缸水。

真正的杀手锏:短路​

惰性求值最妙的地方是——它可以在还没算完的时候就停手。加个 take(2) 试试:

val result = (1..1_000_000)
.asSequence()
.filter { println("检查 $it"); it % 7 == 0 && it % 3 == 0 }
.take(2)
.toList()

println(result) // [21, 42]

因为不需要提前生成中间集合,Sequence 找到 2 个满足条件的元素就当场收工,只检查了 42 个数字。

如果换成 List 版本,filter 会老老实实在 100 万个元素里全部跑一遍,把结果存进一个巨大的 List,然后 take(2) 从里面掐走前两个、把剩下的全丢掉。白算 99 万多次。

这个差别,就是从"能跑"到"跑得动"的距离。

无限序列:List 根本做不到​

惰性求值还解锁了一个全新能力——表示无限个元素。因为没人真的把它算完:

// 2 的幂:1, 2, 4, 8, 16... 想要多少个就 take 多少
val powers = generateSequence(1L) { it * 2 }
.take(10)
.toList()
println(powers) // [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]

// 斐波那契数列前 10 项
val fib = generateSequence(0L to 1L) { (a, b) -> b to (a + b) }
.map { it.first }
.take(10)
.toList()
println(fib) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

generateSequence(种子) { 下一个值 } 只在你索取时才计算下一个。这种写法要换成 List,你就得回答"给我 100 亿个元素"——直接把自己卡死。

实战:读一个大文件​

这是日常开发里最容易立刻见效的场景。假设要统计一个日志文件里所有 ERROR 行的长度总和:

// ❌ readLines() 会把整个文件一次性装进内存
val sum1 = File("app.log").readLines()
.filter { it.contains("ERROR") }
.sumOf { it.length }

// ✅ useLines 给的是一个惰性 Sequence,逐行读、逐行丢
val sum2 = File("app.log").useLines { lines ->
lines
.filter { it.contains("ERROR") }
.sumOf { it.length }
}

readLines() 面对一个 2GB 的日志文件会当场表演内存爆炸;useLines 则在任意时刻只在内存里保留当前这一行。文件多大都不怕。

顺带一提,bufferedReader().lineSequence() 是同一思路的另一种写法。

三件事必须记住​

1. 忘了终端操作 = 什么都没发生。 这是新手最容易踩的坑:

numbers.asSequence()
.filter { println("不会打印!") ; it > 3 }
// 没有 toList()/forEach()/sumOf(),整条管道一次都没执行

中间操作只是"把活儿记在小本本上",没人下单就没人干活。想调试却发现日志一行没打,八成就是这个原因。

2. 小集合别用 Sequence,反而更慢。 Sequence 每次流转元素都要走一层包装和状态机,有固定开销。经验判断:

情况建议
元素少于几千个,且只有一两步操作直接用 List,可读性优先
链式操作 ≥ 3 步用 Sequence
数据量大 / 有 take、first、any 等短路需求用 Sequence
需要处理超大文件、无限序列必须用 Sequence

3. Sequence 是同步的。 它解决的是"少算、少占内存",不是"不阻塞"。想要异步惰性流,那是 Flow 的活(我们之前专门写过一篇 Flow)——Sequence 主动拉取数据,Flow 由协程异步发射数据。两者是"同步版和异步版的对方"。

另外补一句:Sequence 没有并行版本,不像 Java Stream 能一键 parallelStream()。要并发处理,得配协程 asFlow() + flowOn。

小结​

  • filter/map 在 List 上是及早求值,每一步都新建中间集合
  • asSequence() 把管道改成惰性求值:一次一个元素流过全程,零中间集合
  • 惰性带来短路能力,take/first/any 能提前收工,避免海量无谓计算
  • 惰性让无限序列和超大文件逐行处理成为可能
  • 千万记得写终端操作,否则整条链一步都不会执行
  • 数据量小、步骤少时别急着上 Sequence,可读性优先

一句话原则:先写 List 版本保证正确,数据量一大就顺手补个 asSequence()——反正中间的业务代码一个字都不用改。

今日挑战​

找一段你项目里的链式集合操作(filter + map + take 那种),用 asSequence() 改造它,然后在每个 lambda 里塞一句 println,对比改造前后的输出顺序和执行次数。

进阶一题:用 generateSequence 写一个"生成长度不超过 100 万的所有偶数平方,取前 5 个"的表达式,不许写出任何 for 循环。做完你就会明白,惰性求值不是性能技巧,而是一种思考方式。