第054篇 Stream 常用操作:map、filter 与 collect 实战

简介: Java 8 Stream 是面向数据处理的惰性流水线,由数据源、中间操作(如filter/map)和终端操作(如collect/forEach)组成。惰性求值、短路机制与状态操作是理解关键;并行流仅在大数据量+重计算+无共享状态时才提效。简历写“熟悉”远不如答清“何时不该用”。

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-与空值处理:比判空更优雅的表达

有任何问题欢迎在评论区留言交流。

相关文章
|
13小时前
|
存储 Java 数据库连接
第044篇 ThreadLocal 原理:主线程到底能不能用
ThreadLocal核心在于“线程独享+弱引用Key+强引用Value”:每个线程持独立ThreadLocalMap,Key为ThreadLocal弱引用,Value强引用用户对象;若不显式remove,线程池复用时易致内存泄漏与数据串号。务必在finally中清理!
19 0
|
13小时前
|
SQL 缓存 Java
第039篇 synchronized 原理:对象头、锁升级与重量级锁
`synchronized` 底层依托对象头 Mark Word 与 `monitorenter` 指令,锁随竞争升级:无锁 → 偏向锁(JDK15 已废弃)→ 轻量级锁(CAS 自旋)→ 重量级锁(ObjectMonitor + 内核阻塞)。`wait` 必在同步块内调用,确保条件检查与等待原子性。
15 0
|
13小时前
|
缓存 监控 Java
第049篇 JVM 调优入门:从 OOM 日志倒推问题
JVM调优核心是方法论而非参数背诵:先分类(堆/元空间/直接内存/栈OOM)、再取证(GC日志/内存快照/线程栈)、接着定性(泄漏 or 配置不足)、最后动参。顺序错则掩盖问题,盲目调大堆只会延迟OOM。Android需额外关注堆分区、largeHeap及meminfo诊断。
20 0
|
14小时前
|
消息中间件 缓存 Java
第028篇 LinkedList 与 ArrayList 选型:随机访问与插删的权衡
LinkedList与ArrayList选型关键在读写特征:ArrayList随机访问O(1),适合读多写少;LinkedList仅两端增删O(1),但按索引操作需遍历O(n),且节点开销大、缓存不友好。工程中默认选ArrayList,除非明确需双端队列语义。
15 0
|
13小时前
|
安全 Java 编译器
第064篇 空安全入门:?、!! 与 ?. 的三分天下
Kotlin空安全是编译期类型契约:`T`与`T?`本质不同。`?.`链式短路不求值,`?:`右值懒执行,`?.let`中`it`为非空类型,`!!`仅限框架保证场景。核心原则——null是需显式处理的分支,可空性应在数据入口收敛。
19 0
|
14小时前
|
缓存 安全 测试技术
第032篇 LinkedHashMap 与 LRU:图片缓存的原型
Android面试高阶题常以LinkedHashMap与LRU为切入点,考察“顺序”这一关键维度:它通过双向链表维护插入/访问序(accessOrder=true时get/put自动移至尾部),配合removeEldestEntry可几行实现线程不安全但高效的LRU缓存。LruCache即基于此构建。
16 0
|
13小时前
|
Java 定位技术 开发工具
第059篇 建造者模式:链式调用为何无处不用在哪些地方
建造者模式核心是**将复杂对象的创建过程外置、分步可控、集中校验**。它解决参数过多、互斥依赖、分步初始化问题,关键在于:私有构造、链式setter、build()统一校验、产物不可变(final+防御拷贝)、默认值内聚于Builder——非仅为语法糖,而是工程化构造控制。
23 1
|
16小时前
|
存储 传感器 缓存
第001篇 变量与基本类型:整型、浮点与包装类型
Android面试首题常考变量与基本类型——看似简单,实则暴露候选人是否“理解Java”而非仅“用过”。从步数存储选型、int溢出陷阱、浮点精度误区,到包装类缓存机制、static生命周期等,每个细节都关乎线上稳定性。扎实掌握八种基本类型的本质、边界与坑点,是进阶的真正起点。
17 0
|
13小时前
|
缓存 Java 编译器
第069篇 object 与 companion object:Kotlin 里的单例
本文深入剖析 `object` 与 `companion object` 的本质差异:二者虽均为单例,但字节码结构、初始化时机及 Java 可见性截然不同。`object` 编译为私有静态实例(饿汉式),而 `companion object` 是宿主类内的静态 `Companion` 实例,其初始化绑定类加载。`@JvmStatic` 和 `@JvmField` 不提升性能,而是改变 Java 调用形态与反射可见性。核心警示:慎用 `object` 持有可变状态,伴生对象避免耗时初始化——正确选型应基于作用域需求,而非便利性。
24 0
|
16小时前
|
Java API 开发工具
第005篇 数组与多维数组:批量数据的地基
数组是Java中最基础却易被低估的定长连续容器,本质为“数组的数组”(多维),越界抛异常、拷贝为浅拷贝、`length`是字段非方法。常见坑:`Arrays.asList(基本类型数组)`返回size=1、误当深拷贝、与List混淆。工程中重性能、少扩容、严校验。
18 0