Home
avatar

.𝙃𝙤𝙬𝙚

向下滚动

HashMap 底层原理全解|从数组扩容到红黑树化

HashMap 是 Java 日常开发中使用频率最高的集合类之一,也是面试中的绝对高频考点。本文围绕 JDK 8 中 HashMap 的底层结构、hash 寻址、扩容机制、红黑树化条件以及 JDK 7 到 JDK 8 的演进展开,覆盖从 put 到 get 的完整链路。

底层三件套:数组 + 链表 + 红黑树

JDK 8 的 HashMap 底层由一个 Node[] table 数组构成,数组的每个位置称为一个“桶”(bucket)。根据哈希冲突的程度,桶内可能以不同结构存储数据:

  • 单节点:没有冲突时,桶里只存放一个 Node(key-value 对)。
  • 链表:多个 key 的 hash 定位到同一个桶时,它们串成一条链表。
  • 红黑树:当某条链表的节点数达到 8,且数组长度 ≥ 64 时,这条链表会升级为红黑树,查询复杂度从 O(n) 降为 O(log n)。

反过来,当红黑树节点数被删到 ≤ 6 时,会退化为链表。6 和 8 之间留出 7 作为缓冲,防止在边界附近频繁转换造成性能抖动。

注意:链表长度达到 8 时并不会立即转树,还要满足数组长度 ≥ 64 的条件;若不满足,HashMap 会优先扩容。

put 时如何定位数组下标

定位下标分为两步:

① 扰动:

int h = key.hashCode();
int hash = h ^ (h >>> 16);

把 hashCode 的高 16 位与低 16 位做异或,让高位的特征也参与后续的取下标运算,减少碰撞。

② 按位与取下标:

int index = (n - 1) & hash;

其中 n 是数组长度。因为数组长度总是 2 的幂,n-1 的二进制低位全是 1,& 运算等价于 hash % n,但位运算更快。

哈希冲突无法完全避免:hashCode 有 2³² 种可能,而数组下标只有 n 个,必然存在多个 key 落入同一个桶的情况。

为什么数组长度必须是 2 的幂

有两个核心原因:

原因一:位运算等价于取余

只有当 n 是 2 的幂时,(n - 1) & hash 才等价于 hash % n,结果均匀分布在 0 ~ n-1 之间。如果 n 不是 2 的幂,n-1 的二进制位中会出现 0,导致某些桶永远为空,分布严重不均。

原因二:扩容时定位成本极低

扩容时数组翻倍(16 → 32 → 64),每个元素的新下标只可能有两种情况:

  • 新增的那一位二进制是 0 → 留在原位置
  • 新增的那一位是 1 → 移到“原位置 + 旧容量”

也就是说,扩容时不需要对所有元素重新计算完整哈希,只需判断一个 bit 是 0 还是 1,JDK 8 利用这一点实现了高效的“高低位拆分”扩容。

扩容触发条件与过程

  • 默认初始容量:16
  • 默认负载因子:0.75
  • 触发扩容阈值:元素总数 ≥ 容量 × 负载因子
    • 16 × 0.75 = 12,即元素达到 12 时触发第一次扩容
    • 32 × 0.75 = 24 时触发第二次
    • 以此类推

扩容时,数组长度翻倍(16 → 32),所有元素重新散列到新数组中(rehash)。这是一个 O(n) 的昂贵操作,但负载因子 0.75 在“内存浪费”和“查询效率”之间做了合理折中。

链表 ↔ 红黑树互转条件

转换方向触发条件
链表 → 红黑树链表长度 ≥ 8 且 数组长度 ≥ 64
红黑树 → 链表树中节点数 ≤ 6

阈值 8 和 6 之间的缓冲(7)是为了避免在临界值附近反复转换,浪费性能。

get 流程与 equals 的作用

get 方法的完整链路如下:

  1. 调用 key.hashCode() 获取哈希值
  2. 扰动:hash = h ^ (h >>> 16)
  3. 定位桶:index = (n - 1) & hash
  4. 进入对应桶(链表或红黑树)
  5. 逐个调用 equals() 比对,找到匹配的 key 则返回对应 value
  6. 遍历完未找到则返回 null

这里有两个分工明确的机制:

  • hashCode 负责定位桶:把数据均匀分散到不同格子
  • equals 负责桶内精确定位:在同一个桶中区分不同的 key

关键约束:equals 相同的两个对象,hashCode 必须相同。否则相同的 key 会被分到不同桶,导致 get 永远找不到。这也是为什么 HashMap 的 key 不应该使用可变字段——一旦 hashCode 变了,之前存入的数据就再也定位不到了。

JDK 1.7 → 1.8 的变化

维度JDK 1.7JDK 1.8原因
底层结构数组 + 链表数组 + 链表 + 红黑树极端情况下查询从 O(n) 降到 O(log n),防哈希攻击
插入方式头插法尾插法头插法在并发扩容时会产生“自环”,导致死循环 CPU 100%
扩容重散全量重排高低位拆分,仅判断新增 bit更高效,利用 2 的幂特性
红黑树化无链长 ≥ 8 且数组 ≥ 64防止链表过长导致性能退化

需要明确的是:无论是 1.7 还是 1.8,HashMap 都不是线程安全的。1.8 只是消除了“死循环”这个极端 bug,但多线程并发 put 仍然会出现数据覆盖、结构错乱等问题。真正需要并发场景时,应使用 ConcurrentHashMap。

总结

理解 HashMap 的关键在于理清几个层次:

  1. 定位:hashCode 决定进哪个桶,扰动函数保证均匀分布,(n-1)&hash 利用 2 的幂特性实现快速取余
  2. 桶内存储:链表处理普通冲突,红黑树兜底极端情况
  3. 扩容:负载因子控制全局密度,翻倍扩容配合高低位拆分保证效率
  4. 查找:hashCode 定桶,equals 定值,二者缺一不可
  5. 演进:1.8 引入红黑树和尾插法,改善了极端性能和并发安全性(但仍是线程不安全)
Java 集合框架 HashMap 红黑树 面试题