Stream 是 Java 8 里最容易被滥用的 API。写在简历里"熟悉 Stream"加分不多,因为在实际项目里,问的从来不是"会不会用",而是"什么时候不该用"。面试官真正想听的是:惰性求值带来了什么、并行流什么时候比串行还慢、collect 和 forEach 的区别在哪里、状态操作为什么在并行流里会出问题。
先把结论放在前面:Stream 是一套面向数据处理的流水线,由数据源、中间操作、终端操作三段组成。中间操作是惰性的——调用它不触发任何计算,只是往流水线里追加一个节点;直到终端操作出现,才开始真正遍历数据源。 filter/map/sorted/distinct/limit 是常用的中间操作,collect/forEach/count/reduce/anyMatch 是终端操作。终端操作只能有一个,调用后流水线即被消费。
机制拆解
把 list.stream().filter(p).map(f).collect(toList()) 拆开看执行顺序:先构建 Stream 对象(此时不碰数据源)→ filter 追加一个谓词节点 → map 追加一个函数节点 → collect 触发遍历,对每个元素依次过谓词判断、再过函数变换,最后交给 Collector 归约。 数据源是集合时是一次遍历;数据源是 Stream.iterate 这类无限流时,因为惰性,limit(10) 可以在拿到 10 个元素后就停,不会无限跑下去。
短路是另一个关键能力:limit、anyMatch、findFirst 都属于短路操作,一旦得出结果就不再取后续元素。sorted 和 distinct 则是有状态操作,需要把中间结果整个物化才能继续——这也解释了为什么它们不能出现在并行流的前段而不影响正确性,但也意味着它们是并行流上的天然瓶颈。
并行流用 ForkJoinPool.commonPool() 支撑,默认并行度是 CPU 核数 - 1,容器里由 JVM 依据核数决定,Android 上由 ART 依据设备核数决定。它适合同一个问题拆成多个互不依赖的子任务、且每个元素处理有可观的计算量(经验上要上百条数据、每条处理耗时到微秒级才划算)。 如果数据量小或单条处理只是几个字段的读取,并行带来的线程切换与任务拆分开销会超过收益,实测往往比串行还慢。
这些坑的正确绕法
最常见的坑是在并行流里操作共享可变集合,出现数据丢失或 ConcurrentModificationException。 根因是并行流让多个工作线程同时推进,共享状态没有任何同步机制。ArrayList 这类非线程安全容器在并发写入时,内部数组的下标赋值会互相覆盖,表现为元素变少或 ArrayIndexOutOfBounds。 对策有两条:把待处理集合换成 Collections.synchronizedList 或 CopyOnWriteArrayList(读多写少时后者很合适),或者干脆让 Lambda 保持纯函数语义——不修改外部状态,把结果收集到 Collector 里由归约阶段合并。
其次是在 Android 上把并行流当性能优化的默认手段,结果是更慢。 并行流适合同构、可切分、无副作用的计算;以下情况它是负优化:数据量只有几百条、单条处理是简单字段读取、Lambda 里存在共享状态或加锁、或者在主线程上计算 UI 相关数据。真实项目里,UI 线程上的 IO 才是真瓶颈,把 Stream 并行化并不会让界面更快。
还有一个更隐蔽的坑:peek 里做副作用,被当成 forEach 用。 peek 的语义是"窥探",官方明确它不应该被用来产生副作用,未来的实现可能因为优化而改变它的调用次数。常见错误写法是 list.stream().peek(u -> save(u)).collect(toList()) —— 某次 JDK 优化里 peek 被跳过,导致保存逻辑根本没执行。 对策是老老实实用 map 或 forEach,需要副作用就用 forEach。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 惰性 + 短路:limit 让无限流可以安全使用
List<Integer> firstTen = Stream.iterate(1, i -> i + 1)
.filter(i -> i % 2 == 0)
.limit(10)
.collect(Collectors.toList());
// groupingBy 做分组归约,比手写 Map 好读
Map<Boolean, List<String>> byPrefix = list.stream()
.collect(Collectors.groupingBy(s -> s.startsWith("a")));
// counting / averaging / summarizing:现成的归约
IntSummaryStatistics stats = list.stream()
.map(User::getAge).collect(Collectors.summarizingInt(Integer::intValue));
Log.d("s", "avg=" + stats.getAverage() + " max=" + stats.getMax());
// toMap 要给重复键的处理策略,否则遇到重复直接抛 IllegalStateException
Map<Long, String> byId = list.stream()
.collect(Collectors.toMap(User::getId, User::getName, (a, b) -> b));
// peek 只用于调试观察,不做副作用
list.stream().peek(System.out::println).count();
// 并行:仅在数据量大、单条计算重、无共享状态时使用
long cnt = bigList.parallelStream()
.filter(s -> heavyCheck(s))
.count();
这段代码值得盯三处:第一处,toMap 的合并函数是必填的,缺了会在遇到重复 key 时抛 IllegalStateException: Duplicate key,这是线上偶发崩溃的高频来源;第二处,summarizingInt 一次拿到 count/avg/min/max,比分开跑四趟高效;第三处,peek 只做 System.out 这类观察,把副作用交给 forEach。
这题在面试里怎么问、怎么答
"Stream 的惰性求值是怎么回事?"答:中间操作返回新的 Stream 而不执行计算,每个 Stream 持有前一个 Stream 的引用形成管道,终端操作才从数据源开始按顺序推元素。补两个推论:惰性让 limit 能在无限流上工作,也让 map 的函数可能被调用多次(不保证只调一次),因此不能在 map 里做有副作用的事。
"forEach 和 for 循环、forEach 和 collect 有什么区别?"分三组答。Stream.forEach 与增强 for 循环:语义上都是顺序遍历,但并行流的 forEach 不保证顺序,且 forEach 里不能修改集合(结构性修改会抛异常)。 Stream.forEach 与 collect:forEach 没有返回值、只做副作用,collect 负责归约出结果(列表、Map、统计量),二者在语义上互补。List.forEach 与 Stream.forEach:前者是集合的直接方法,没有惰性与管道。
"并行流在什么情况下更快,怎么判断?"给判据而不是结论:数据量足够大(经验上 10⁴ 起步)、单个元素的计算是 CPU 密集且无 IO、单条耗时在微秒量级以上、集合支持高效切分(ArrayList、数组好,LinkedList 需要遍历切分,优势有限)、计算可并行无共享状态。判断方法是用 System.nanoTime 分别测串行与并行,测多次取中位数,别用一次结果下结论。
"使用中遇到过什么问题?"案例一:数据导出功能用并行流处理 200 条记录,耗时反而从 8ms 变成 30ms;定位到每条记录只是读几个字段,任务拆分与线程切换成本占了大头;修复为改回串行。案例二:线上偶发崩溃,堆栈是 IllegalStateException: Duplicate key;定位到 Collectors.toMap 遇到重复 id;修复为补上合并函数并把兜底值写清楚。
再补一个工程上值得讲清的点:Android 上的 D8 desugar 能处理部分 Java 8 库 API(java.util.function、Optional、Stream 的部分操作),但覆盖范围随 minSdk 与 AGP 版本变化。 别把 Stream 当成"设了 minSdk 24 就万事大吉",而是在工具类里收口,把需要 Stream 的那几处抽成静态方法,这样将来遇到低版本兼容问题,改一个文件就够。
给正在准备面试的你
把 Stream 流水线画成一条三段的横线:左段"数据源"(集合/数组/Stream.generate/iterate/文件行),中段"中间操作"分成三类挂上去——过滤型(filter、distinct、limit、skip)、变换型(map、flatMap、peek)、排序型(sorted)、归约前的状态型;右段"终端操作"挂上 collect、forEach、count、reduce、anyMatch。 中间操作上方画一个括号标注"惰性:只追加,不执行",终端操作上方标注"触发遍历:一次且仅一次"。再在图下方写一句"短路 vs 有状态"的区分。面试中关于 Stream 的问题,全都在这张图上。
再补工程案例与踩坑——应用落点是把项目里手写的 for 循环 + if 筛选 + Map 归约这类三段式代码,收敛成 stream 写法(可读性收益最大的地方),同时把并行流的使用点逐个标出来复查一遍,确认没有共享状态与锁。
复习时别孤立刷题:Lambda 与函数式接口——四个函数式接口正好对应 Stream 的四个阶段,接口设计决定了流水线好不好写。
划两句重点:Stream 惰性求值、短路执行,终端操作只调一次;toMap 必须给重复键合并策略,peek 不能做副作用;并行流只在大数据量 + 重计算 + 无共享状态时才有收益。
下一篇聊 Optional 与空值处理:比判空更优雅的表达——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Lambda-与函数式接口:Android-开发的日常语法
下一篇预告:Optional-与空值处理:比判空更优雅的表达
有任何问题欢迎在评论区留言交流。