Skip to content

HashMap 源码设计

HashMap 是理解 Java 集合实现的核心入口。学习它时不要只记“数组 + 链表 + 红黑树”,更重要的是把 put 流程、扩容规则、哈希扰动、红黑树化、线程不安全原因,以及它和 ConcurrentHashMap 的差异串起来。


1. 底层结构

JDK 1.8 之后,HashMap 的底层结构是:

text
数组 + 链表 + 红黑树

核心数组叫 table,数组中的每个位置叫一个桶。发生哈希冲突时,多个节点会挂在同一个桶下:

text
table[i] -> Node -> Node -> Node

当链表过长时,会转成红黑树:

text
table[i] -> TreeNode

这样可以把极端冲突下的查询复杂度从 O(n) 降到 O(log n)


2. 核心字段

常见字段含义:

字段含义
table底层数组
size当前键值对数量
threshold下一次扩容阈值
loadFactor负载因子,默认 0.75
modCount修改次数,用于 fail-fast

扩容阈值通常是:

text
threshold = capacity * loadFactor

默认负载因子是 0.75,这是时间和空间之间的折中:负载因子太小浪费空间,太大冲突变多。


3. 为什么容量必须是 2 的幂

HashMap 定位桶下标时不是直接取模,而是:

java
(n - 1) & hash

n 是 2 的幂时,n - 1 的二进制低位全是 1,与运算等价于取模:

text
hash % n == (n - 1) & hash

这样做有两个好处:

  • 位运算比取模更快。
  • 扩容时可以利用二进制新增位快速判断节点新位置。

如果容量不是 2 的幂,(n - 1) & hash 的分布会不均匀,冲突会变多。


4. hash() 扰动函数

JDK 1.8 的 HashMap 会对 key 的 hashCode() 做一次扰动:

java
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这样做是为了让高 16 位也参与到低位计算中。

因为桶下标计算只使用低位:

java
(n - 1) & hash

如果很多 key 的低位相同,即使高位不同,也会落到同一个桶。高低位异或后,可以降低冲突概率。


5. put 流程

简化流程:

text
put(key, value)
  -> 计算 hash
  -> 如果 table 为空,先初始化
  -> 根据 (n - 1) & hash 定位桶
  -> 桶为空:直接插入新节点
  -> 桶不为空:
       -> key 相同:覆盖 value
       -> 桶是链表:尾插法插入
       -> 桶是红黑树:按红黑树规则插入
  -> 如果链表长度超过阈值,尝试树化
  -> size + 1
  -> 如果 size 超过 threshold,扩容

注意:JDK 1.8 链表插入使用尾插法,JDK 1.7 使用头插法。JDK 1.7 在并发扩容时可能出现链表成环问题。


6. 扩容机制

当元素数量超过阈值时触发扩容。扩容容量通常变成原来的 2 倍:

text
newCap = oldCap << 1

扩容后,旧桶中的节点只会去两个位置之一:

text
原下标 i
i + oldCap

判断依据是:

java
hash & oldCap

如果结果为 0,节点仍在 i;如果结果不为 0,节点移动到 i + oldCap

原因是容量翻倍后,参与下标计算的二进制位多了一位。只需要判断这新增的一位是 0 还是 1


7. 链表转红黑树

链表转红黑树需要同时满足:

text
链表长度 >= 8
数组长度 >= 64

如果链表长度达到 8,但数组长度还小于 64,HashMap 优先选择扩容,而不是树化。

这是因为数组较小时,冲突很可能是容量不足导致的,扩容能更有效地降低冲突。

红黑树退化回链表的阈值是 6。使用 86 两个不同阈值,是为了避免链表和红黑树之间频繁转换。


8. 为什么线程不安全

HashMap 没有任何同步控制,多线程同时写入时可能出现:

  • 数据覆盖。
  • size 统计不准确。
  • 扩容过程中数据丢失。
  • JDK 1.7 并发扩容时链表可能成环。
  • 迭代时并发修改会触发 ConcurrentModificationException

所以多线程写场景应该使用 ConcurrentHashMap,或者在外部做同步控制。


9. HashMapLinkedHashMapTreeMapConcurrentHashMap

容器底层结构是否有序是否线程安全典型场景
HashMap数组 + 链表 + 红黑树无序普通 key-value 存储
LinkedHashMapHashMap + 双向链表插入顺序或访问顺序LRU、顺序遍历
TreeMap红黑树按 key 排序范围查询、排序
ConcurrentHashMap数组 + 链表 + 红黑树无序并发读写

10. 理解检查

为什么重写 equals 必须重写 hashCode

因为 HashMap 先根据 hashCode 定位桶,再用 equals 判断 key 是否相等。如果两个对象 equals 相等但 hashCode 不同,它们可能落到不同桶,导致逻辑上相同的 key 被存成多份。

为什么负载因子默认是 0.75

这是空间利用率和查询效率的折中。负载因子越大,空间越省,但冲突越多;负载因子越小,冲突越少,但数组空间浪费越多。

为什么链表长度到 8 才树化?

哈希分布正常时,链表很少变长。长度达到 8 已经是低概率事件,说明冲突比较严重。树化可以降低极端情况下的查询复杂度。

为什么数组长度小于 64 时不树化?

数组太小时,冲突通常来自容量不足。扩容比树化更划算。


11. 总结

HashMap 的核心是:用数组定位桶,用链表或红黑树处理冲突,用 2 的幂容量和扰动函数优化哈希分布,用扩容降低冲突概率。它追求的是单线程场景下的高性能,不提供并发安全。

Released under the MIT License.