日常写代码时,经常会遇到按某个字段给数据分组的场景。一种很常见的写法是这样的:
Java
if (!grouped.containsKey(key)) {
grouped.put(key, new ArrayList<>());
}
grouped.get(key).add(value);
这段代码逻辑是没问题的,但在性能上其实还有优化的空间。原因在于,它对着同一个key做了三次独立的查找:containsKey、put和get各自来了一遍。这意味着每次都要重新计算hash、定位到桶,再在桶里逐个比对。数据量小的时候这点开销可以忽略,但在调用频繁、数据量大的场景下,重复的hash计算和遍历会白白浪费CPU资源。
在Java 8之后,我们可以使用computeIfAbsent把这三次查找收拢成一次调用:
Java
grouped.computeIfAbsent(key,k->newArrayList<>()).add(value);
用这种写法,底层只需算一次hash,定位一次桶。如果key在,就直接返回对应的值;如果不在,才会执行lambda表达式创建一个新的ArrayList放进去并返回。
这里的一个关键点在于,lambda表达式是惰性执行的。这比直接用put(key,newArrayList<>())性能要好,因为后者不管key存不存在,每次执行都会先把对象new出来,产生不必要的开销。
在看这个方法的底层实现时,有两个细节值得和大家分享一下。
首先是并发修改的问题。在执行lambda之前,源码里会记下当前的modCount,执行完会再比对一次。如果在这期间你在lambda里面不小心动了这个map,程序会抛出ConcurrentModificationException。这是因为computeIfAbsent本身正在修改map的结构,如果lambda里又触发了结构性修改,两次修改叠加会导致内部状态不一致,后续操作可能产生不可预期的结果。
其次是对于null的处理。如果lambda计算完返回的是null,computeIfAbsent不会往map里放任何东西,连节点都不会建,直接把null返回给你。所以,别指望用这个方法给某个key占一个null的位,回头get到的依然是null。
在处理多层嵌套分组时,这个方法的优势会更明显。比如业务上要先按部门分,部门内部再按职级分,用老写法每一层都需要containsKey加put,套起来很啰嗦。换成computeIfAbsent就可以直接连着写:
Java
//先按部门分,再按职级分
grouped.computeIfAbsent(dept,k->newHashMap<String,List<Employee>>())
.computeIfAbsent(level,k->newArrayList<>())
.add(emp);
因为外层返回的一定是个能用的map(要么本来就有,要么刚建好),所以可以直接点下去继续调内层,三层四层也是一样的写法,逻辑非常清晰。
当然,技术方案要结合场景。如果我们手头已经有一个现成的List,并且分组规则明确,那直接用Stream里的Collectors.groupingBy是最省事的,一行代码就能解决问题,多级分组也能通过嵌套groupingBy搞定:
Java
//按部门一次性分组
Map<String,List<Employee>>byDept=list.stream()
.collect(Collectors.groupingBy(Employee::getDept));
而 computeIfAbsent 的核心价值,在于处理数据是一条条来的、没法一次性拿全 的场景,比如从数据库游标逐条读取、消费消息队列、解析大文件等。这类场景下没有完整的集合供 Stream 操作,用 computeIfAbsent 做边处理边分组才是更合适的选择。
最后,整理了一下Map里这组容易混淆的方法,方便大家在不同业务场景下做选择:
| 方法 | 什么时候用 | 典型场景 |
|---|---|---|
| computeIfAbsent | key 不存在才计算并放入,返回最终值 | 惰性建默认值、边处理边分组 |
| putIfAbsent | 放入一个已经算好的值 | 值创建没代价时可用,有代价优先 computeIfAbsent |
| compute | 不管 key 在不在都重新算 | 要基于旧值更新 |
| merge | 缺失用给定值,存在用函数合并 | 计数累加 merge(key, 1, Integer::sum) |
| getOrDefault | 只读取缺省值,不改 map | 查询时想要个兜底值 |
希望这些细节对大家日常写代码有所帮助。