2.1 equals与hashCode契约事故


文档摘要

2.1 equals 与 hashCode 契约事故 本节摘要:只重写 忘了 ,结果是去重缓存命中率为零、Set 里出现"重复"元素、Map 越查越慢。本节从一次缓存失效事故讲清两者契约、哈希桶的查找机制、可变对象作键的二次陷阱,以及 Objects 工具类的标准写法。 一个命中率持续为零的缓存 风控系统上线了一个设备指纹去重缓存,逻辑是 , 重写了 (按 deviceId + channel 比较)。上线后监控显示两个异常:缓存命中率稳定为 0,且该 Map 的内存持续增长。 问题藏在 的查找流程里:先按 定位桶,再在桶内用 精确比较。没重写 时用的是 的默认实现——每 New 一个对象一个哈希值。于是:每次 都定位到一个空桶, 根本没机会执行,查不到;

2.1 equals 与 hashCode 契约事故

本节摘要:只重写 equals 忘了 hashCode,结果是去重缓存命中率为零、Set 里出现"重复"元素、Map 越查越慢。本节从一次缓存失效事故讲清两者契约、哈希桶的查找机制、可变对象作键的二次陷阱,以及 Objects 工具类的标准写法。

一个命中率持续为零的缓存

风控系统上线了一个设备指纹去重缓存,逻辑是 HashMap<DeviceKey, Boolean>DeviceKey 重写了 equals(按 deviceId + channel 比较)。上线后监控显示两个异常:缓存命中率稳定为 0,且该 Map 的内存持续增长。

class DeviceKey { final String deviceId; final String channel; DeviceKey(String deviceId, String channel) { /* ... */ } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof DeviceKey)) return false; DeviceKey that = (DeviceKey) o; return deviceId.equals(that.deviceId) && channel.equals(that.channel); } // hashCode 没重写 }

问题藏在 HashMap 的查找流程里:先按 hashCode 定位桶,再在桶内用 equals 精确比较。没重写 hashCode 时用的是 Object 的默认实现——每 New 一个对象一个哈希值。于是:每次 get 都定位到一个空桶,equals 根本没机会执行,查不到;每次 put 都是"新键",Map 越来越大。业务表现是"去重失效",即同一个设备每次都被当作新设备——风控规则直接漏放。

契约本体

equalshashCode 的约定有五条,核心是两条:

  1. 自洽性a.equals(b) 为 true,则 a.hashCode() == b.hashCode() 必须成立。反过来不要求(哈希碰撞合法)。
  2. 一致性:equals 用到的字段不变时,多次调用 hashCode 返回值必须一致。

其余三条属于 equals 自身:自反、对称、传递。对称性被破坏的典型是把父类实例和子类实例互相比较(instanceofgetClass 之争):用 instanceofequals,子类加了字段后 parent.equals(child) 为 true 而 child.equals(parent) 为 false。值对象建议用 getClass 严格比较,或者干脆声明为 final、record 化。

哈希查找的两步流程与契约位置

哈希查找的两步流程与契约位置

顺带一提"哈希写得很差"的变种:hashCode 返回常量不违约,但所有对象挤一个桶,HashMap 退化为单链表,查询从 O(1) 变 O(n)。某次压测里 QPS 上不去,最后发现是新人把 hashCode 写成 return deviceId.length()——设备号定长,全员同桶。

标准写法与工具

JDK 7 之后不需要手写 hashCode 的位运算:

@Override public int hashCode() { return Objects.hash(deviceId, channel); }

更好的办法是直接用 record(Java 16+):编译器基于全部字段自动生成 equalshashCodetoString,天然自洽,还不可变:

record DeviceKey(String deviceId, String channel) { }

IDE 生成(IntelliJ 的 Generate equals and hashCode)也可靠,但要注意生成后人工核对字段清单——漏掉一个参与 equals 的字段,比不重写还难查。

可变对象作键:二次陷阱

equals 依赖的字段如果可变,对象放进 Map 后再改字段,哈希值变了,定位到旧字段的桶,get 从此查不到它——对象在 Map 里"失踪",但内存不释放(引用还在,只是找不到了)。纪律只有一条:作为哈希键的类必须不可变,字段全 final,类型也尽量不可变。

⚠️ 常见坑:equals 参数类型写成 DeviceKey 而不是 Object——这不叫重写,叫重载,集合框架调用的仍是 Object.equals@Override 注解会让编译器当场揭穿它。

💡 关键直觉:hashCode 是"粗筛",equals 是"精查"。两个方法描述的必须是同一套业务身份,任何一边多算或少算一个字段,契约就裂开。

防坑清单

  • 重写 equals 必同时重写 hashCode,用 Objects.hash 或 record
  • 哈希键必须不可变;可变对象需要键时抽不可变的自然键
  • 值对象 equalsgetClass 严格比较,防止子类破坏对称性
  • equals 参数必须是 Object,加 @Override 让编译器把关
  • 评审时搜 equals 重写点,逐个确认 hashCode 成对出现

本节要点回顾

  • 契约核心:equals 相等则 hashCode 必相等,反之不然
  • 事故机理:不重写 hashCode → 相等对象落不同桶 → get miss、put 重复、Map 膨胀
  • 退化变种:hashCode 全同 → 单桶链表 → O(n) 查询
  • 现代解法:record 自动生成,不可变天然满足一致性
  • 二次陷阱:可变键改字段后对象在 Map 里失踪

最后说说这个事故为什么测试也没拦住。去重缓存的单测用的是固定的几台设备,equals 覆盖的场景全都命中——缓存的 miss 是统计行为,小样本看不见。上线后的观测指标也只看了"缓存是否报错",没人看命中率。复盘后加的动作很便宜:给这类带缓存的基础组件强制要求命中率指标,低于阈值告警。另外一个值得沉淀的经验是排查路径:当"逻辑正确但行为不对"时,优先怀疑对象的等价语义而不是业务流程——业务流程会抛错,等价语义只会安静地让数据结构说谎。把这条排错直觉带在身上,同类问题的定位时间能从两天缩到十分钟。

下一节看继承——同样安静的耦合,爆炸半径更大。

同类问题后来在另一个团队又出现了一次:排查同事用了整整一天,因为他一直在检查缓存框架的配置。事后两边的经验合并进了同一份评审清单:凡是重写 equals 的类,评审人必须能看到配套的 hashCode 与不可变声明,三者齐活才放行。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U