HashMap 分组操作的演进:从三次查找到一次调用

88 阅读3分钟

日常写代码时,经常会遇到按某个字段给数据分组的场景。一种很常见的写法是这样的:

if (!grouped.containsKey(key)) {
    grouped.put(key, new ArrayList<>());
}
grouped.get(key).add(value);

这段代码逻辑是没问题的,但在性能上其实还有优化的空间。原因在于,它对着同一个key做了三次独立的查找:containsKeyputget各自来了一遍。这意味着每次都要重新计算hash、定位到桶,再在桶里逐个比对。数据量小的时候这点开销可以忽略,但在调用频繁、数据量大的场景下,重复的hash计算和遍历会白白浪费CPU资源。

在Java 8之后,我们可以使用computeIfAbsent把这三次查找收拢成一次调用:

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。

在处理多层嵌套分组时,这个方法的优势会更明显。比如业务上要先按部门分,部门内部再按职级分,用老写法每一层都需要containsKeyput,套起来很啰嗦。换成computeIfAbsent就可以直接连着写:

//先按部门分,再按职级分
grouped.computeIfAbsent(dept,k->newHashMap<String,List<Employee>>())
.computeIfAbsent(level,k->newArrayList<>())
.add(emp);

因为外层返回的一定是个能用的map(要么本来就有,要么刚建好),所以可以直接点下去继续调内层,三层四层也是一样的写法,逻辑非常清晰。

当然,技术方案要结合场景。如果我们手头已经有一个现成的List,并且分组规则明确,那直接用Stream里的Collectors.groupingBy是最省事的,一行代码就能解决问题,多级分组也能通过嵌套groupingBy搞定:

//按部门一次性分组
Map<String,List<Employee>>byDept=list.stream()
.collect(Collectors.groupingBy(Employee::getDept));

而 computeIfAbsent 的核心价值,在于处理数据是一条条来的、没法一次性拿全的场景,比如从数据库游标逐条读取、消费消息队列、解析大文件等。这类场景下没有完整的集合供 Stream 操作,用 computeIfAbsent 做边处理边分组才是更合适的选择。

最后,整理了一下Map里这组容易混淆的方法,方便大家在不同业务场景下做选择:

方法什么时候用典型场景
computeIfAbsentkey 不存在才计算并放入,返回最终值惰性建默认值、边处理边分组
putIfAbsent放入一个已经算好的值值创建没代价时可用,有代价优先 computeIfAbsent
compute不管 key 在不在都重新算要基于旧值更新
merge缺失用给定值,存在用函数合并计数累加 merge(key, 1, Integer::sum)
getOrDefault只读取缺省值,不改 map查询时想要个兜底值

希望这些细节对大家日常写代码有所帮助。