“ hashCode和equals”这两个方法,几乎每个 Java 开发者都见过,也都能说上两句。但真到了写代码的时候,翻车率却高得惊人。我见过线上系统因为只重写了 equals 没重写 hashCode,导致 HashSet 去重完全失效,一批重复数据直接写进了数据库;也见过有人把可变字段放进了 hashCode,结果对象放进 HashMap 之后再也没能 get 出来。这两个方法看着简单,背后的约定和坑却不少,值得从头到尾认真捋一遍。
这篇文章不打算讲教科书式的定义,我会从实际使用场景出发,把两个方法的关系、重写规则、常见误区、排查思路一次说清楚,也给出一套可以直接照抄的落地实践。无论是刚接触 Java 的开发者,还是写过一段时间但总在集合上踩坑的人,都能从这里拿到自己想要的东西。
1. 先搞清楚这两个方法到底在干什么
在讨论“怎么重写”之前,必须先把两个方法自身的语义搞明白。很多问题之所以发生,根子在于对这两个方法“默认行为”的理解就是错的。
1.1 equals 不是“比较对象”,而是“判断两条数据是否等价”
Object 类里的 equals 默认实现,是利用了 == 判断内存地址,等价于“两个引用是否指向同一个对象”。但业务代码里通常不需要这种物理级比较,我们需要的是逻辑等价:两个对象虽然在不同内存区域,但如果它们的业务字段一致,就应该被认为是同一条数据。
举个例子,在某模拟订单系统里,订单号相同就代表同一个订单,无论订单对象被 new 出来多少次。这种情况下若不重写 equals,两个字段完全相同的订单对象比较结果永远是 false,那业务逻辑就没法做了。所以 equals 本质上是“业务相等性”的定义,重写它等于是在告诉程序:什么情况下两个对象应该被视为同一条记录。
1.2 hashCode 是哈希结构的“门牌号”
hashCode 返回的是一个 int 类型的散列值。它的作用不体现在业务逻辑里,而是体现在基于哈希的集合结构中,比如 HashMap、HashSet、HashTable。
这些集合的底层组织方式是“桶数组 + 链表/红黑树”的结构。当你要往 HashMap 里放一个键值对,程序不可能拿新 key 和已有所有 key 挨个 equals 比较,那样性能会退化成 O(n)。正确思路是先算一下 key 的 hashCode,然后用它定位到某个桶,只在桶内做小范围查找。这个定位过程,相当于你在找一本书时,先看索书号所在的楼层和书架号,而不是从第一本书开始逐本比对。
所以 hashCode 的价值在于“尽量快速缩小查找范围”,equals 的价值在于“在小范围内做精确判断”。两者是合作关系,不是替代关系。
1.3 默认行为下的连锁反应
如果我们什么都不重写,一个类的 equals 比较的是地址,hashCode 默认也是基于内存地址生成的。这时候把对象放进 HashSet,由于每个 new 出来的对象内存地址不同,hashCode 大多不同,equals 也总是 false,所以 HashSet 会认为每个对象都是新元素,全部收下。这在某些场景下有用,比如你确实想按“对象唯一性”去重,但绝大多数业务场景里,这不是我们想要的行为。
我见过不少系统里抛出“重复数据异常”的 bug,根源就是实体类只重写了 equals,却忘了 hashCode。结果 HashMap 定位时发现两个绕绕等价的 key 哈希值不同,跑到了不同的桶里,永远撞不上。
2. hashCode 和 equals 之间有一条“硬性契约”
Java 官方文档里对 hashCode 和 equals 的约定写得很清楚,虽然不是强制编译器检查,但它决定了集合类能否正常工作,所以被我视为硬性的逻辑约束。
2.1 契约的三条核心规则
规则一:如果两个对象通过 equals 比较是相等的,那么对它们各自调用 hashCode 必须得到相同的整数结果。也就是说 equals 相等是 hashCode 相等的充分条件。
规则二:如果两个对象的 hashCode 相等,equals 不一定相等。因为哈希碰撞本来就被允许存在,不同对象散列到同一桶里是设计内的事,落到桶里之后再靠 equals 区分。
规则三:在对象的 equals 比较没有修改任何信息的前提下,对同一个对象多次调用 hashCode 必须始终返回同一个整数。但要注意,如果一个对象被放进 HashSet 之后又修改了参与 hashCode 计算的字段,那么它的 hash 值就变了,这属于使用方式的问题,后面会详细说。
这三条规则里,最容易在日常代码里被违反的是第一条。“我明明重写了 equals,结果还是不对”,最常见的原因就是 hashCode 没有跟着改。
2.2 为什么“equals 相等但 hashCode 不等”会翻车
用 HashMap 来想这个问题就一目了然。假设放数据时 key 的 hashCode 是 100,数据被放进桶 100。查找同一数据时,你可能新建了一个相等的 key,它的 hashCode 却是 200,程序会去桶 200 里找。桶 200 是空的,于是你得到一个 null,仿佛数据凭空消失了。
这不是数据真的丢了,是定位规则变了,导致“同一条数据”被分到了两个不同的桶。HashMap 永远只在一个桶内做 equals 比较,不会跨桶去比对,所以 equals 再怎么相等也白搭。
2.3 用“图书索引”理解这条契约
我习惯用一个生活化类比帮别人理解:假设图书馆里每本书都对应一个索书号,索书号相同的书会被放在同一排书架上。现在有两本内容完全相同的书(equals 相等),如果它们的索书号不同,就会被放到两排不同的书架。你在查询系统里按第一本书的索书号去找,永远只能找到第一本,第二本不会出现在结果里。
反过来,如果两本书的索书号恰好相同,被放在同一排书架,并不意味着它们是同一本,还得翻开内容去比对(equals)。这个比喻几乎能解释所有哈希结构的核心行为。
3. 实战还原:HashMap 里的一趟查找过程
理论说完了,我们把一个键值对放进 HashMap,然后按源码级视角走一遍完整流程。理解了这一趟流程,你就知道重写时该把力气花在哪了。
3.1 put 和 get 的内部步骤
假设执行map.put(key, value),HashMap 会经历以下几步:
第一步计算。调用key.hashCode()得到一个 int,然后将这个 int 做一次扰动运算,通常是把高 16 位与低 16 位做异或,目的是让高位信息也能参与低位定位,减少碰撞概率。
第二步定位。将扰动后的 hash 值与桶数组长度减一按位与。数组长度是 2 的幂,比如 16 时就是 hash & 15,效果等同于取模,但位运算效率更高。这一步得到的下标就是要放的桶位。
第三步判断。如果桶位为空,直接放新节点。如果桶位非空,就遍历桶内的链表或红黑树,用 key 的 equals 逐一比对。如果找到了 equals 相等的 key,就用新值替换旧值;如果没找到,就在链表尾部插入新节点。
get 的流程几乎对称:同样先计算 hash、定位桶,再在桶内用 equals 找匹配项。所以你会发现,整条链路里 hashCode 决定了“去哪找”,equals 决定了“找到了没”。
3.2 为什么 Java 8 以后还要“树化”
如果 hash 函数设计得再合理,也无法避免极端情况下的碰撞。当某个桶里元素数量超过阈值(默认 8),且数组长度大于等于 64 时,链表会转成红黑树。这样一来,即使大量 key 堆在同一桶里,查找复杂度也能从 O(n) 降到 O(log n)。
这一点对使用者的启发是:hashCode 的重质量越差,越容易触发树化。树化本身是为了兜底,但性能代价仍然比均匀散列高得多。写 hashCode 的时候,保证散列足够分散,比什么都重要。
3.3 翻车现场:只重写 equals 的代价
我用一个最基础的自定义类做实验,演示只重写 equals 而不重写 hashCode 会引发什么问题。
public class User { private String name; public User(String name) { this.name = name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(name, user.name); } // 注意:没有重写 hashCode }然后执行这样的测试代码:
Set<User> set = new HashSet<>(); set.add(new User("张三")); set.add(new User("张三")); System.out.println(set.size()); // 输出 2两个 name 相同的 User 对象,equals 比较返回 true,但 HashSet 却把它们都留下了。原因就是它们各自的 hashCode 不一样,被放进了不同桶,压根没机会走到 equals 那一步。如果这是订单去重逻辑,重复订单就混进去了。
拿到线上问题,第一反应也应该是看这个类的 equals 和 hashCode 是否配套重写。
3.4 活着 vs 等价:为什么集合里用 equals 而不用 ==
有人会问:既然同一条业务数据在内存中可能有多个实例,为什么集合不用 == 比较,非要绕一圈先算 hashCode 再 equals?
因为 == 比较的是引用地址,两个 new 出来的对象地址必然不同,那“按内容去重”就永远生效不了。equals 的默认实现又恰好就是 ==,所以我们必须重写 equals 来自定义逻辑等价。hashCode 存在的意义则是避免在大量数据中一个一个 equals,提升检索效率。两者配合,才是哈希结构能够同时保证“正确”和“高效”的根本原因。
4. 重写 hashCode 和 equals 的正确姿势
知道原理之后,接下来就是落地。这套代码很多人写过,但细节里全是坑。
4.1 手写一个散列算法的思路
一个经典、通用、散列分布良好的实现套路是:选定几个核心业务字段,依次累积计算。惯用写法是:
@Override public int hashCode() { int result = 1; result = 31 * result + (name != null ? name.hashCode() : 0); result = 31 * result + (age != null ? age.hashCode() : 0); return result; }为什么用 31?因为 31 是一个不大不小的质数,作为乘数可以让散列值分布更均匀,同时 JVM 对乘以 31 有优化,等价于(result << 5) - result,左移五位再减一次,计算成本很低。这是实践中权衡出的选择。
要给这个套路加上推导过程的话,它本质上是把对象字段的散列值组合成一个整数散列值。字段顺序会影响最终散列值,所以需要稳定、一致的计算顺序。只要 equals 里用到的字段全部参与计算,并且计算结果与 equals 结果不冲突,这个写法就是靠谱的。
4.2 不要用“独一无二”的字段来膨胀散列
网上有人建议在 hashCode 末尾“乘以一个大质数”来增强随机性,比如再乘上 47 之类。这个做法没有实际意义,反而可能让哈希值溢出,得到的负数会影响定位逻辑(好在 HashMap 内部取 hash 时会用位运算抹掉符号位的影响,所以不至于出错,但也毫无收益)。
还有人喜欢把“所有字段”都放进 hashCode,哪怕这个字段是一个列表。这时要小心,如果列表是空的时候散列值是一种情况,列表变化后散列值又变了,一致性问题就会冒出来。
4.3 equals 重写时要注意的对称性和类型判断
重写 equals 有几条不成文的铁律:
第一是自反性。a.equals(a)必须返回 true,这个很容易满足。
第二是对称性。a.equals(b)与b.equals(a)结果必须一致。最容易犯的错是子类和父类之间做比较时用了getClass()或instanceof导致不对称。比如父类用 instanceof 判断,子类又用 getClass 判断,两边结果自相矛盾。
第三是传递性。a 等于 b,b 等于 c,a 就必须等于 c。用继承关系时要格外小心,通常做法是在一个类内部保持字段一致,不跨层混比。
第四是一致性。equals 中使用的字段如果发生改变,可能会破坏已经放入集合的对象的比较结果。规范要求是,在没有修改字段的情况下,多次 equals 结果一致。所以参与 equals 的字段最好是不可变字段。
实际写的时候,现代开发可以直接用Objects.equals处理 null 安全,不用手写一串空判断。
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; return Objects.equals(name, user.name) && Objects.equals(age, user.age); }这里用getClass() != o.getClass()而不是o instanceof User,是为了保证对称性的同时不产生不安全的向下转型。涉及子类场景时,instanceof 会带来隐患,所以绝大多数实体类场景直接 getClass 判断就好。
4.4 用工具而不是手写:IDE、Lombok 与现代 Java
手写容易出错,所以实际开发里我更推荐用工具生成或编译期处理。
IDE 自动生成是第一步选择。IDEA 里 Alt+Insert 选择 equals() and hashCode(),模板会自动选择 non-null 字段,生成的代码是经过验证的。它对初学者也很友好,生成的代码就是上面那种风格。
Lombok 是日常开发的效率神器。@EqualsAndHashCode注解可以直接自动生成两个方法,还可以用@Data一并带上 getter setter 和 toString。但它有个关键参数要记住:callSuper = true。如果父类也有字段参与相等性判断,不加这个参数,生成的 equals 会漏掉父类字段,子类之间比较时会把父类字段差异忽略。我见过不止一次因为 Lombok 没配置 callSuper 导致“两个对象明明字段不一样,却判断相等”的线上故障。
现代 JDK 还有一个更清爽的方案:record。record 天生会基于组件字段生成 equals、hashCode、toString,而且这些方法是不可变的,不能重写,天然满足一致性。如果你的类能被设计成不可变数据载体,用 record 几乎是零成本的正确。
我给出一张对比表,方便按场景选。
| 方式 | 生成逻辑 | 优点 | 注意点 |
|---|---|---|---|
| 手写 | 手动编码 | 可控性强 | 容易漏字段、误用可变字段 |
| IDE 生成 | 基于模板 | 规范、快 | 需要手动选择参与字段 |
| Lombok @Data | 自动生成 | 代码简洁 | 需要处理 callSuper |
| record | 编译期生成 | 不可变、天然正确 | 类需要设计为数据载体 |
4.5 结合 toString 一起看
很多时候 toString 也会顺带踩坑:比如把集合对象直接塞进 toString 里,日志打出来爆长;或者在 toString 里调用可变 List 的 toString,导致输出不确定。个人习惯是把 toString 和 equals/hashCode 分开设计。toString 只服务于日志和调试,不需要和参与判断的字段保持一致。
5. 常见问题排查技巧与面试高频点
这部分是我在工作和辅导新人时反复遇到的情况,整理成速查形式,遇到问题直接对号入座。
5.1 HashSet 去重失效,怎么排查
第一步,确认目标类有没有重写 equals。如果没重写,默认就是地址比较,那“相同业务数据”永远不会相等。
第二步,确认 equals 和 hashCode 是否配套。只重写 equals 不重写 hashCode,去重依然失效,原理在前面已经讲了。
第三步,确认 hashCode 里用了哪些字段,是否覆盖了 equals 用到的全部字段。如果有一方漏字段,equals 判断相等的两个对象,hashCode 可能不同。
第四步,确认参与 hashCode 的字段是不是可变的。如果对象放入集合后,某个字段被 setter 改了,那 hash 值就变了,后续查找与去重都会异常。
User user = new User("张三", 20); Set<User> set = new HashSet<>(); set.add(user); user.setAge(21); // 修改后 hash 值变了 System.out.println(set.contains(user)); // 大概率输出 false这种情况不是集合的 bug,而是使用方违反了“放入哈希集合后不要修改参与 hash 的字段”的约定。正确的做法是让对象不可变,或者保证放入集合后不再改动。
5.2 对象放进 HashMap 后 get 不到,怎么恢复
如果对象已经放进 HashMap,并且字段被改了,get 不出来是必然的。理想方案是提前设计为不可变类,从根上杜绝。如果实在要改,那只能把对象从 map 中取出来,修改完成后重新 put 一遍。但有的时候你并没有持有引用,那就查不到,只能靠数据兜底方案,比如用其他唯一索引重查后覆盖。
这个场景最常见的业务对应是缓存系统。如果用可变对象做缓存 key,修改 key 字段几乎必然导致缓存命中失败。所以缓存 key 我强烈建议用不可变类或 record。
5.3 几个高频面试问法的答题思路
面试官常问“为什么重写 equals 必须重写 hashCode”,回答时可先说明哈希结构定位过程:先按 hashCode 找桶,再用 equals 精确比对。然后说明若 equals 相等而 hashCode 不等,会定位到不同桶,导致查询失效,并顺手给出 3.3 节的实验现象佐证。
又问“hashCode 相同,equals 一定相同吗”,答案是否定的,因为存在哈希碰撞。碰撞后同一桶里有多个节点,要靠 equals 区分。这也是为什么 HashMap 的桶内结构是链表或红黑树。
再问“两个对象 hashCode 相同,内存地址也相同吗”,完全不相关,hashCode 是逻辑散列值,内存地址是物理位置。默认 Object 的 hashCode 与内存地址相关,但重写后就是按字段计算的结果了。
这种问法背后考察的是对数据结构机制的理解,而不是背概念。回答时结合 hashCode 定位、equals 比对以及冲突处理,基本可以拿高分。
5.4 一个容易被忽略的坑:数组字段参与 hashCode
如果类里有一个int[]字段,直接对其调用 hashCode,结果会基于数组对象的身份,而非数组元素内容。也就是说两个内容相同的数组字段,hashCode 可能不同,进一步导致 equals 判断相等但 hashCode 不等的违约问题。
正确做法是使用Arrays.hashCode(field)对数组内容计算散列值。如果数组本身作为整体参与判断,equals 也得用Arrays.equals(field1, field2)进行内容比较。这一点在包含二进制流、坐标点数组、位图数据的实体里特别常见,务必留意。
6. 从“能用”到“好用”的散列策略取舍
很多项目对 hashCode 的要求只是“不违反契约”,这当然没问题,但如果你要构建大数据量、高并发场景下的哈希索引,散列质量就变得重要了。
6.1 散列过密与过疏的代价
散列过疏,比如所有对象的 hashCode 都返回同一个常量,虽然不违反契约,但所有元素都挤在一个桶里。如果集合又恰好用了链表,查找速度退化成 O(n),和线性遍历没有区别。极端场景下,HashMap 的性能反而比 ArrayList 还差,因为链表节点还有额外内存开销。
散列过疏也有代价,比如 hashCode 值域太广但实际分布不均,大量桶空置,浪费内存。最理想的状态是,在字段值均匀分布的前提下,hashCode 也均匀分布,把碰撞概率压到最低。
6.2 如何快速评估散列质量
一个直接的实验是:构建一万个样本对象,放入一个长度为 1024 的模拟桶数组,记录每个桶的元素个数,然后看最大桶深度和平均桶深度。如果最大桶深度远高于平均值,说明散列有集中倾向,需要调整字段组合或乘子。
我在实际项目里做过一个简单的统计脚本,用随机生成的用户ID来计算 hashCode 分布。结果发现只用低几位的字段时,数据分布很差,后来加了一个大质数乘子并把多个字段组合参与,分布才变得平坦。多数业务场景不需要极致调优,但“观察桶深”这个习惯值得保留。
6.3 equals 与 hashCode 的一致性对并发的影响
在多线程环境中,多个线程同时读取一个可变对象的 hashCode,如果某个线程修改了字段,另一个线程正在用它做查找,就会出现不可预知的结果。虽然集合框架内部有 fail-fast 机制可以暴露修改问题,但最佳实践始终是:把作为 key 的对象设计为不可变。
record、不可变集合、final 字段、不对外暴露 setter,这些都是常见的不可变设计手段。拿缓存 key 来说,我一般直接定义成 record,省事且彻底安全。
6.4 特殊业务场景:复合主键的哈希
多字段复合主键在业务里很常见,比如订单号加商品序号。定义一个 CompositeKey 类时,不要图省事把两个字段拼成字符串再算哈希,那样凭空多一次字符串创建开销。正确做法是把两个字段直接参与组合计算。
public record OrderLineKey(String orderId, int lineNo) {}更复杂一点,如果你希望忽略某些字段比较,那 equals 和 hashCode 要一致地只选比较用的字段。最忌讳的是 equals 用五个字段,hashCode 却用八个字段,这样集合内部判断会乱。每当你调整 equals 的字段集合,hashCode 必须同步调整。
7. 聊聊我踩过的那些坑和沉淀下来的习惯
关于 hashCode 和 equals,其实网上教程足够多,但很多人还是记不住,本质上是因为没有把它当成“一条业务规则”来对待。我自己也经历过几个印象深刻的翻车现场,写出来给大家避避坑。
第一个翻车现场是某次线上订单去重统计。当时使用的实体类只重写了 equals,没有重写 hashCode。结果同一个用户同一笔订单,在 HashSet 里出现了两次,统计金额直接翻倍。排查时第一眼看到的就是 HashSet 的 size 明显偏大,然后补上 hashCode 后一切恢复正常。从那以后,我对“只重写 equals”的代码都有一种天然警惕。
第二个翻车现场是缓存 key 使用可变对象。我们当时把包含查询条件的对象作为缓存 key 放入了 LoadingCache,为了复用查询结果,在后续逻辑里直接修改了 key 对象上的某个时间戳字段。结果缓存命中率一夜之间跌到百分之十几。排查时发现 key 的 hashCode 在 put 和 get 之间发生了变化。这个教训让我深刻理解了“不可变 key”的价值。
第三个经验是代码评审习惯。现在我在评审时,看到任何自定义类,都会下意识地问三个问题:它是否会被放进 HashSet 或 HashMap?equals 和 hashCode 是否配套重写了?参与 hashCode 的字段是否是稳定的?如果这三个问题回答都没问题,基本可以放心。
如果你刚开始接触这块,我的直接建议是:能不手写就不手写。能设计成 record 就设计成 record,否则用 Lombok 的 @EqualsAndHashCode,再不行就靠 IDE 生成。手写属于比赛答题或特殊定制场景才需要的能力,日常业务开发选工具是更稳妥的选择。
另外一个小习惯也值得推广:每次写完一个“数据载体类”,顺手写一个极简测试,往 HashSet 里塞两个字段完全相同的对象,断言 size 为 1。这个测试成本极低,但能把住很多回归问题。把它放进单元测试里,以后类字段调整导致 equals/hashCode 不一致时,测试会第一时间暴露。
hashCode 和 equals 这两个方法,看着基础,实际遍布在集合、缓存、去重、幂等等无数核心链路里。把它们的契约和坑放在心上,能帮你少踩很多雷,也让你的代码在正确性和性能上都更稳。