1. 项目概述:HashMap 与扰动 hash 究竟是什么
我做了十来年 Java 开发,面试过的人少说也有几百个。每次问到 HashMap,大部分人都能背出“数组加链表、红黑树、负载因子 0.75”这几句话,但你再追问一句“为什么 hash 方法要右移 16 位再异或”,十个人里有八个会卡住。说实话,这不能全怪应聘者——网上讲 HashMap 的文章太多了,但绝大多数都把“扰动 hash”当成一个结论直接扔给你,从来不讲清楚它到底在解决什么问题、是怎么被设计出来的。
HashMap 底层原理说白了就三件事:数据存哪里、怎么找位置、位置冲突了怎么办。扰动 hash 则是这一切的地基——如果 hash 算得不够散,后面数组扩容、链表转红黑树这些设计全都白搭。这篇博文我不想再重复那些烂大街的源码逐行注释,我想换一个讲法:先告诉你 HashMap 设计者当年在头疼什么问题,再带你一步一步推导出扰动 hash 为什么偏偏是“右移 16 位异或”,最后把 put、get、扩容的完整链路串起来。
这篇内容的定位是给有一定 Java 基础、准备面试或者想真正吃透集合框架的读者。不管你是刚学完语法想进阶,还是已经工作几年想补课,只要跟着我把这条线走完,再有人问你 HashMap,你不仅能答出来,还能讲明白“为什么”。
2. 底层存储设计思路:为什么是数组加链表
2.1 从“找东西”这个场景说起
把 HashMap 想成一个超大的储物柜,柜子有一排排抽屉,每个抽屉有自己的编号。你要存东西的时候,先根据钥匙算出一个抽屉号,把东西放进去;取的时候再根据同一把钥匙算出同一个抽屉号,直接去那个抽屉里拿。如果两个不同的钥匙算出了同一个抽屉号,就叫“哈希冲突”,这时候就不能硬塞了,得在同一个抽屉里把多个东西按顺序排好,这就是链表。
这个设计妙在哪?数组的随机访问是 O(1),链表解决冲突又能无限扩容。两者一拼,理想情况下 HashMap 的所有操作都是常数时间。但这里有个关键点:数组的容量是有限的,到底给多少个抽屉才合适?抽屉太少,冲突严重,链表越来越长,性能退化到 O(n);抽屉太多,内存大片浪费。HashMap 的答案是用“负载因子”来平衡——默认 0.75,意思是数组用到 75% 就翻倍扩容,既不过度浪费空间,也不让冲突失控。
我见过不少刚入门的朋友有个误解,觉得链表就是用来存 hash 一样的数据的。实际上链表里存的是“hash 取模后落到同一个桶”的数据,这些数据的原始 hash 值并不一样,只是桶号碰巧相同。理解这一点,后面看扩容时的链表拆分逻辑才不迷糊。
2.2 JDK 1.8 的改进:红黑树是补救措施而非设计目标
JDK 1.8 之前,HashMap 的桶里只有链表。一旦发生严重的 hash 碰撞,比如恶意构造一堆 hash 相同的数据往里塞,链表会变得巨长,HashMap 直接退化成一个链表,put 和 get 都变成 O(n),这是典型的拒绝服务攻击手段。为了防住这种攻击,1.8 引入了红黑树:当链表长度超过 8 且数组容量达到 64 时,链表转成红黑树,把最坏情况的时间复杂度从 O(n) 降到 O(log n)。
注意我强调“数组容量达到 64”,这是新手很容易忽略的细节。源码里有一段条件判断,如果链表长度超过 8 但数组容量还不到 64,不会转树,而是先扩容。什么逻辑?转红黑树的代价很高——节点要变色、旋转,比普通链表节点重得多。数组容量小的时候,大概率是因为容量不够导致冲突集中,这时候扩个容,把数据分散到新桶里,冲突自然就缓解了,没必要急着树化。这就像房间太小东西放不下,你硬塞抽屉不如直接换个更大的房间来得实在。
树化阈值 8 和反树化阈值 6 之间故意留了空档,这也是有讲究的。如果树化后链表长度降到 6 以下就转回链表,而 put 和 get 的频繁操作会让长度在 6 和 8 之间反复横跳,导致频繁转换,白白消耗性能。留出 2 的缓冲区间,是为了避免抖动。
3. 扰动 hash 深度拆解:一个右移 16 位的精妙设计
3.1 hash 计算过程的完整推导
现在我们来到这篇文章的核心。HashMap 计算桶位置的完整链路是这样的:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } // 计算桶索引 index = (n - 1) & hash第一步,调用 key 的 hashCode() 方法得到一个 int。这个 int 是 32 位的二进制数,取值范围理论上有 40 多亿个,但 HashMap 的数组初始容量只有 16,扩容一次翻倍,哪怕扩到几万,和 int 的取值范围比起来还是九牛一毛。
第二步就是扰动函数:把 hashCode 右移 16 位,再和自己做异或。右移 16 位,相当于把高 16 位挪到了低 16 位的位置上;异或操作,则把原始 hashCode 的高位信息和低位信息混在一起。这样处理后,低 16 位就同时包含了原始 hash 的高位和低位特征。
第三步,用 (n - 1) & hash 计算桶下标。n 是数组长度,由于 HashMap 的容量始终是 2 的幂,n - 1 的二进制就是全 1。比如 n = 16,n - 1 = 15,二进制是 0000 1111,和任何 hash 值做按位与,等于直接取 hash 的低 4 位。
第三步才是问题所在——如果你不扰动,直接用原始 hashCode 做按位与,那么数组长度是 16 时,只有 hash 的低 4 位参与了运算,高 28 位全部浪费。如果两个对象的 hashCode 在高位不同、低位相同,它们会算出同一个桶号,hash 冲突的概率非常高。
3.2 从二进制视角看扰动前后的差异
光说理论不够直观,我手推一个例子。假设数组长度 n = 16,n - 1 = 15,即二进制 0000 1111。有两个对象 A 和 B,它们的 hashCode 分别是:
A 的 hashCode: 1010 1100 0110 1001 0001 1100 1011 0101 B 的 hashCode: 0110 1001 0011 1010 0101 0011 0011 0101
这两个 hashCode 的低 4 位都是 0101,如果你不扰动,直接用 hashCode & 15,A 和 B 都会被分到桶 5,冲突了。
现在看扰动后的效果。A 的 hashCode 右移 16 位得到 0000 0000 0000 0000 1010 1100 0110 1001,异或后就变成了:
1010 1100 0110 1001 0001 1100 1011 0101 ^ 0000 0000 0000 0000 1010 1100 0110 1001 = 1010 1100 0110 1001 1011 0000 1101 1100B 同理,右移 16 位得到 0000 0000 0000 0000 0110 1001 0011 1010,异或后是:
0110 1001 0011 1010 0101 0011 0011 0101 ^ 0000 0000 0000 0000 0110 1001 0011 1010 = 0110 1001 0011 1010 0011 1010 0000 1111现在再看低位,A 的低 4 位变成 1100,桶号 12;B 的低 4 位变成 1111,桶号 15。原本冲突的两个人,扰动后被分配到了不同的桶。
这个例子的本质是:A 和 B 的原始 hash 在高 16 位上有差异,扰动操作把这段差异“传导”到了低 16 位,而低 16 位正是参与桶号计算的区域。这就是“扰动”二字的含义——人为制造变化,把高位的随机性扩散到低位,让最终的桶分布更均匀。
3.3 为什么偏偏是异或,为什么是 16 位
你可能会问:扰动操作有很多种写法,为什么偏偏是异或,偏偏是 16 位?
先说异或。按位与偏向 0,按位或偏向 1,只有异或是真正的 50% 概率出 0、50% 概率出 1,能把两个输入的信息以最“平均”的方式融合。比如我们想让输入的第 1 位影响输出第 17 位,用异或既不会有信息丢失,也不会引入偏差。如果用与运算,只要其中一个位是 0,结果永远为 0,这会压制另一个输入的信息;或运算同理,一个位是 1 就覆盖另一个。异或没有这个问题,它的输出能充分保留两个输入的随机性。
再说 16 位。高 16 位右移 16 位和低 16 位异或,本质上是让高 16 位的每一位和低 16 位的对应位互相混合。为什么不是 8 位、24 位?因为 int 是 32 位的,最终参与桶号计算的是低 n 位(n 是数组容量的对数,最坏情况下扩容到非常大时 n 可能接近 30)。把高 16 位混合进来,相当于保证了“无论数组多大,原始 hash 的所有位都有机会影响到桶号计算”。
从概率角度看,有一个经典的数学结论可以说明扰动函数的威力:假设原始 hashCode 是按照均匀分布随机生成的,字符串的 hashCode 计算中,低位容易出现聚集现象——比如字符串“Aa”和“BB”这两个 hash 差异只在高位。如果不扰动,当数组长度较小时,这两个对象几乎必然冲突。扰动后,高位的差异被引入低位,冲突概率显著下降。Java 的哈希分布在实际数据中往往不是理想的随机分布,尤其像 Integer 这种 hashCode 就是自身值的类型,如果 key 是连续的 1、2、3...16,不扰动的话,它们会整整齐齐地排满一条链,扰动后虽然这几个值仍然会均匀散开(因为)它们的低位本身不同),但如果你遇到的是像“16、32、48、64”这种步进为 16 的数字序列,不扰动的后果就完全暴露了:
16 的二进制是 0001 0000,32 是 0010 0000,48 是 0011 0000……它们的低 4 位全是 0,直接 & 15 全落到桶 0,链表爆炸。扰动后,16 右移 16 位的值和自身异或,结果不再是原来的 16,低 4 位不再是 0,冲突自然化解了。我当年在项目里就遇到过用 Integer 做 key、并且数据规律性极强的场景,加了扰动和不加扰动,HashMap 的查询效率差距是肉眼可见的。
4. put 流程全解析:从定位到插入的完整路径
4.1 一次 put 操作到底做了什么
把扰动函数讲清楚了,put 的完整流程就顺理成章了。这里我画不出实物流程图,但你跟着文字顺序走一遍,逻辑非常清晰:
第一步,判断哈希表是否为 null 或者长度为 0。首次 put 时会先调用 resize() 初始化一个默认容量为 16 的数组。
第二步,调用 hash(key) 计算扰动后的哈希值。
第三步,用 (n - 1) & hash 计算桶索引,找到桶位置后分三种情况处理:
- 如果桶是空的,直接 new 一个 Node 放进去,完事。
- 如果桶不为空,先比较桶里第一个节点的 hash 和 key。如果都相等,说明是更新操作,直接把老值覆盖掉。
- 如果第一个节点不匹配,判断它是链表节点还是红黑树节点。树节点走 putTreeVal 方法,链表节点则从头到尾遍历,逐个比较。如果找到了相同 key,覆盖并返回旧值;如果遍历到末尾也没找到,把新节点追加到链表尾部。此时再检查链表长度是否超过 8,超过则调用 treeifyBin 尝试转红黑树。
最后,修改次数 modCount 加 1,容量加 1,如果超过阈值(数组容量乘以负载因子),触发 resize() 扩容。
这里有个细节必须提醒:判断 key 是否相同用的是 key.equals(k),所以重写 equals 时一定要同步重写 hashCode,否则就会出现“equals 相同但 hash 不同”的诡异问题——两个相等的 key 被放进不同桶,get 的时候找不到。踩过一次你就知道有多痛。
4.2 从 JDK 1.7 到 1.8 的改动:尾插法为何更好
JDK 1.8 还有一个低调但极其重要的改动:链表插入从头插法改成了尾插法。
1.7 时代用头插法有一个性能上的考虑——新插入的数据更可能被频繁访问,放链表头部能减少遍历。但头插法在并发扩容时有个著名的问题:因为 java.util.HashMap 本身就不是线程安全的,多线程同时 put 触发 resize,头插法会让链表在转移过程中形成环,一旦成环,下次 get 这个桶里的数据时就会发生死循环,CPU 飙到 100% 都拉不回来。这是 1.7 的著名悲剧。
1.8 改成尾插法后,链表在扩容转移时只有指针调整,不会反转顺序,也就不会出现环形链表。但别误会,这并不代表 HashMap 可以在并发环境下放心用。并发 put 仍然会丢数据、覆盖数据,正确做法是用 ConcurrentHashMap。面试官问到这里,你如果能说出“尾插法是为了配合扩容时的链表拆分逻辑,避免死循环”,会显得你真的看过源码、理解改动动机。
5. 扩容机制:resize 背后的数学与工程权衡
5.1 为什么容量必须是 2 的幂
HashMap 扩容的老规矩是:新容量 = 旧容量 × 2。为什么死磕 2 的幂?这个设计贯穿了整个 HashMap,前面计算桶索引时已经体现过了——n 是 2 的幂时,n - 1 的二进制是全 1,& 运算等价于取模,但比取模快得多。CPU 做位运算只需要一个时钟周期,做取模除法可能要几十个周期,这个差距在高频 put/get 下会被无限放大。
2 的幂的另一个好处体现在扩容时的索引重算。假设旧容量 n 是 16,新容量是 32。一个 key 的扰动 hash 假设是 h,它在旧数组的索引是 h & 15,在新数组的索引是 h & 31。注意 15 和 31 的二进制差什么?差在第 5 位。也就是说,h 的第 5 位是 0,索引不变;第 5 位是 1,新索引 = 旧索引 + 16。
JDK 1.8 的源码正是利用这个性质优化扩容的——它没有重新计算每个节点的 hash 再取模,而是直接检查节点 hash 值的第 5 位(即 oldCap 对应的那一位),为 0 的留在原桶,为 1 的移到“原索引 + 旧容量”的新桶。这一手极大地提高了扩容效率。
5.2 扩容时链表拆分的底层逻辑
具体到代码层面,扩容时每个桶的链表会被拆成两条新链表:低位链表(loHead)和原索引位链表(hiHead)。这里我贴一下 1.8 里的核心套路,但做了简化:
Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } e = next; } while (e != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }看懂这段代码的关键就一行:(e.hash & oldCap) == 0。oldCap 是旧容量,也就是 16、32 这种值,二进制是 10000、100000,恰好就是 n 的最高位。这个与运算检测的就是 hash 在“新增位”上的值。为 0 说明扩容后索引不变,挂到 loHead 上;为 1 说明新索引要加 oldCap,挂到 hiHead 上。
记住,这里重用的是“扰动后的 hash”,不是原始 hashCode。这也是扰动 hash 贯穿始终的体现——如果 hash 在初始计算时没混合高位,扩容时这一位的随机性就会差一些,链表拆分后两个新桶的元素数量可能严重失衡。
5.3 负载因子 0.75 是怎么来的
负载因子是容量与性能的杠杆。太大,比如 1.0,数组利用率高但冲突概率大,链表变长、查询变慢;太小,比如 0.5,冲突少、查询快,但浪费一半内存,扩容频繁,整体吞吐量反而下降。0.75 是一个在时间和空间成本上取得平衡的经验值。
有一种解释是,0.75 是泊松分布下的一个临界参数。当负载因子为 0.75 时,桶中出现链表长度达到 8 的概率大约是千万分之六,这个概率低到在常规业务中可以忽略不计,因此红黑树理论上很少被真正触发,它主要是为了防御极端攻击而设的保险措施。这个数学背景我当年也是看了源码注释反复推算才彻底理解,面试能主动讲出这一点,通常会让人眼前一亮。
6. HashMap 排序实战:把理论落到代码上
热词里提到“hashmap 排序”,也是面试里的常客。这里分享两个最实用的实现,我平时在项目中碰到需要按 key 或 value 排序的场景,基本都是这两种写法。
6.1 按 key 排序的推荐写法
最推荐的方式是构造一个 TreeMap,直接把 HashMap 丢进去,因为 TreeMap 天然按 key 的自然顺序排序:
import java.util.*; public class HashMapSortByKey { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("banana", 5); map.put("apple", 3); map.put("cherry", 8); map.put("date", 1); // 直接放入 TreeMap,按 key 升序 Map<String, Integer> sortedMap = new TreeMap<>(map); System.out.println("按 key 排序结果:" + sortedMap); // 如果需要自定义排序规则,比如按 key 长度 Map<String, Integer> customSortedMap = new TreeMap<>(Comparator.comparingInt(String::length)); customSortedMap.putAll(map); System.out.println("按 key 长度排序结果:" + customSortedMap); } }TreeMap 底层是红黑树,插入时就会按照比较器调整顺序,省去了手写排序的麻烦。注意自定义比较器时,如果两个 key 长度相同,TreeMap 会认为它们是同一个 key,后面的值覆盖前面的值,这是新手最容易掉的坑。解决办法是在比较器里加一个二级比较:
Comparator<String> comparator = Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder());6.2 按 value 排序的实现思路
HashMap 本身不支持按 value 排序,需要把 Entry 拿出来放到 List 里,再用 Collections.sort 或者 Stream 的 sorted 操作。Java 8 之后的写法最简单:
import java.util.*; import java.util.stream.*; public class HashMapSortByValue { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("banana", 5); map.put("apple", 3); map.put("cherry", 8); map.put("date", 1); // 按 value 升序 List<Map.Entry<String, Integer>> list = new ArrayList<>(map.entrySet()); list.sort(Map.Entry.comparingByValue()); System.out.println("按 value 升序:" + list); // 按 value 降序 List<Map.Entry<String, Integer>> descList = new ArrayList<>(map.entrySet()); descList.sort((e1, e2) -> e2.getValue().compareTo(e1.getValue())); System.out.println("按 value 降序:" + descList); // 使用 Stream 的写法,更简洁 Map<String, Integer> result = map.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .collect(Collectors.toMap( Map.Entry::getKey, Map.Entry::getValue, (oldVal, newVal) -> oldVal, LinkedHashMap::new )); System.out.println("Stream 按 value 降序:" + result); } }Stream 版本里最后用了 LinkedHashMap::new,很多第一次写的人会漏掉这一步,直接 Collectors.toMap 得到的新 map 是默认的 HashMap,顺序根本不保证,排序等于白做。用 LinkedHashMap 才能保留插入顺序。这是一个极其容易踩的隐藏 bug,我在代码 review 里见过不止三次。
7. 常见问题与排查技巧实录
HashMap 相关的坑,我在实际项目中踩过的、面试中问过的,整理成了一张速查表,希望对你有用。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 重写 equals 后 get 不到数据 | 没有同步重写 hashCode,equals 相同但 hash 不同 | equals 和 hashCode 必须同时重写 |
| 自定义对象作为 key 时出现逻辑错误 | 对象是可变的,hashCode 在放入 HashMap 后发生变化 | 优先使用不可变对象作为 key,如 String、Integer |
| 高并发下 put 丢数据 | HashMap 非线程安全,并发写入互相覆盖 | 换用 ConcurrentHashMap |
| 容量设得很大但仍频繁扩容 | 误解了初始容量的语义,只设了容量没考虑负载因子 | 估算数据量,initialCapacity = 预期数据量 / 0.75 + 1 |
| 转红黑树后偶发退化 | 链表长度在 6 到 8 之间震荡 | 这是设计内的防抖机制,属正常现象 |
| 使用 Stream 排序后顺序仍然不对 | 收集时用了默认 HashMap,未使用 LinkedHashMap | 在 Collectors.toMap 中指定 LinkedHashMap::new |
我在实际工作中还发现一个技巧:当你预估 HashMap 要存放大量数据时,别用默认容量。比如预估要放 1 万个数据,直接 new HashMap<>(10000) 其实会在元素数超过 7500 时触发一次扩容,而真正的操作是 new HashMap<>(10000 / 0.75 + 1),也就是 13434 左右,保证全程不扩容。很多人忽略这个点,存储大数据集时白白多了一次 O(n) 的扩容开销。
还有一个排查效率问题的经验——如果生产环境出现 HashMap 查询变慢,很可能不是代码逻辑问题,而是 key 的 hashCode 分布出了问题。你可以在本地写个小脚本,生成实际数据跑一遍,把每个桶的元素数量打印出来,看看有没有某个桶元素数量异常多。如果确实有,检查 key 对象的 hashCode 实现,十有八九是某个字段取值范围太窄,导致 hash 集中在少数几个值上。这种问题靠调大初始容量解决不了,得从源头调整 hashCode 的散列逻辑。
最后说一下项目实战里 HashMap 的选型建议。单线程环境用 HashMap,读多写少可以用 LinkedHashMap 保留插入顺序,需要线程安全就用 ConcurrentHashMap。HashTable 已经是历史遗留物了,它的所有方法都加了 synchronized 锁,性能远不如 ConcurrentHashMap,能不用就别用。排序需求用 TreeMap 或者 Stream 排序,千万别自己手写一个结构去维护顺序,完全没必要。
我在实际使用中最大的体会是,HashMap 这个类最精彩的不是某个单独的数据结构,而是数组、链表、红黑树、扰动函数、扩容优化这些设计彼此咬合、环环相扣。你单看扰动函数觉得不过是一行位运算,但放到整个 put 流程里,它直接影响冲突率;你还得结合扩容时的(e.hash & oldCap)判断,才能明白扰动后的 hash 在扩容时也起到了均衡分布的作用。所谓底层原理,就是这样一层一层勾连起来的,搞清楚一条线索,其他部分也就都通了。