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

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

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 查询时想要个兜底值

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

相关推荐
深蓝AI2 小时前
无头浏览器不必从 Chrome 改:Lightpanda 用 Zig 从零造,100 个页面只占 123MB 内存
人工智能·程序员
实名上网宋凯宣7 小时前
Django
后端·python·django
深蓝AI7 小时前
桌面 Agent 缺的不是视觉:Vercel 开源 agent-desktop,用无障碍树 ref 替代截图坐标
人工智能·程序员
天河归来8 小时前
第三方接口不稳定,如何让核心任务继续完成
java·大数据
Knight_AL8 小时前
Java 文件下载时文件名中的 `#` 被转换成 `%23`?一次 URL 编码与解码问题排查
java·开发语言
利威尔xu9 小时前
Go IM 源码实战(14):本地缓存快 3000 倍,失效却难 10 倍
后端·缓存·微服务·面试·golang
逃逸线LOF9 小时前
Ribbon负载均衡原理(Irule组件、源码分析)
后端·spring cloud·ribbon
砚底藏山河10 小时前
python量化自定义周期 K 线合成
java·开发语言·数据库·python·金融
慢云智慧空间10 小时前
从设备智能到空间理解,空间智能终端解决方案商如何重新定义行业标准
后端
利威尔xu10 小时前
Go IM 源码实战(13):30 个会话只有 2 个红点,未读数错在三条路径
redis·后端·微服务·面试·golang