Sequence 这题的分水岭在两个词上:惰性与冷流。绝大多数人只知道"asSequence 可以省内存",但讲不清"为什么省"和"什么时候会出问题"。后者才是面试官真正想问的——因为惰性求值一旦用错,产生的 bug 往往是不崩溃但结果不对,极难定位。
先把结论放在前面:Sequence 是惰性的(操作被记录成一条链,直到终端操作才执行)且是冷流(每次消费都从头重新执行,不缓存结果)。这两个性质各带来一个收益和一个代价:省内存(中间不物化)与不能多次遍历(第二次遍历会重新计算)。Android 上最典型的适用场景是大集合的多级过滤与无限/无界数据的截断(配合 take/takeWhile 让无限序列也能用)。
机制拆解
asSequence() 返回一个 Sequence,它内部只有一个状态机:iterator() 每次被调用都新造一个迭代器。map/filter 返回新的 Sequence(包装上游 + 记录一个变换),toList/sum/forEach 这类终端操作才真正触发 hasNext/next 的循环。所以一条 asSequence().filter{}.map{}.toList() 的执行路径是:拿迭代器 → 逐个 next → 每个元素先过 filter 再过 map → 直接进结果集合,中间没有任何集合。这就是省内存的机制来源——不是"少拷贝了几次",而是"根本没有中间集合"。
冷流的含义要举例才清楚。假设 val s = data.asSequence().map { expensive(it) },然后 s.toList() 与 s.count() 各调一次——expensive 会对每个元素执行两次。而如果写成 data.asSequence().map { expensive(it) } 两次调用 asSequence(),那就是两个独立序列,各自算一次(不共享计算)。这个区别在"序列来自文件读取或网络分页"时尤其重要:每次遍历都会重新读一遍数据源。
约束部分同样关键:Sequence 没有索引访问(get(i) 要顺序遍历到第 i 个,O(n)),所以按下标取值应该退回 List;Sequence 只能遍历一次(调用两次 iterator() 得到两个独立迭代器,不是同一个游标)。take(n) 与 takeWhile 属于短路操作,能在拿到足够元素后停止,这也是惰性最有价值的地方——generateSequence 配合 take 可以处理无界序列。
这些坑的正确绕法
最常见的坑是对 Sequence 多次消费,冷流式语义导致第二次遍历重新生成。 表现是:把 asSequence() 赋给一个变量,在两处分别 toList() 与 sumOf{},日志里发现转换逻辑被执行了两遍;更糟的情况是序列的数据源是数据库游标或文件流,第二次遍历时源已被读完,返回空列表或抛异常。修法有两条:①消费一次就物化(val list = seq.toList() 后复用 list);②把序列写成内联表达式(data.asSequence().map{}.toList()),不要抽成变量。
其次是在序列里做有副作用的操作,副作用次数与直觉不符。 表现是 seq.map { count++; it }.toList() 之后 count 的值比列表长度大,或 log() 打了两次。根因同冷流 + 惰性:只要序列被消费,变换就对每个经过的元素执行;消费两次就执行两遍。规矩是序列里只做纯转换,副作用放终端操作里,或者干脆物化后再处理。
还有一个更隐蔽的坑:对无限序列用 toList() 或 sortedBy,内存被打满。 表现是调用 generateSequence(1) { it + 1 }.map { it * it }.toList() 直接 OOM。根因是 toList 与 sortedBy/groupBy 都需要完整物化,惰性优势归零。修法是在物化之前先截断(take(10)、takeWhile{ it < 100 }),或者改用终端聚合(first、sum、count),它们只消费需要的那部分。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
val data: List<Item> = loadItems()
// 1) 惰性:中间无集合,只遍历一次
val cheap = data.asSequence()
.filter {
it.price > 100 }
.map {
it.price * 2 }
.toList() // 终端操作在这里才执行
// 2) 冷流:同一序列消费两次 = 计算两遍
val seq = data.asSequence().map {
expensive(it) }
val a = seq.toList() // expensive 执行 n 次
val b = seq.sumOf {
it.price } // expensive 再执行 n 次
// 修法一:消费一次就物化
val materialized = seq.toList(); val c = materialized.sumOf {
it.price }
// 修法二:写成内联,两次各建序列(不共享计算)
val d = data.asSequence().map {
expensive(it) }.toList()
// 3) 短路:无限序列也能安全用
val first10 = generateSequence(1) {
it + 1 }
.map {
it * it }
.take(10) // 拿到 10 个就停
.toList()
// 4) 反例:物化前不截断 → OOM
// val boom = generateSequence(1) {
it + 1 }.map {
it }.toList()
// 5) 聚合类终端操作只消费需要的那部分
val total = data.asSequence().map {
it.price }.sum() // 不产生集合
val firstValid = data.asSequence().map {
it }.firstOrNull() // 只走到第一个
// 6) 序列没有索引访问
val s = data.asSequence()
val third = s.elementAt(2) // 要从头遍历,O(n)
// 需要多次按下标取值,就该用 List
// 7) 数据源型序列:每次遍历都重新读源
// val rows = db.query().asSequence()
// rows.toList(); rows.count() // 第二次重新查询数据库
这段代码值得盯三处:第一处,第 2 段把"冷流导致计算两遍"与两种修法并排写清;第二处,generateSequence 配合 take 与注释掉的 OOM 反例,形成"短路 vs 物化"的对照;第三处,第 7 段点出数据源型序列的严重后果——第二次消费重新查库。
这题在面试里怎么问、怎么答
"惰性求值和 Stream 的惰性是一回事吗?"答:机制相同(中间操作返回新流、终端操作触发),差别在载体——Java 的 Stream 并行度可配置且有 Spliterator,Kotlin 的 Sequence 更轻、更贴近 Kotlin 集合(直接支持 first、single、chunked 这类更"集合感"的操作),但没有并行版本。Kotlin 想并行要用 asFlow() + flowOn(走协程)或 Java 的 parallelStream。
"什么时候不该用 Sequence?"给三条:①数据量小(千级)且要多次遍历 → 用 List,一次物化后随便遍历;②需要按下标随机访问 → 用 List;③需要多次消费 → 先物化。
"Sequence 和 Flow 有什么区别?"答:Sequence 是同步的、无上下文的,适合纯数据变换;Flow 是协程的、有 Flow 上下文(能切换调度器、能在 collect 里做挂起操作),适合流式处理。Android 上从数据库/网络流式读取用 Flow 更合适。
"使用中遇到过什么问题?"案例一:一个数据转换序列被两个消费者分别消费,转换里的正则匹配执行了两遍,耗时翻倍;修复为物化后复用。案例二:generateSequence 配 groupBy 做实时统计,把无限序列完整物化导致 OOM;修复为先 takeWhile 限定范围再做聚合。
再补一个工程上值得讲清的一点:Android 上 Sequence 与 Compose 的 remember 组合有坑值得提前知道。 如果在 Composable 里写 val list = data.asSequence().map { ... }.toList() 而没有 remember 包裹,那么每次重组都会重新执行整条链——而 Sequence 的惰性在这里帮不上忙,因为已经有 toList 了。正确写法是 val list = remember(data) { data.asSequence().map { ... }.toList() },用 data 作为 remember 的 key:数据变了才重算,数据没变就复用结果。这条把上一批的"链式性能"与 Compose 的重组机制连了起来,是很实际的落地经验。
给正在准备面试的你
把 Sequence 画成一张"惰性执行时间线":横轴是元素位置 1..n,纵轴是操作阶段。画出三行:filter/map(画成一条随元素推进的流水线,每个元素依次经过两站)、toList(画成在末端一次性"倒桶"进结果集合)、以及中间一条虚线标"这里没有中间集合——虚线区域就是省下来的内存"。再在图右侧画一个分叉,标"第二次消费 = 从头再走一遍整条线",旁边写"冷流"。图下方写一行结论:"惰性省的是内存,冷流的代价是重复计算;物化一次是折中点。"这张图能完整回答"asSequence 到底省了什么"。
再补工程案例与踩坑——应用落点是找出项目里的 asSequence() 变量,检查是否被多处消费,能物化就物化;给 Compose 里的派生计算补 remember(key);给"无限/大序列 + 聚合"的代码加截断;数据源型序列(游标、流)只消费一次。
复习时别孤立刷题:集合操作符链——asSequence() 就是把 eager 链改成 lazy 链,两者是同一套操作符在不同执行模型下的表现。
划两句重点:Sequence 惰性 + 冷流;多次消费会重复计算,数据源型序列会重新查源;物化前必须先 take/takeWhile 截断;序列无索引访问;Compose 里要 remember(key);不需要惰性时直接用 List。
下一篇聊高阶函数:函数作为参数与返回值——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:集合操作符链:filter、map-与-fold
下一篇预告:高阶函数:函数作为参数与返回值
有任何问题欢迎在评论区留言交流。