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

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

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

这段代码逻辑是没问题的,但在性能上其实还有优化的空间。原因在于,它对着同一个key做了三次独立的查找:containsKeyputget各自来了一遍。这意味着每次都要重新计算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。

在处理多层嵌套分组时,这个方法的优势会更明显。比如业务上要先按部门分,部门内部再按职级分,用老写法每一层都需要containsKeyput,套起来很啰嗦。换成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 查询时想要个兜底值

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

相关推荐
Shinner欣儿1 小时前
个人项目中 Monorepo, pnpm workspace, Turborepo, prisma 和 Docker 如何协作?
程序员
newerp1 小时前
Golang 接口的两副面孔:eface、iface 与动态派发之谜
后端·程序员·go
万物智能1 小时前
开源鸿蒙内核配置与驱动三条路径—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
前端·后端
Zane19941 小时前
自己写一个 java.lang.String,为什么永远替换不掉 JDK 那个
java·后端
morn_venus1 小时前
上位机业务卡死问题windbg分析
程序员
明月_清风1 小时前
递归算法:从原理到实战,一次讲透
后端·算法·go
吴声子夜歌1 小时前
Guava——并发(一)
java·guava
掘金者阿豪1 小时前
ChatGPT Plus、Pro 5x、Pro 20x 到底有多少额度?聊聊 Codex 那个让人看不懂的“周限额”
后端
天空属于哈夫克31 小时前
企业微信API:企业微信如何实现自动化运营?
java·自动化·企业微信