一、HashMap 底层结构(JDK 1.8+)
HashMap 的底层结构是数组 + 链表 + 红黑树,核心设计如下:
-
哈希桶数组(Node [] table) :
- 每个数组元素称为一个哈希桶(bucket) ,存储链表或红黑树的头节点。
- 数组长度始终为 2 的幂(如 16、32),通过
(n - 1) & hash
快速定位索引。
-
链表节点(Node<K,V>) :
- 当发生哈希冲突时,新元素会以链表形式连接到桶中。
- JDK 1.8+ 使用尾插法(避免扩容时的环形链表问题)。
-
红黑树节点(TreeNode<K,V>) :
- 当链表长度≥8 且 数组长度≥64 时,链表转换为红黑树(
treeifyBin
方法)。 - 选择 8 的依据:理想情况下链表节点数符合
泊松分布
,长度为 8 的概率仅为 0.00000006,此时转为树结构更高效。 - 红黑树退化为链表的条件:节点数≤6(
untreeify
方法),避免频繁转换。 - 选择64的原因是:时间和空间的平衡
- 避免空间浪费:在小规模数组中,链表即使较长也不会树化,减少红黑树的空间开销。
- 利用扩容优化冲突:优先扩容可以分散链表节点,降低树化的必要性。
- 极端情况的容错:仅在数组足够大且冲突异常严重时才树化,确保最坏情况下的查询效率(O (logn))。
- 当链表长度≥8 且 数组长度≥64 时,链表转换为红黑树(
二、HashMap 的 put 过程
HashMap 的 put
过程通过 “哈希扰动
→索引计算
→冲突处理
→树化扩容
” 的流程。
-
哈希值计算与扰动处理
首先对 key 计算哈希值,通过key.hashCode()
获取原始哈希,再将高 16 位与低 16 位异或((h = key.hashCode()) ^ (h >>> 16)
),目的是让高低位特征混合,减少哈希冲突。 -
数组初始化与索引定位
- 若数组(
table
)未初始化,首次插入时会触发resize()
初始化(默认容量 16,阈值 12)。 - 通过
(数组长度 - 1) & 哈希值
计算索引,例如数组长度 16 时,(15 & hash)
等效于hash % 16
,但位运算效率更高。
- 若数组(
-
节点插入逻辑
-
空桶直接插入:若目标桶(
tab[i]
)为空,直接创建新节点存入。 -
桶非空时的处理:
-
若桶中第一个节点的 key 与插入 key 相同(
hash 相等且 equals 为 true
),直接覆盖 value。 -
若桶中节点是红黑树节点(
TreeNode
),调用红黑树的插入方法(putTreeVal
)。 -
若为普通链表,遍历链表查找相同 key:
- 找到则覆盖 value;未找到则在链表尾部插入新节点(JDK 1.8 尾插法,避免环形链表)。
-
-
-
链表树化条件
插入后若链表长度 ≥ 8 且数组长度 ≥ 64,触发treeifyBin
方法将链表转为红黑树,查询效率从 O (n) 提升至 O (logn)。若数组长度 < 64,优先触发扩容而非树化。 -
扩容机制
插入后若元素总数超过阈值(容量 × 负载因子,默认 0.75),触发resize()
扩容:- 容量翻倍(如 16 → 32),阈值同步翻倍(12 → 24)。
- 重新计算所有元素的位置(利用
hash & 原容量
判断索引是否变化,无需重新哈希)。
以下为put过程源码:
java
体验AI代码助手
代码解读
复制代码
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 1. 数组为空时初始化(懒加载) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 2. 计算索引,若桶为空直接插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 3. 桶中第一个节点key相同,直接覆盖 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 4. 若为红黑树节点,调用树的插入方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 5. 遍历链表 else { for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); // 尾插法 if (binCount >= TREEIFY_THRESHOLD - 1) // 链表长度≥8 treeifyBin(tab, hash); // 可能转为红黑树(需数组长度≥64) break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 找到相同key,跳出循环 p = e; } } // 6. 若key已存在,根据onlyIfAbsent决定是否覆盖 if (e != null) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); // LinkedHashMap回调 return oldValue; } } ++modCount; // 7. 超过阈值则扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); // LinkedHashMap回调 return null; }
核心步骤解析:
- 哈希计算:
java
体验AI代码助手
代码解读
复制代码
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
将 key 的 hashCode 高 16 位与低 16 位异或(扰动函数),减少低位冲突概率。
-
索引定位:
- 通过
(n - 1) & hash
计算数组索引(等效于hash % n
,但位运算效率更高)。
- 通过
-
树化条件:
- 链表长度≥8 且 数组长度≥64 时,链表转为红黑树。
- 若数组长度<64,优先触发扩容(
resize()
)而非树化。
-
扩容机制:
- 扩容阈值
threshold = capacity × loadFactor
(默认 16×0.75=12)。 - 扩容时容量翻倍(如 16→32),重新计算所有元素的位置(无需重新 hash,利用
hash & oldCap
判断索引是否变化)。
- 扩容阈值
三、HashMap 线程不安全的本质
-
数据覆盖(JDK 1.7/1.8 均存在) :
- 多线程并发执行
put
时,若两个线程计算出相同索引,可能导致其中一个线程的数据被覆盖。
- 多线程并发执行
java
体验AI代码助手
代码解读
复制代码
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 非原子操作,可能被覆盖
-
JDK 1.7 的环形链表问题:
- 扩容时采用头插法(
transfer
方法),多线程并发扩容可能形成环形链表,导致后续查询死循环。
- 扩容时采用头插法(
-
JDK 1.8 的数据不一致:
- 虽然改用尾插法避免环形链表,但多线程下
size
计算可能不准确(如线程 A 判断无需扩容,线程 B 插入后触发扩容,导致数据丢失)。
- 虽然改用尾插法避免环形链表,但多线程下
四、延伸问题:对比 ConcurrentHashMap
-
JDK 1.7 的分段锁机制:
- 将数据分为多个段(Segment),每个段独立加锁,并发度为 Segment 数量(默认 16)。
-
JDK 1.8 的 CAS + synchronized:
-
取消分段锁,直接对数组节点(
Node
)加锁,锁粒度更小。 -
插入时使用 CAS(Compare-And-Swap)操作保证原子性:
-
java
体验AI代码助手
代码解读
复制代码
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; // no lock when adding to empty bin }
-
红黑树优化:
- 与 HashMap 类似,但树化操作通过
synchronized
保证线程安全。
- 与 HashMap 类似,但树化操作通过
五、困惑解答
- 为什么高 16 位异或低 16 位?
数组长度通常较小(如 16),仅用低 16 位计算索引会导致高 16 位特征被忽略,异或操作让高低位特征混合,减少哈希冲突(例如hashCode
高 16 位全 0 时,扰动后仍有高位特征)。 - 树化为何需要数组长度≥64?
红黑树节点空间开销是链表的 2-3 倍,数组较小时优先扩容(分散节点),避免小数据量下树化的空间浪费。源码中treeifyBin
方法会先检查数组长度,不足 64 则触发扩容。 - 扩容时如何重新定位元素?
扩容后数组长度为原 2 倍,元素新索引 = 原索引 或 原索引 + 原容量,通过hash & 原容量
判断:若结果为 0,索引不变;否则索引 + 原容量(利用二进制低位特征)。
六、总结
-
选择标准:
- 单线程场景:优先使用 HashMap。
- 多线程场景:使用 ConcurrentHashMap,避免 HashMap。
-
性能优化:
- 预估初始容量(如
new HashMap<>(1000)
),减少扩容次数。 - 自定义 key 类时重写
hashCode()
和equals()
方法,降低哈希冲突。
- 预估初始容量(如
-
注意事项:
- 避免在多线程环境下对 HashMap 进行 put 操作,否则需外部同步(如
synchronizedMap
)。 - 理解红黑树与链表的转换条件,避免频繁树化 / 退化影响性能。
- 避免在多线程环境下对 HashMap 进行 put 操作,否则需外部同步(如