当equals遇上HashSet:一个对账系统的惨案
我们的对账模块需要比对两个数据源的交易记录,逻辑很简单:用HashSet存储基准数据,然后遍历待核对数据调用contains()比对。在测试环境一切正常,但生产环境跑完总有漏网之鱼。
通过日志最终锁定问题:某些Transaction对象虽然业务字段值相同,但hashCode()返回值却不一样——因为有人偷懒只重写了equals却忘了hashCode。这里有个硬规则:如果两个对象equals返回true,它们的hashCode必须相同。否则,当对象作为键存入HashMap或HashSet时,会存在哈希桶定位错误的风险。
<span>// 错误示范:只重写equals</span>
<span>@Override</span>
<span>public</span> <span>boolean</span> <span>equals</span><span>(Object o)</span> {
<span>if</span> (<span>this</span> == o) <span>return</span> <span>true</span>;
<span>if</span> (o == <span>null</span> || getClass() != o.getClass()) <span>return</span> <span>false</span>;
<span>Transaction</span> <span>that</span> <span>=</span> (Transaction) o;
<span>return</span> amount.equals(that.amount) &&
accountId.equals(that.accountId);
}
<span>// 正确姿势:必须配套重写hashCode</span>
<span>@Override</span>
<span>public</span> <span>int</span> <span>hashCode</span><span>()</span> {
<span>return</span> Objects.hash(amount, accountId); <span>// 使用相同字段</span>
}
为什么Lombok的@EqualsAndHashCode也翻过车
你可能会说:"我用Lombok自动生成不就完了?"别急,去年另一个坑正出在这里。某次上线后,监控突然显示某个核心服务CPU飙到90%,堆栈显示卡在HashMap.get()——因为自动生成的hashCode()包含了全部字段,而其中一个字段是BLOB类型的大文本!
- 关键点:hashCode()必须满足一致性(对象不变时返回值不变),但不要求所有字段参与运算*。对于重型字段,应该手动排除:
<span>@EqualsAndHashCode(onlyExplicitlyIncluded = true)</span>
<span>public</span> <span>class</span> <span>Order</span> {
<span>@EqualsAndHashCode</span>.Include
<span>private</span> Long id; <span>// 只使用轻量级字段</span>
<span>private</span> <span>byte</span>[] pdfContent; <span>// 排除大字段</span>
}
实测对比:包含10KB文本字段的类,调用hashCode()耗时从1200ns降至60ns(JMH基准测试,预热后结果)。
谁杀死了你的equals性能?
假设你给用户权限系统写了这样的equals:
<span>// 性能杀手:无谓的字符串比对</span>
<span>@Override</span>
<span>public</span> <span>boolean</span> <span>equals</span><span>(Object o)</span> {
<span>//...省略判空</span>
<span>User</span> <span>user</span> <span>=</span> (User) o;
<span>return</span> username.equals(user.username) &&
permissions.stream()
.sorted()
.collect(joining(<span>","</span>))
.equals(user.permissions.stream()
.sorted()
.collect(joining(<span>","</span>)));
}
问题出在每次比较都要对流进行排序和拼接。如果权限列表有20项,单次equals耗时直接突破1ms(实测数据)。对于高频调用的场景,这种写法就是自杀。优化原则:优先比较大概率不等的字段,避免深层嵌套结构的完全遍历:
<span>return</span> username.equals(user.username) &&
permissions.size() == user.permissions.size() &&
permissions.containsAll(user.permissions); <span>// 前提是Set实现</span>
避坑清单:equals重写的军规
- hashCode必须和equals同步重写:违反这条直接导致HashMap/HashSet行为异常
- 避免可变字段参与计算:如果参与equals的字段会被修改,对象作为Map键时将"失踪"
- 数组字段用Arrays.equals比较:直接调用array.equals()比较的是引用
- 浮点数字段用Double.compare:处理NaN和±0.0等特殊情况
- 子类equals必须满足里氏替换原则:子类新增字段会导致对称性被破坏
终极答案:什么时候才该重写equals?
经过这些年踩坑,我的判断标准只有两条:
- 需要对象逻辑相等而非引用相等时(比如值对象)
- 确定该对象会作为集合元素或Map键使用时
否则,直接使用Object的默认实现反而是最安全的选择。你在项目里遇到过哪些equals的奇葩坑?欢迎分享你的血泪史。
厘清equals/hashCode重写要点,结合性能与集合行为,适合Java后端排查HashSet/HashMap异常及优化值对象。