2.1 equals 与 hashCode 契约事故 本节摘要:只重写 忘了 ,结果是去重缓存命中率为零、Set 里出现"重复"元素、Map 越查越慢。本节从一次缓存失效事故讲清两者契约、哈希桶的查找机制、可变对象作键的二次陷阱,以及 Objects 工具类的标准写法。 一个命中率持续为零的缓存 风控系统上线了一个设备指纹去重缓存,逻辑是 , 重写了 (按 deviceId + channel 比较)。上线后监控显示两个异常:缓存命中率稳定为 0,且该 Map 的内存持续增长。 问题藏在 的查找流程里:先按 定位桶,再在桶内用 精确比较。没重写 时用的是 的默认实现——每 New 一个对象一个哈希值。于是:每次 都定位到一个空桶, 根本没机会执行,查不到;
本节摘要:只重写
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 越来越大。业务表现是"去重失效",即同一个设备每次都被当作新设备——风控规则直接漏放。
equals 与 hashCode 的约定有五条,核心是两条:
a.equals(b) 为 true,则 a.hashCode() == b.hashCode() 必须成立。反过来不要求(哈希碰撞合法)。其余三条属于 equals 自身:自反、对称、传递。对称性被破坏的典型是把父类实例和子类实例互相比较(instanceof 与 getClass 之争):用 instanceof 写 equals,子类加了字段后 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+):编译器基于全部字段自动生成 equals、hashCode、toString,天然自洽,还不可变:
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 或 recordequals 用 getClass 严格比较,防止子类破坏对称性equals 参数必须是 Object,加 @Override 让编译器把关equals 重写点,逐个确认 hashCode 成对出现最后说说这个事故为什么测试也没拦住。去重缓存的单测用的是固定的几台设备,equals 覆盖的场景全都命中——缓存的 miss 是统计行为,小样本看不见。上线后的观测指标也只看了"缓存是否报错",没人看命中率。复盘后加的动作很便宜:给这类带缓存的基础组件强制要求命中率指标,低于阈值告警。另外一个值得沉淀的经验是排查路径:当"逻辑正确但行为不对"时,优先怀疑对象的等价语义而不是业务流程——业务流程会抛错,等价语义只会安静地让数据结构说谎。把这条排错直觉带在身上,同类问题的定位时间能从两天缩到十分钟。
下一节看继承——同样安静的耦合,爆炸半径更大。
同类问题后来在另一个团队又出现了一次:排查同事用了整整一天,因为他一直在检查缓存框架的配置。事后两边的经验合并进了同一份评审清单:凡是重写 equals 的类,评审人必须能看到配套的 hashCode 与不可变声明,三者齐活才放行。