
做 Java 开发这么多年有个话题几乎每次面试都会被问到日常写代码也天天围着它转但真正能把它从头到尾讲明白的人真不多——这就是“哈希”。新人刚接触的时候往往觉得它神神秘秘一堆“散列”“碰撞”“扰动函数”的术语砸过来直接劝退老手写了好几年 HashMap被问一句“为什么容量要是 2 的幂次方”也可能当场卡壳。这篇东西我准备用聊天的口吻把 Java 里的哈希从底层原理到实际应用再到踩坑经验完整地捋一遍。里面有我对源码的理解有线上问题的排查记录还有一些面试官真正想听的答案。不管你是刚开始学集合类的入门者还是准备跳槽的资深工程师应该都能从中找到点有用的东西。1. 哈希到底是什么从生活常识到 Java 底层1.1 用图书管理员的方式理解哈希哈希Hash也叫散列本质上做的一件事特别简单把任意长度的输入映射成一个固定长度的输出。我在给新同事讲这个概念的时候喜欢拿图书馆举例子。图书馆有几百万本书你要找《Java 编程思想》总不能一本一本翻吧通常的做法是根据书名或者编号算出一个数字然后根据这个数字直接定位到某个书架。这个“根据书名算出书架号”的过程就是一次哈希运算。书架号就是哈希值书名就是原始数据计算规则就是哈希函数。你输入“Java 编程思想”永远得到同一个书架号输入“设计模式”也会得到一个确定的、大概率不同的书架号。有了这个书架号查找一本书的时间复杂度几乎可以做到 O(1)这就是哈希表能够“秒查”的秘密。如果你理解了图书管理员这套工作流程那 HashMap 的底层逻辑你已经看懂一半了。剩下的问题就是书架不够了怎么办两个人算出来同一个书架号怎么办这俩就是哈希表扩容和哈希碰撞要解决的事。1.2 Java 里的 hashCode 是怎么来的在 Java 中几乎所有的对象都继承自 Object 类而 Object 类里有一个非常关键的方法public native int hashCode()。注意这个native关键字这说明它的实现不是用 Java 写的而是由 JVM 底层提供的。默认情况下它返回的是一个基于对象内存地址换算出来的整数你可以把它理解为 JVM 给每个对象发的一个“身份编号”。不同的对象实例默认 hashCode 大概率不同同一个对象实例在程序运行期间调用多次 hashCode返回值必须一致。但是这里有个坑默认的 hashCode 是“基于内存地址”的而不是“基于内容”的。两个字符串“abc”你用new String(abc)创建两次它们是两个不同的对象内存地址不同但如果直接用默认的 hashCode它们的哈希值就会不一样——这显然不合理因为从业务角度来看这两个字符串的内容明明是一样的。所以 String 类重写了 hashCode根据字符内容计算哈希值。这也是为什么 HashMap 用 String 做 key 特别好使因为内容相同的字符串hashCode 一定相同equals 也一定为 true这两个特性是哈希表能正常工作的基石。1.3 哈希不是加密别把概念搞混很多人听到“哈希算法”第一反应是“加密”。严格来说哈希和加密是两码事。加密是双向的加密之后还能解密哈希是单向的算出来就不太好还原。而且哈希的应用场景远不止安全领域HashMap 里的哈希、Redis 的哈希槽、负载均衡里的一致性哈希这些和 MD5、SHA-256 这些“加密哈希”用途完全不同。前者关心的是“怎么把数据均匀地打散到不同的桶里”后者关心的是“怎么让数据难以被篡改和伪造”。我在后面几个章节会分别展开讲先把概念分清楚后面才不会乱。2. HashMap 底层原理逐行拆解2.1 从数组加链表到红黑树的演进HashMap 是 Java 里最常用的哈希表实现它的底层结构经历过一次重要升级。JDK 1.8 之前HashMap 的底层是“数组 链表”JDK 1.8 之后变成了“数组 链表 红黑树”。为什么要在链表后面加红黑树因为一旦发生大量哈希碰撞数据会全部堆积在同一个数组位置上形成一个很长的链表链表的查询复杂度是 O(n)数据多了查找就会退化得很严重甚至失去哈希表 O(1) 的优势。红黑树的引入把这个问题缓解了当链表长度超过 8、数组容量大于等于 64 的时候链表会转换成红黑树查询复杂度从 O(n) 降到了 O(log n)。我实测过一个极端场景故意用一个哈希函数把所有 key 的哈希值都设置为同一个值往 HashMap 里塞了一万条数据。JDK 1.7 环境下查询耗时从几微秒飙到了几百毫秒而 JDK 1.8 环境下因为有红黑树兜底耗时只到了十几毫秒。虽然这是个极端案例但你已经能感受到树化对极端场景的意义了。2.2 put 一个 key 到底经历了什么拿map.put(name, 张三)这一行代码举例。整个流程是这样的先调用name.hashCode()拿到哈希值 h然后对 h 做一次“扰动处理”也就是把高 16 位和低 16 位做异或运算最后用得到的哈希值和数组长度减 1 做与运算得到数组下标。这个下标对应的位置就是桶的位置如果这个桶是空的直接放进去完事如果桶里已经有数据就要用 equals 方法逐个比较看看 key 是否已经存在。存在就覆盖旧值不存在就追加到链表尾部或者红黑树里。这里我想多说一句扰动函数的事。h ^ (h 16)这一行代码被很多人忽略其实它非常关键。它的作用是让哈希值的高位也能参与到底层的下标计算中这样即使两个 hashCode 的低位相同、高位不同最终计算出的数组下标也可能不同降低了碰撞概率。Java 的作者们搞了这么一手完全是为了在分布均匀性和计算性能之间取平衡。如果你自己设计哈希表这个思路非常值得借鉴。2.3 扩容机制和加载因子到底该怎么理解HashMap 里有个特别重要的参数加载因子默认值是 0.75。这个参数决定了哈希表在多“满”的时候开始扩容。举个例子默认初始容量是 16加载因子是 0.75那么当元素个数达到16 * 0.75 12的时候哈希表就会触发扩容容量翻倍变成 32。为什么是 0.75 而不是 1 或者 0.5这是个经典的面试题。加载因子越大空间利用率越高但碰撞概率也越高查询效率降低加载因子越小碰撞概率越低查询效率高但空间浪费严重。0.75 是 Java 作者在时间和空间成本之间做出的一个折中是经过大量测试的经验值。理论上你也可以把它调成 0.5 或者 1.0但除非有特殊场景我强烈不建议干这种事默认值就是最优的。还有一点必须说清楚为什么 HashMap 的容量一定要是 2 的幂次方核心原因在于可以用hash (length - 1)这个位运算来代替hash % length取模。位运算的速度远快于取模运算而且当 length 是 2 的幂次方时length - 1的二进制位全为 1hash (length - 1)的结果就能均匀分布在 0 到 length-1 之间不会浪费数组空间。如果你把一个非 2 的幂次方的值传进去HashMap 内部也会通过一系列移位和或运算把它规整到最近的 2 的幂次方。2.4 头插法、尾插法和并发环境下的死循环JDK 1.7 的 HashMap 在扩容时采用的是头插法也就是把链表节点按倒序的方式重新插入到新的数组里。在单线程环境下这没什么问题但在多线程环境下两个线程同时扩容就可能造成链表成环下一次查询时就会陷入死循环。这个问题在 JDK 1.8 里被修复了改成尾插法也就是按原来的顺序重新插入节点即使并发扩容也不会再有环状链表的问题。不过注意修复了死循环不代表 HashMap 可以在并发环境下放心使用。JDK 1.8 的 HashMap 在多线程下照样可能丢数据甚至因为并发 put 导致数组覆盖、数据丢失。如果你有并发场景老老实实用 ConcurrentHashMap它有分段锁或者 CAS 机制专门为并发设计。我在实际项目中见过有人嫌 ConcurrentHashMap “性能不行”非要自己加锁用 HashMap结果线上数据对不上账排查了一整天才发现是并发写入碰撞的问题。专业的事交给专业的工具这句老话在集合类里同样适用。3. hashCode 和 equals不成文却必须遵守的契约3.1 三条铁律背下来不亏Java 官方对 hashCode 和 equals 的关系定了三条规则这不是建议是硬性要求第一条如果两个对象用 equals 比较是相等的那么这两个对象的 hashCode 必须相等。注意是必须。第二条如果两个对象的 hashCode 相等equals 不一定相等这种情况叫哈希碰撞是允许的。第三条如果两个对象的 hashCode 不相等那么 equals 一定不相等这也意味着它们永远不可能出现在同一个桶里。这三条规则看起来简单但我面过太多候选人能把“equals 相等的对象 hashCode 必须相等”说清楚但问到“为什么 HashMap 里要是 equals 相等而 hashCode 不等会发生什么事”就答不上来了。答案很简单因为 HashMap 是根据 hashCode 先找桶再在桶里用 equals 找具体节点。如果 hashCode 不同两个 equals 相等的对象会被放进不同的桶那 HashMap 根本找不到你之前存的值。你会以为 set 的 key 没存进去其实是它“迷路”了。3.2 不遵守契约的惨痛教训我在项目里遇到过这样一个真实问题有个订单实体类 Order里面只重写了 equals没有重写 hashCode把它当作 HashMap 的 key用订单号判断是否同一个订单。结果程序运行一段时间后某些订单查不到对应的记录还偶发重复插入。排查到最后的结论令人哭笑不得——就是 hashCode 没重写导致的。两个订单号相同的对象equals 返回 true但默认的 hashCode 基于内存地址一个对象在堆里换个位置hashCode 就变了HashMap 按新 hashCode 找桶自然找不到旧数据。所以一个非常实用的建议是凡是重写了 equals 的类几乎必须重写 hashCode。这已经不是“规范性”问题而是正确性问题。3.3 一个标准的业务对象写法示例正确的写法是这样的比如一个只有订单号的简单订单对象public class Order { private String orderNo; // 省略 getter、setter、构造方法 Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; Order order (Order) o; return Objects.equals(orderNo, order.orderNo); } Override public int hashCode() { return Objects.hash(orderNo); } }这里说一下为什么用Objects.hash(orderNo)。首先订单号是字符串String 本身已经重写了 hashCode用它当 key 的哈希值非常合理。其次如果你的对象里有多个参与 equals 比较的字段一般在 hashCode 里也要尽量把这些字段都考虑进去保证相等对象算出的哈希值一致。但这里有个小坑如果你用Objects.hash()方法注意它会装箱为 Integer 等包装类型字段特别多的时候性能会稍差但绝大多数业务场景完全可忽略。4. 哈希碰撞与常见哈希算法解析4.1 碰撞的必然性鸽笼原理一点都不玄乎哈希碰撞是哈希表绕不开的话题。什么叫碰撞两个不同的输入算出来同一个哈希值就是碰撞。为什么一定会发生碰撞这里有个特别朴素的数学原理叫鸽笼原理抽屉原理。举个例子你有 10 个鸽笼却有 11 只鸽子那至少有一个笼子里有两只以上的鸽子。哈希函数也一样HashMap 的数组容量有限而你要存储的数据是无限的把无限的数据映射到有限的空间里必然会出现多个数据映射到同一个位置的情况。有人可能会问那我在设计哈希函数的时候能不能做得完美一点避免碰撞答案是不能只能降低碰撞概率不可能完全消除。哪怕你用 MD5、SHA-256 这种看起来已经很“随机”的算法理论上也存在碰撞的可能。区别在于碰撞概率的高低。哈希函数的好坏主要看它能不能把数据均匀地“打散”避兔扎堆而不是看它能不能彻底消灭碰撞。4.2 HashMap 的碰撞处理策略HashMap 用的处理策略叫链地址法也叫拉链法。也就是说多个元素映射到同一个桶时它们并不会互相覆盖而是通过链表或者红黑树串起来。与之相对的还有一种开放地址法也就是发生碰撞的时候顺着数组继续往后找空位ThreadLocalMap 用的就是这种方法。链地址法的好处是实现简单删除元素也比较方便缺点是极端情况下链表会很长开放地址法的好处是空间利用率高、缓存友好缺点是删除元素时的处理比较麻烦。回到 HashMap。JDK 1.8 的树化阈值为 8也就是链表长度达到 8 且数组容量达到 64 时链表转红黑树。这两个条件需要强调一下是且的关系不是或的关系。源码里写得非常清楚如果数组容量没有达到 64即使链表长度已经到了 8也只会触发扩容而不是转树。为什么非要有这个容量限制因为如果数组本身很小扩容比转树更划算扩容可以把数据打散到更多桶里从根源上降低链表长度。4.3 用 MessageDigest 计算文件哈希的实战示例除了 HashMap 内部用的哈希函数Java 里还有一个非常重要的哈希应用方向消息摘要。java.security.MessageDigest这个类提供了 MD5、SHA-1、SHA-256 的实现。我经常用它来做文件唯一性校验比如判断一个文件是否被修改过核心代码如下public static String sha256(File file) throws Exception { MessageDigest digest MessageDigest.getInstance(SHA-256); try (InputStream in new FileInputStream(file)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { digest.update(buffer, 0, len); } } byte[] bytes digest.digest(); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); }这段代码做的事情很简单分块读取文件内容不断喂给MessageDigest最后输出一个 64 位的十六进制字符串。这里有两个细节值得注意。第一必须要分块读取不能把整个文件一次性读进内存否则大文件会撑爆堆内存。第二输出格式一定要用%02x保证每个字节输出两位十六进制不然会出现哈希值长度不固定、比较失误的问题。顺带提一个和热词相关的点文件导出重新生成之后哈希值一般会发生变化但如果你只是修改了文件的元数据、文件内容完全没变某些哈希算法尤其是带随机盐的情况结果可能不同SHA-256 这种纯内容哈希则完全不受元数据影响。判断文件是否被修改正确的做法是固定用同一种算法比如都用 SHA-256而不是混用多种算法。5. 哈希在 Java 工程实践中的关键场景5.1 HashSet、ConcurrentHashMap 等集合类的哈希应用Java 集合框架里凡是名字里带 Hash 的底层几乎都和 HashMap 脱不了关系。HashSet 本质上就是一个 HashMap只不过它的 value 位置用一个固定的常量填充你往 HashSet 里 add 的元素实际是作为 HashMap 的 key 存进去的。所以 HashSet 的去重逻辑完全依赖 key 的 hashCode 和 equals这也是为什么自定义对象放进 HashSet 前必须重写这两个方法不然两个内容相同的对象会同时存在。ConcurrentHashMap 是并发环境下的王者JDK 1.8 的 ConcurrentHashMap 抛弃了 JDK 1.7 的分段锁设计改用 CAS synchronized 锁住数组的单个桶粒度更细并发度更高。它的存储结构依然是一个“数组 链表 红黑树”的哈希表只是每个桶的读写做了精细的并发控制。读操作基本无锁写操作先 CAS 再锁桶性能表现相当亮眼。在我们项目中高并发下的商品库存、登录 token、分布式锁这些数据都用它来存稳定性很好。5.2 布隆过滤器省内存的哈希高手布隆过滤器是哈希思想的一个极致产物。它的核心是用一个很长的二进制位数组和几个不同的哈希函数来判断一个元素“可能不存在”或者“一定不存在”。注意这个语义它有误判率但不会漏判。也就是说它说“不存在”就一定不存在它说“存在”有可能是误判。这个特性非常适合做缓存穿透的防御。举个例子在电商系统里用户查询某商品详情如果每次都直接查数据库遇到恶意请求疯狂查询不存在的商品 ID数据库就被打崩了。一个常见的做法是把数据库中所有存在的商品 ID 都放到布隆过滤器里。查询请求进来先问布隆过滤器这个 ID 存在吗如果回答“不存在”直接返回不再查库如果回答“存在”再去查库。哪怕是误判也只是多查了一次数据库不会造成严重后果。Guava 里提供了BloomFilter的实现用起来很简单但要注意设置合理的误判率和预估数据量这两个参数直接决定了位数组的大小。5.3 一致性哈希分布式场景中的哈希妙用分布式缓存场景下一致性哈希是绕不开的思想比如 Redis 集群的数据分片。假设你有 3 个缓存节点最简单粗暴的做法是hash(key) % 3把数据均匀分散到 3 个节点。但一旦节点数变成 4几乎所有 key 的取模结果都会变缓存会大量失效形成缓存雪崩。一致性哈希通过把节点和数据都映射到一个环形哈希空间让新增或删除节点时只影响环上的一小部分数据极大降低了重新映射的成本。实现思路其实不难先把各个节点可以用 IP端口的哈希值放到环上然后把数据的哈希值也算出来顺时针找到最近的节点作为存储目标。当某个节点挂掉它负责的数据会顺时针转移到下一个节点其他节点上的数据完全不受影响。这就是一致性哈希在工程中巨大的价值。虽然 Java 的标准库没有直接提供一致性哈希算法但实现起来也就一百多行代码网上也有很多成熟方案。5.4 幂等校验、分布式去重中的应用除了集合和缓存哈希在业务层面也有很多精巧的应用。比如接口幂等性校验前端每次都带一个由业务参数生成的请求摘要后端把这个摘要存到 Redis 里设置一个过期时间同一个摘要第二次进来直接被拒绝。生成摘要的方式就是把业务参数拼接后做 SHA-256。再比如短信发送频率限制把“手机号 时间窗口”做成 key哈希到 Redis 集群的某个节点上快速统计发送次数。哈希思想在分布式系统里无处不在学会用它来设计 key 的分布是高级工程师的基本功。6. 面试高频题你以为会了其实还没穿透6.1 为什么 HashMap 的容量非得是 2 的幂次方这个问题我在前面已经讲到过核心原因这里再单独拉出来放大。面试官问你这个问题他们想听的答案有几层。第一层是计算层面hash (length - 1)比hash % length快因为位运算只需要几个 CPU 时钟周期取模运算是除法运算非常慢。第二层是均匀性当 length 是 2 的幂次方时length - 1 的低位全是 1与运算能完整保留 hash 的低位信息数据分布更均匀。第三层是扩容方便扩容时元素的新位置要么在原位置要么在原位置加旧容量判断依据是 hash 值新增的那一位是 0 还是 1不需要重新计算全部哈希值。JDK 1.8 就是这么优化的性能提升非常明显。6.2 为什么重写 equals 必须重写 hashCode这个问题几乎每次面试都会碰见。标准回答我已经在前面说过了但面试官还会追问一句如果不重写 hashCode实际会发生什么你最好能结合 HashMap 的存取流程来回答先按 hashCode 定位桶再用 equals 在桶内比较两个 equals 相等但 hashCode 不一样的对象会被分到不同的桶HashMap 在查找时先去第一个桶里翻当然翻不到于是返回 null导致数据“丢失”。你要是能再补充一个自己实际踩过的业务场景面试印象分会好很多。6.3 HashMap 线程安全吗为什么不安全不安全而且 JDK 1.7 和 JDK 1.8 不安全的表现还不太一样。JDK 1.7 的问题我前面提到过多线程扩容可能形成环形链表导致死循环CPU 直接飙满。JDK 1.8 把头插法改成了尾插法环形链表的问题解决了但并发 put 时可能出现数据覆盖两个线程同时往同一个桶里 put后写入的覆盖了先写入的导致丢失更新。还有一种情况是扩容时多个线程同时操作导致部分节点丢失。回答这个问题的时候你如果能顺带说出 ConcurrentHashMap 的应对方案那就很加分了。JDK 1.8 的 ConcurrentHashMap 利用 synchronized 锁住链表头节点同时用 CAS 做无锁检查读多写少的场景下几乎无锁。6.4 HashMap 和 Hashtable 的区别为什么总被翻来覆去地问面试官喜欢问这个不是因为它多难而是因为它能快速考察你对基础知识的掌握是不是系统性的。HashMap 允许 null 的 key 和 null 的 valueHashtable 不允许HashMap 线程不安全Hashtable 线程安全但效率很低HashMap 的初始容量是 16Hashtable 是 11HashMap 用的扩容方式是乘 2Hashtable 是乘 2 加 1。但我要特别给你提个醒Hashtable 是遗留类不要在新的代码里使用它它的线程安全只是简单的 synchronized 锁方法性能远不如 ConcurrentHashMap设计上也没有区分读锁和写锁。6.5 用可变对象做 HashMap 的 key 会怎样这个坑非常隐蔽但一旦踩中就要命。比如你用一个自定义的 Person 对象做 key它里面有个 age 字段这个字段参与了 hashCode 的计算。你把 Person 放进 HashMap 之后又修改了它的 age那么它的 hashCode 就变了。但 HashMap 里的桶位置是根据旧的 hashCode 计算的现在你拿着这个对象去查找算出新的 hashCode直接去另一个桶里找自然找不到旧的数据就成了“孤儿”永远留在 HashMap 里还可能导致内存泄漏。所以永远不要用可变对象做 HashMap 的 key如果一定要用就把参与 hashCode 计算的字段设计为不可变的。实际开发中用 Integer、Long、String 这种不可变类型做 key 是最省心的。7. 实战踩坑记录与排查心法7.1 BigDecimal 和 Double 做 key 的教训有段时间我在做金额统计相关功能为了图方便直接用 BigDecimal 作为 HashMap 的 key。起初一切正常后来数据量大了出现的 bug 让我一头雾水同一个金额有时候能查到对应的明细有时候查不到。排查了大半天才发现问题BigDecimal 的 hashCode 是和 scale小数位数有关的1.0 和 1.00 用 equals 比较是 trueequals 相等时 hashCode 确实也相同但 2.0 和 2.00 呢实际上 BigDecimal 的 hashCode 考虑了 scale等价但精度不同的 BigDecimalequals 相等时 hashCode 一定相等这是满足契约的。问题出在另一个场景我先用了new BigDecimal(1.0)作为 key后续查询时用了new BigDecimal(1.00)。两个对象 equals 为 truehashCode 也一致看起来没问题。但 HashMap 查找时先比哈希值再比 equals按理说得找到才对。后来我把源码翻开一看发现问题出在内部计算避免用 BigDecimal 做 key问题就消失了。说实话这个“为什么 1.0 和 1.00 在一些数量级下 hashCode 一致但 equals 在某些场景返回 trueHashMap 还是找不到”的细节后来发现和 BigDecimal 的缓存机制有关系——不同 scale 的 BigDecimal 在hashCode()计算里其实会产生不同结果但那是在 equals 不完全相等的前提下。这里我最想给你的建议是金额这种数据等值比较永远用 compareTo不要用 equals做 key 就更别用了转成 Long 分单位存储才是正解。7.2 String 的哈希缓存和碰撞隐患String 类里有个很有意思的设计它内部有个hash字段默认是 0第一次调用 hashCode 的时候计算结果并缓存下来之后每次调用都直接返回缓存值。这样可以避免同一个字符串反复计算哈希值性能优化很到位。但这种缓存也带来一个理论上的小风险如果字符串的哈希值在缓存后你通过反射修改它的内部 char 数组内容那么后续 hashCode 返回的还是旧值equals 却可能比较出新内容这就破坏了哈希表的语义。当然这种鸡生蛋蛋生鸡的骚操作一般没人干我提这个主要是想提醒你反射能改的“不可变类”在并发环境里要特别小心。还有一个和字符串相关的经典攻击手法叫哈希洪水攻击攻击者构造大量哈希值相同的字符串往 HashMap 里塞让链表变得极长把查询复杂度从 O(1) 拖成 O(n)从而达到瘫痪系统的效果。JDK 通过红黑树树化缓解了这个风险而且 JDK 9 以后对 String 的哈希函数做了调整碰撞更难被刻意构造出来了。7.3 排查哈希问题时我的思路如果你怀疑线上问题出在哈希相关的逻辑上我会按下面几步来排查。第一重写实体类的 toString 方法把对象的 hashCode 打出来对比日志里存进去的时候和查出来的时候 hashCode 是否一致。第二检查这个对象是否被修改过特别是那些参与了 hashCode 计算的字段。第三检查这个类的 equals 和 hashCode 是否同时被重写代码扫描工具比如 SonarQube 会直接标出“重写 equals 但未重写 hashCode”的告警平时一定要重视。第四如果数据量大且分布不均可以临时在 put 和 get 的地方打点统计每个桶的深度快速定位是不是发生了大规模碰撞。这四步走下来绝大多数哈希相关的问题都能水落石出。7.4 自定义哈希策略的适用场景大多数情况下直接用对象自身的 hashCode 就够了。但某些特殊场景下你需要自己设计哈希策略。比如在分库分表的时候订单表中的数据要按订单号分散到 64 张表里你不能直接用订单号 % 64因为订单号可能有规律比如末尾位数是日期这样会导致数据倾斜。正确做法是先对订单号做一次 MD5 或者 SHA-256取结果的前几位转成整数再对 64 取模。这样即使订单号本身有规律哈希之后也会变得均匀。另一个场景是负载均衡里的 URL 哈希同一个用户的请求要始终落到同一台机器上那么对用户 ID 做一致性哈希就能保证会话粘滞。哈希是一种看似基础、实际贯穿整个 Java 技术体系的核心思想。我在实际项目里一次次感受到真正理解哈希的人写出来的代码在性能、稳定性、可维护性上都有明显优势。最后分享一个小技巧平时写代码时凡是重写了 equals 的地方先停下来想想 hashCode 要不要同步重写凡是用自定义对象做集合的 key先想象一下这两个对象在 HashMap 里是怎么被找到的。多想这两步你能避开绝大多数集合类的隐形坑。