Java 重写 equals 忘了 hashCode:HashMap 里找不到 key 的排查一个诡异的 bug:明明往HashMap里 put 了一个 key,拿一模一样的对象去 get 却返回 null。或者往HashSet里加了两个「相等」的对象,size 却是 2。八成是同一个原因——你重写了equals却没重写hashCode。这篇讲清这条约定为什么必须成对遵守,以及怎么正确实现。复现:能相等,却在 HashMap 里查不到先写一个只重写equals的类,这是新手最自然的写法:publicclassUser{privatefinalLongid;privatefinalStringname;publicUser(Longid,Stringname){this.idid;this.namename;}Overridepublicbooleanequals(Objecto){if(thiso)returntrue;if(!(oinstanceofUser))returnfalse;Useruser(User)o;returnObjects.equals(id,user.id);// 按 id 判等}// 注意:故意没重写 hashCode}用它当 HashMap 的 key:MapUser,StringmapnewHashMap();map.put(newUser(1L,Alice),VIP);UserquerynewUser(1L,Alice);System.out.println(query.equals(map.keySet().iterator().next()));// trueSystem.out.println(map.get(query));// 竟然是 null!两个 User 用equals判断明明相等,map.get却查不到。这不是 JVM 的 bug,是我们违反了约定。根因:HashMap 先用 hashCode 定位桶理解这个 bug 必须知道 HashMap 的查找过程,它分两步:算hashCode,对桶数组长度取模,定位到某个桶(bucket)。在那个桶里,用equals逐个比对,找到相等的 key。关键在第一步。Object默认的hashCode是基于对象内存地址的,两个内容相同但new出来的不同对象,默认 hashCode 几乎必然不同。于是:put时:new User(1L,Alice)的 hashCode 假设是 111,放进 1 号桶。get时:另一个new User(1L,Alice)的 hashCode 是 222,去 2 号桶找。根本走错了桶,第二步的equals压根没机会执行,自然返回 null。equals说它们相等,hashCode却说它们不同,矛盾就出在这。Object 的约定:equals 相等则 hashCode 必须相等Object的文档定义了hashCode的三条契约,最关键的一条是:如果两个对象a.equals(b)为true,那么a.hashCode()和b.hashCode()必须相等。反过来不要求:hashCode 相等的两个对象,equals 可以不相等(这叫哈希冲突,允许存在)。所以规则是:重写equals就必须重写hashCode,而且要用equals里用到的同一批字段来算 hash。上面的equals用了id,hashCode就必须也基于id。正确写法用Objects.hash把 equals 用到的字段传进去,一行搞定:publicclassUser{privatefinalLongid;privatefinalStringname;publicUser(Longid,Stringname){this.idid;this.namename;}Overridepublicbooleanequals(Objecto){if(thiso)returntrue;if(!(oinstanceofUser))returnfalse;Useruser(User)o;returnObjects.equals(id,user.id);}OverridepublicinthashCode(){// 必须和 equals 用同一个字段 id,保证 equals 相等则 hash 相等returnObjects.hash(id);}}再跑一遍开头的代码,map.get(query)就能正确返回 “VIP” 了。最容易出错的点:两个方法的字段必须一致一个隐蔽的坑:equals和hashCode用了不同的字段集。比如 equals 只比 id,hashCode 却用了 id name:Overridepublicbooleanequals(Objecto){// 只比 idreturnoinstanceofUserObjects.equals(id,((User)o).id);}OverridepublicinthashCode(){returnObjects.hash(id,name);// 错!多带了 name}后果:两个 id 相同、name 不同的对象,equals说相等,但hashCode因为 name 不同而不等——直接违反契约。它们在 HashMap 里会落到不同的桶,又出现「相等却查不到」。铁律:hashCode 用到的字段,必须是 equals 用到字段的子集或相等集。最省心的做法是两者用完全相同的字段。可变字段做 key:另一个隐形陷阱即使 equals/hashCode 都正确,如果你用可变字段参与 hash,又在 put 之后改了这个字段,一样会丢 key:SetUsersetnewHashSet();UserunewUser(1L,Alice);set.add(u);// 按当时的 hashCode 放进某个桶u.setId(2L);// 改了参与 hashCode 的字段(假设 id 可变)System.out.println(set.contains(u));// false!对象还在集合里,却找不到对象放进去后 hashCode 变了,但它仍待在原来的桶里,contains按新 hashCode 去别的桶找,当然找不到。结论:用作 HashMap key / HashSet 元素的字段应当是不可变的(上例把 id 设为final就能从源头杜绝)。这也是为什么推荐用不可变对象当 key。用 IDE 或 record 自动生成,别手写手写 equals/hashCode 容易漏字段、错字段。两个更省心的方案:1. IDE 生成:IntelliJ 的Generate → equals() and hashCode(),选字段后自动生成配套的两个方法,保证字段一致。2. Java 16 用record:record 自动按所有组件生成 equals/hashCode/toString,天然满足契约,而且字段不可变:// 一行搞定,equals/hashCode 自动生成且一致、字段不可变publicrecordUser(Longid,Stringname){}如果你的 User 只是个数据载体,直接上 record,前面所有的坑都不用操心了。注意 record 是按全部组件算 equals/hashCode,如果你只想按 id 判等,那还是得手写或用 IDE 生成。小结equals和hashCode必须成对重写,这是Object的硬约定:equals 相等 ⇒ hashCode 必须相等。HashMap 查找先用 hashCode 定桶,再用 equals 比对;只重写 equals 会导致走错桶,equals根本没机会执行,表现为「相等却查不到」。两个方法必须用同一批字段;hashCode 多带或少带字段都会破坏契约。参与 hash 的字段要不可变,否则 put 后修改会让 key 在集合里「失踪」;推荐用final字段或不可变对象当 key。优先用 IDE 生成或 Java 16 的record,从源头避免手写出错。一句话记忆:改了 equals 就一定改 hashCode,而且用同一批不可变字段——HashMap 是先按 hash 找桶再按 equals 比对,hash 不一致,equals 再对也白搭。