
写这篇文章的念头源于最近帮一个小伙伴排查一个线上问题。他们自定义了一个对象作为Map的key结果数据怎么都查不到我看了半天代码发现那个类只重写了equals没重写hashCode。典型的“半吊子重写”问题很多Java开发者都在这上面栽过跟头。标题里的问号其实挺好的因为我发现越是基础的东西越容易“以为自己懂了”。hashCode这个东西面试被问了无数次八股文背得滚瓜烂熟什么“哈希值”“散列”“bucket”张口就来可真到写代码的时候能真正说明白、用得对的人不多。这篇文章就把我之前梳理的内容整理一下从原理、源码、应用场景到实际操作中的坑一次说透。1. 从Object说起为什么Java里会有一个hashCode方法刚接触Java的时候我们会发现Object类里除了equals方法之外还有一个native方法叫hashCode。这个方法是干嘛的学过数据结构的人应该知道“哈希表”这个概念Java中的HashMap、HashSet、Hashtable这些集合类底层都是基于哈希表实现的而hashCode方法就是对象进入这些集合时计算存储位置的依据。用一个生活化的例子来说明。你去图书馆还书管理员不会一本一本翻书架去帮你找位置而是根据书籍编号算出一个楼层和书架号然后直接走过去放下。哈希表就是干这个事的——它先把“对象”转换为一个“数组下标”把对象存储到数组对应的桶里。而这个“转换”的核心算法就依赖对象的hashCode返回值。hashCode方法本身是个native方法意味着它是由JVM底层实现的默认情况下返回的是对象内部地址经过某种算法换算出来的一个整型数。给对象分配一个“初始身份编号”这个编号大概率不重复但也不保证绝对不重复。之所以不保证绝对不重复是因为返回值的类型是int最多只能表示2的32次方个不同的数而JVM中的对象数量理论上可以突破这个上限碰撞是必然存在的只是概率大小问题。很多初学者有一个错误认知认为hashCode就是对象的内存地址。其实准确地说在HotSpot VM的默认实现里它是通过对象头里的mark word跟随机数做运算后得到的一个值JDK 8之后甚至默认采用了随机数生成策略也就是同一个对象每次启动JVM得到的hashCode可能是不同的而且跟实际内存地址没有直接的线性对应关系。只有在开启某些特定参数比如-XX:hashCode0时才比较接近地址偏移量。所以从Object继承的hashCode其实提供的是一种“识别功能”它帮Java的集合框架判断两个对象到底“像不像同一个”。默认情况下的“像”是指地址相同也就是同一个对象。可问题来了实际开发中我们经常希望的是“内容相同就认为是同一个对象”比如两个字符串“abc”虽然它们可能是两个不同的对象实例但我们期望它们在HashMap里被当作同一个key来对待。这个时候就必须重写hashCode方法了。一句话总结hashCode是对象的“散列身份标识”它是Java集合框架运作的基础也是equals方法在哈希类集合中能够高效工作的前提。2. hashCode与equals的契约为什么重写equals必须重写hashCode这一条可以说是Java面试中最高频的题目之一了。官方Java文档里明确规定了hashCode和equals必须遵守的协议如果两个对象通过equals方法比较是相等的那么对这两个对象分别调用hashCode方法必须产生相同的整数结果。如果两个对象通过equals方法比较是不相等的那么对这两个对象分别调用hashCode方法并不要求必须产生不同的整数结果。也就是说不相等的对象也可以有相同的哈希值哈希碰撞。在一个Java应用的执行期间只要对象的equals比较操作所用到的信息没有被修改那么对同一个对象多次调用hashCode方法都必须始终返回同一个整数。同一个对象的hashCode在多次执行中不需要保持一致因为JVM每次运行可以设定不同的散列策略。合约看起来很抽象第一个规定是硬性要求违反它会导致严重问题。第三个规定主要针对对象字段可变的情况也就是你依赖的字段一变hashCode也跟着变那这个对象就不适合放进依赖哈希的集合里。那为什么要这样规定根本原因在于哈希集合的内部工作流程。我们看HashMap的get流程先对key调用hashCode算出下标然后到对应位置的链表或红黑树里用equals逐个比对。如果两个对象equals相等但hashCode不同那么它们会散落到不同的桶里你在get的时候拿着一个hashCode去查很可能永远查不到另一个桶里的“等值对象”。反之如果hashCode相同但equals不同则会落到同一个桶里查询时需要通过equals来区分这就是哈希碰撞的概念。碰撞多了会降低性能但不会影响正确性。所以这两个方法的重写规范本质上是为了确保哈希集合的“正确性优先性能其次”。下面我写一个错误示例让你直观感受一下这个坑有多深。假设我们定义了一个颜色类只重写equals不重写hashCodepublic class Color { private String name; private int code; public Color(String name, int code) { this.name name; this.code code; } Override public boolean equals(Object obj) { if (this obj) return true; if (!(obj instanceof Color)) return false; Color other (Color) obj; return this.code other.code; } }然后我们把这个对象作为HashMap的key使用MapColor, String map new HashMap(); map.put(new Color(红色, 1), color-1); map.get(new Color(红色, 1)); // 返回 null第二行代码返回null原因就是equals相等code都是1的两个Color对象hashCode却是不同的都是Object的默认hashCode而它们是两个不同的实例。HashMap计算下标的时候用的hashCode根本不一样所以第二个对象去查的时候路过了它该去的位置却因为换了个下标而永远找不到前面放进来的数据。把equal给补上这段代码就能正常工作了Override public int hashCode() { return Integer.hashCode(code); }注意equals和hashCode所依据的字段应当保持一致你用code作为equals的判断依据hashCode就也必须用code参与运算。如果equals依赖三个字段hashCode却漏掉其中一个仍可能产生等值对象哈希不同的情况。我自己的习惯是使用JDK 7之后提供的Objects.hash工具类把参与equals的字段全部传进去简洁而且不容易漏Override public int hashCode() { return Objects.hash(name, code); }不过要留意Objects.hash的底层会把字段包装成数组再调用Arrays.hashCode如果你非常追求性能、调用频繁程度极高可以考虑手写质数乘加运算。后面我会专门展开讲一讲手写hashCode的性能和碰撞权衡。3. 为什么String重写了hashCode算法选择背后的门道提到hashCode的经典实现String类是一个绕不开的例子。这项工作已经被Java官方做掉了而且做得非常讲究。String的hashCode算法是公开的源码写得清清楚楚public int hashCode() { int h hash; if (h 0 value.length 0) { char val[] value; for (int i 0; i value.length; i) { h 31 * h val[i]; } hash h; } return h; }核心就是那句h 31 * h val[i]也就是从第一个字符开始每次把当前结果乘以31再加上下一个字符的ASCII或者Unicode编码值。这个算法在字符串领域叫做霍纳法则Horners Rule本质上是把字符串当作一个以31为底的“数字”来算。为什么偏偏选择31而不是29、37或者更大的质数主要是因为31这个乘数有几个值得注意的特点31是质数质数与其他数相乘时产生碰撞的可能性相对低一些。31可以写成31 32 - 1而32是2的5次方。JVM底层做乘法时会针对这种形式做优化把31 * i转换成(i 5) - i一次移位加一次减法比直接做乘法更快。31的哈希分布在实际使用中表现良好这是一个经过大量数据验证的经验值。有个经典对比是JVM规范里最初建议乘数用33、37、39等但这些数字在编译器优化方面不如31方便最终选择31是性能和分布之间的折中。想一想这个过程对我们会有什么启发当你自己写hashCode的时候选择质数作为乘数是共识但没必要一味追求大质数因为乘数太大会导致int溢出更快反而可能让哈希分布变得不可控还增加了无谓的计算开销。再来看Integer的hashCode那就简单到极致了Override public int hashCode() { return Integer.hashCode(value); } public static int hashCode(int value) { return value; }因为包装类的值本身就是int直接返回就行。Long的hashCode就认真一点了为了避免高位的数值信息在截断时丢失它把64位拆成高低两个32位public static int hashCode(long value) { return (int)(value ^ (value 32)); }这是JDK 8之后的写法把高位和低位的32位做异或散列效果比早期直接截断要好太多。这里想提醒一下hashCode的返回值是int而int是有符号的。如果计算结果超出了int的范围会发生溢出Java的整数溢出不会报错而是按照补码规则截断。也就是说hashCode出现负数是非常正常的只要结合HashMap内部算法做无符号右移处理负数依然能映射到正确的数组下标。初学者看到负哈希值不用慌。4. 深入HashMap的散列过程hashCode是起点而不是终点很多人以为HashMap就是简单地拿着hashCode除一下数组长度取余数作为下标。这个想法大方向没错但JDK的实现比这个精细得多。了解了完整流程你才算真正明白为什么面试官喜欢追问“HashMap为什么容量是2的n次幂”这类问题。HashMap计算下标总共两步。先看这一步代码在hash(Object key)方法里static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }它的操作是拿到key的hashCode记为h然后把h无符号右移16位再与h本身做异或。目的让hashCode的高16位信息参与低16位的运算。为什么需要这么做因为HashMap的数组长度一般不会特别大默认数组大小是16即使扩容到几万直接用长度取余时实际参与计算的主要是hashCode的低位。如果对象实现的hashCode算法比较简单低位区分度又不高碰撞率就会飙升。通过高16位和低16位异或即使数组长度很小也能让高位的信息间接影响下标分布起到信息稀释的作用。接着是索引定位代码在putVal里if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null);这里使用的是位运算(n - 1) hash而不是取模运算hash % n。前提是数组长度n必须是2的幂因为2的幂减1之后二进制低位全是1这时候(n - 1) hash等价于hash % n并且位运算比取模取余快得多。这也是HashMap要求初始容量必须调整为2的幂的原因你在构造函数里传入一个不是2的幂的容量内部也会通过一系列位移运算帮你补齐到最近的2的幂。等一等这就有个性能提示了正常情况下数组下标分布只取决于hash值的低位而低位重复会导致桶内链表长度变长。JDK 8里HashMap还做了一个重要升级当一个桶的链表长度超过8并且数组容量达到64时链表会转成红黑树把最坏情况下O(n)的查询优化成O(log n)。这就是为什么面试经常问到“为什么是8”官方注释给出了泊松分布的计算参考在随机哈希码的情况下桶内链表长度达到8的概率已经低于千万分之一正常人写的hashCode不会差到这个程度所以阈值定为8在大多数场景下不会触发树化。再延伸一个知识点重写hashCode时返回值应该尽量“散列均匀”。有人说那不如直接返回一个随机数反正均匀。这是大错特错的。因为hashCode要满足前面说的“equals相等的对象hashCode必须相同”如果equals判断依赖字段你却返回随机数那两个内容完全相同的对象哈希值必然不同直接违反合约程序立刻出Bug。所以实操上我们在设计hashCode时应该围绕“稳定的字段”来做文章。什么是稳定的字段就是不参与业务变化、只作为对象身份标识的那部分数据。比如订单号、用户ID这种一旦生成就不会变的字段就适合参与哈希计算。反过来如果对象的某个字段会随着业务发生改变而你拿它来做hashCode那么对象放进HashMap或HashSet之后一旦字段发生变更对象就“找不到了”因为它在桶中的位置已经变了。这是掉进哈希集合里的另一个大坑值得专门提示。5. 手写hashCode的实践要点质数、字段、均匀性一个不能少那么在企业级项目里如果不使用Objects.hash我们应如何手写一个高质量的hashCode这里给出一个常用的标准写法模板Override public int hashCode() { int result 1; result 31 * result fieldA; result 31 * result fieldB; result 31 * result (fieldC ! null ? fieldC.hashCode() : 0); return result; }几个要点拆开来讲。第一初始值设置。result初始值选1还是别的质数其实对最终分布的影响很小真正重要的是“每一步都乘以同一个质数再加上一个字段的哈希”。为什么每一步都要乘同一个数这是为了把字段之间的相对顺序编码进结果中。你可以做个实验两个字段分别算a * 31 b和b * 31 a结果一般不同。这意味着字段顺序权重大同样的字段集不同顺序会得到不同哈希值能在一定程度上减少碰撞。第二字段选择。原则就是“equals用到哪些字段hashCode就用哪些字段”。这个规则是维护的重点也是最容易出问题的。一旦漏掉后果不是慢而是直接违反契约等值对象哈希不同集合行为就会错乱。我建议在写完hashCode后返回去看一眼equals方法体逐一核对字段或者用IDE的generate equals and hashCode功能让工具帮你保持一致。第三空指针处理。如果字段是对象类型直接调用其hashCode前要做判空。因为字段为null时调用hashCode会抛NullPointerException。标准写法是三目运算result 31 * result (fieldC ! null ? fieldC.hashCode() : 0);第四基本类型数组的处理。如果是int[]、byte[]这类不建议逐位遍历循环累加直接用Arrays.hashCode(arr)这个工具方法已经处理好了按顺序累加的逻辑比自己写一个for循环更严谨。注意Arrays.hashCode和Objects.hash是两个不同方法一个是针对数组的一个是针对可变参数的别搞混了。那有同学会问能不能直接用IDEA生成的模板不修改可以但生成完之后最好检查一下字段因为IDE没参与你的业务设计它不知道这个类将来会不会被放进HashMap中也不知道哪些字段会在对象存续期变化。它只能机械地把所有非静态字段拉进来参与运算。如果你有一个字段是状态类型的比如“订单处理进度”它会改变用这个字段参与哈希隐患非常大。另一个高频问题是如果hashCode返回固定值比如0或者1会怎样从正确性角度讲如果equals实现正确那么“equals相等则hashCode相等”这个契约依然满足程序不会出现查询错误。但是从性能角度讲这等于所有对象都落在同一个桶里HashMap退化成链表查询复杂度变成O(n)代码一上线就可能直接把CPU扛满。所以一旦重写了equalshashCode的质量也要一并考虑它直接关系到集合性能上限。6. 经典面试题与实战踩坑从理论到代码的查漏补缺聊到这里我忍不住想把一些常见的面试题和真实的排查场景串起来因为这些才是大家平时最容易慌的地方。面试题一两个对象equals比较为truehashCode一定相同吗答案是“必须相同”这是Java规范强制约定的。哪怕你没重写hashCode只要equals说它们相等JVM层面的哈希集合就默认它们必须落到同一个桶里而你如果没做到就是违反了契约。面试题二两个对象hashCode相同equals一定相等吗不一定。这是哈希碰撞是允许存在的只是会影响性能。面试官如果追问“请说一种你遇到的碰撞场景”你可以提String哈希算法因为理论上不同字符串组合是有可能算出同一个int值的尤其是超长字符串int溢出之后碰撞概率明显增加。面试题三重写hashCode会影响equals结果吗不影响equals是独立的hashCode只是给集合用的两者并不相互调用。但集合逻辑会把两者联系起来所以要么都不重写要么都重写。面试题四HashMap允许key为null吗允许HashMap的hash方法里专门处理了key为null的返回0的场景。HashTable和ConcurrentHashMap就不允许因为它们在计算哈希时直接调用hashCodenull会抛异常。面试题五HashSet去重时是先比较hashCode还是先比较equals先比较hashCode。两个对象的hashCode不同直接判定它们“不相等”even equals可能返回true也不会进入比较。所以如果你只重写了hashCode没重写equals那么哈希值相同但equals为false的对象会在同一个桶里共存而哈希值不同却equals为true的对象会被错误地当成不同元素——这又回到老问题两个方法必须一起维护。接下来讲一个我们项目组真实遇到的坑。某天线上网关服务出现偶发性的请求路由错乱排查下来竟然是分布式缓存key导致的。网关把请求方信息包装成一个参数对象作为key往本地缓存里存结果。这个参数对象的equals判断了两个字段appId和userId但hashCode只用了appId。结果就出现了不同的用户查到了同一个缓存值的情况因为hashCode相同都只跟appId相关而equals不同userId不同本该放到不同桶的数据全挤到一个桶里变成链表同时get的时候又存在覆盖逻辑一个用户的结果被另一个用户的结果覆盖。表面看是缓存数据串了根因却是hashCode设计字段不足。这个案例可以给我们一个非常重要的实际建议在hashCode的设计阶段把所有参与对象身份区分的字段都放进去不要觉得“少一个字段影响不大”。少一个字段不只是碰撞增多的问题如果那个字段正好是equals区分的主要字段比如userId、订单号那整个集合的行为都会进入不可控状态。还有一个坑是可变对象放入集合后字段被修改。比如把对象放进HashSet时hashCode正常但之后你改了对象参与hashCode计算的字段那么这个对象在集合里的哈希位置就跟它当前的实际哈希不匹配了。查询时用新对象去查hashCode对不上会返回“集合里没有这个元素”但元素其实还在集合里形成了“内存泄漏”。解决方式很简单放进哈希集合的对象要么设计为不可变类要么明确约定放入后不得修改关键字段。这里说一个Java面经里非常爱考的知识点为什么String适合作为HashMap的key因为String是不可变类它的hashCode一旦计算出来就会被缓存到字段hash中同一个字符串对象的哈希不会变这保证了作为key的稳定性。而String在每次重新生成时能算出一致的哈希这保证了等值字符串的哈希一致。这种双重特性使String成为Java世界里最理想的key类型。7. 关于hashCode的编码习惯从“能用”到“好用”最后说说养成什么习惯能让代码少踩坑。其实说到底hashCode不是写完了就完了它伴随整个对象的生命周期。我在项目里有个不成文的小规矩如果某个类被用作Map的key或者Set的元素那么类的设计上我优先考虑做成不可变类。也就是所有字段添加final修饰构造时一次性赋值不提供任何setter。这样从根本上杜绝了“字段变化导致哈希漂移”的问题。就算业务上确实需要修改某些属性那也不该修改key对象本身而是新创建一个对象再放进去。代码审查的时候我会重点关注三件事一是equals和hashCode使用的字段集是否一致二是hashCode是否包含易变字段三是有没有用无参构造创建一个对象之后、往里set一堆字段作为key的做法。第三种写法出现的Bug率最高风险也最高看到类似的代码我一般会建议改成构造器或建造者模式。如果团队里有人依赖Objects.hash写hashCode我也不反对毕竟它能保证字段顺序和判空处理出错的概率极低。唯一让我犹豫的是那种追求极致性能的接口每秒调用hashCode几十万次Objects.hash的数组包装开销就会被放大。遇到这种情况手写质数乘加也完全可以但一定要写好单元测试确保符合合约。至于测试的方式可以构造两组“equals为true”的对象分别断言它们的hashCode相等再构造一些等价类不同但哈希理论相同的特例断言它们可以正常存入和取出。另外一个容易被忽略的性能点是对频繁使用的对象可以把hashCode缓存在实例字段里比如前面String类里的private int hash字段第一次计算后存下来后续直接返回省得每次重复遍历字符数组。在自定义类中也可以用同样的思路但注意前提依旧是“参与哈希的字段在对象生命周期内不能变”。实际工作中我还遇到过一种奇怪情况把对象放到分布式缓存框架里比如RedisTemplate的key序列化后不同JVM进程间的hashCode不一致导致路由到不同的Redis节点。这其实不怪hashCode因为哈希集合本身就要求hashCode在一次运行期间保持一致即可跨JVM并没有这个义务。如果你需要分布式环境中生成稳定的哈希值请自己实现基于内容的摘要算法比如SHA-256或者MD5再用截断的字节转换成int不要依赖Object.hashCode。围绕hashCode的内容其实还有很多可以深挖比如红黑树化的临界条件、哈希扩容时的rehash机制、ThreadLocal的魔数0x61c88647每一个都值得单独写一篇。不过最核心的仍然是那句话hashCode不是独立的数字游戏它是和equals、对象状态、集合框架绑定在一起的一套合约你遵守它代码就四平八稳你违反它排查问题的过程会让你印象深刻到下次再也不敢忘。希望这篇梳理对正在啃Java基础或者准备面试的朋友有帮助。