一段报表代码用 groupingBy 按"地区"分组、再按"金额"求和,结果"总计比明细少"。查下来:groupingBy 默认丢弃 null 键(于是"地区为空"的 200 条不见了),而 summingDouble 在"空组"上返回 0(于是"没数据的地区"看起来"有数据".
Collectors 的坑集中在一处:它们对"空值、空组、顺序、可变性"的处理,都有默认行为——而默认行为"不报错",只"算出一个不同的数".
1. "分组丢数据"的三种默认
| 默认 | 后果 |
|---|---|
groupingBy 丢弃 null 键 |
统计总额少 |
summingX 空组给 0 |
无法区分"0"与"无数据" |
toMap 键冲突抛异常 |
偶发失败 |
第一项最"贵":"地区为空"的记录"默默消失"第一项的"反向陷阱"是"用 toMap 代替 groupingBy":toMap 遇到 null 键"直接抛 NPE"——于是"改法"把"静默丢"换成"启动即炸",两者都不算"处理".
——而它往往正是"需要关注的脏数据".
第三项的"偶发性"来自"数据分布":"同一天两条记录"平时没有,遇到"补数据/重跑"就有——**于是它"上线半年才炸一次".
第三项在"key 重复"时直接抛 IllegalStateException——而生产上"重复"是常态(如"同一天两条记录").
"分组前先规范化键"是最省心的做法,而它只要一个 map:
// 三种"键规范化"(按场景选)
1 补默认值:o -> o.region() == null ? "未知" : o.region()
2 用"空字符串":o -> Objects.toString(o.region(), "")
3 先过滤"脏数据"并告警:filter(o -> o.region() != null)
// 判据:报表要"总数对得上" → 选 1;要对账"脏数据" → 选 3
2. "键为 null"的三种处理
| 处理 | 说明 |
|---|---|
先"补默认值"("未知") |
最常用(可统计) |
用 HashMap 允许 null 键的分组 |
需自定义 |
| 单独筛出来"告警" | 最稳(暴露脏数据) |
第一项是"报表场景"的正解:"未知地区"作为一个分类出现第一项的"额外收益"是"脏数据可见":"未知 200 条"本身就是"该修数据"的信号——**而"丢了"就"永远不知道有 200 条".
——于是"总数对得上",而"脏数据"也可见.
"计数 + 求和"的组合可以写成"一次遍历",而不必"两组流":
// 一次遍历:同时得到 计数 / 总额 / 最大值
record Stat(int n, double sum, double max) {}
var stats = orders.stream().collect(groupingBy(Order::region,
Collector.of(() -> new double[]{0,0,0},
(a, o) -> { a[0]++; a[1]+=o.amount(); a[2]=Math.max(a[2], o.amount()); },
(a, b) -> { a[0]+=b[0]; a[1]+=b[1]; a[2]=Math.max(a[2], b[2]); return a; },
a -> new Stat((int)a[0], a[1], a[2]))));
// 判据:n==0 → 无数据;n>0 && sum==0 → 真 0 元
3. "空组 vs 0 值"的三种区分
| 做法 | 效果 |
|---|---|
用 Map<K, List<T>> 自己算 |
能区分 |
| 用"计数 + 求和"两个收集器 | 能区分(c=0 表示无) |
| 只看和 | 无法区分 |
第二项的"代价"是"多一个字段":但"多一个字段"换来"能区分",在"对账"场景里是刚需——**因为"对账"的第一句话就是"这个 0 是没数据还是真的 0".
第二项是"最省"的:同时收集"条数"与"总额"——于是"总额 0 且条数 0"= 无数据,"总额 0 且条数 >0"= 真 0 元.
// 两组收集器:同时拿"条数"与"总额"(能区分"无数据"与"0")
var stat = orders.stream().collect(groupingBy(
o -> o.region() == null ? "未知" : o.region(),
collectingAndThen(toList(), l -> new Stat(l.size(), l.stream().mapToDouble(Order::amount).sum()))
));
// 判据:Stat(0, 0)=无数据;Stat(3, 0)=三笔 0 元
"唯一性"要"能被打断",否则"冲突"就是"静默覆盖":
// 三种"唯一性"的表达
1 toMap(k, v) // 冲突即抛(默认,最安全)
2 toMap(k, v, (a,b) -> b) // 后写覆盖(最危险)
3 toMap(k, v, (a,b) -> { throw new IllegalStateException("dup: "+a+" / "+b); })
// 判据:不确定"是否唯一"就用 3 —— 让"重复"显式失败
4. "toMap 冲突"的三种处理
| 处理 | 说明 |
|---|---|
给"合并函数"((a,b) -> b) |
最常用(后写覆盖) |
收集成 List 再归并 |
明确语义 |
| 抛异常(默认) | 只适合"确信唯一" |
第一项的"风险"是"静默覆盖":"同一天两条记录"时,只留了一条第一项的"选择语义"要写清:(a,b) -> b 是"后写覆盖",(a,b) -> a 是"先写保留"——**而"哪条在前"取决于"流顺序",于是"留哪条"可能"两次运行不同".
——而"留哪条"取决于"流顺序",可能不稳定.
"有序收集"要么"换容器",要么"最后排序",两者成本不同:
// 两种"稳定顺序"
1 groupingBy(k, LinkedHashMap::new, toList()) // 保"首次出现顺序"
2 collect(...).entrySet().stream().sorted(...) // 显式排序(语义最清楚)
// 判据:报表"行序"要稳定 → 用 1(便宜);要"按业务字段排" → 用 2
5. "并行流"的三种失真
| 失真 | 原因 |
|---|---|
顺序丢(groupingBy 的 map 无序) |
并行 + 无序容器 |
| 累加器"非结合" | 用 reduce 做减法 |
| 有副作用 | 在 map 里"改外部集合" |
第一项的"暴露方式"是"导出文件的行序":"上周的报表按 A/B/C 排,这周按 C/A/B"——而"人眼对行序敏感",于是"以为数据变了".
第一项在"需要稳定顺序"的报表里很实际:"同样数据两次运行,分组顺序不同"**——**解法是"用 LinkedHashMap 收集"或"最后排序".
"内存暴涨"这条,是因为"默认收集器"会"物化中间结果":
// 三种"重"的收集器(大流慎用)
1 toList() / toSet():全部物化(内存 = 元素数)
2 groupingBy(..., toList()):物化"每个组"
3 counting()/summingX():只要"累加器"(内存 = 组数)
// 判据:只要"数"就不要"物化"——后者会让"10 万条流"吃掉几百 MB
6. 排错:五种"收集器问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 总计少 | null 键被丢 | 补默认值 |
| 空地区显示 0 | 无法区分 | 加计数 |
| 偶发异常 | toMap 冲突 |
给合并函数 |
| 结果顺序不稳 | 并行 + HashMap | LinkedHashMap |
| 内存暴涨 | 中间 List 太大 |
用"累加器"收集器 |
"两行断言"是"报表"最便宜的保险,而它比"单元测试"更贴业务:
// 一条"分组自洽"断言
var total = orders.stream().mapToDouble(Order::amount).sum();
var grouped = orders.stream().collect(groupingBy(
o -> o.region() == null ? "未知" : o.region(), summingDouble(Order::amount)));
assert Math.abs(grouped.values().stream().mapToDouble(Double::doubleValue).sum() - total) < 1e-6;
// 判据:这条断言"失败"就说明"有数据没进分组"
7. 边界:什么情况"默认行为"就是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 数据"保证无 null 键"(非空约束) | 无丢失风险 | 默认即可 |
| "唯一键"已由约束保证 | 无冲突 | 默认 toMap |
| 单线程、小数据 | 顺序与并行无关 | 默认即可 |
| 只要"总额"(不区分空组) | 0 与"无"等价 | 默认 summing |
判断口径一句话:"这份统计里,"缺失"与"为零"要不要分开"。最后一条与"验收"有关的边界:"统计"要"和明细对一次":"分组之和 == 总数" 是一条"两行代码"的断言——而它正好能抓住"null 键被丢""toMap 覆盖"这两类问题;**"没写这条断言"的报表,错误会"一直存在直到有人对账".
要(报表、对账、告警),就必须"补默认值 + 同时收计数";不要(内部看板、粗略趋势),默认收集器最省事——而"默认"的问题从来不是"错",而是"它替你做了决定而你没察觉".
8. 自测
groupingBy对null键的默认行为是什么?为什么它"最贵"?summingX在空组上返回什么?为什么它让"0"与"无数据"混在一起?- 怎么用"两个收集器"区分"无数据"与"真 0 元"?
toMap键冲突默认会怎样?给合并函数的风险是什么?- 并行流为什么会让"分组顺序不稳"?两种解法是什么?
- 判断"默认行为是否可接受"的那个问题是什么?