HashMap 核心原理

一、HashMap 底层结构(JDK 1.8+)

HashMap 的底层结构是数组 + 链表 + 红黑树,核心设计如下:

  1. 哈希桶数组(Node [] table) :

    • 每个数组元素称为一个哈希桶(bucket) ,存储链表或红黑树的头节点。
    • 数组长度始终为 2 的幂(如 16、32),通过(n - 1) & hash快速定位索引。
  2. 链表节点(Node<K,V>) :

    • 当发生哈希冲突时,新元素会以链表形式连接到桶中。
    • JDK 1.8+ 使用尾插法(避免扩容时的环形链表问题)。
  3. 红黑树节点(TreeNode<K,V>) :

    • 当链表长度≥8  数组长度≥64 时,链表转换为红黑树(treeifyBin方法)。
    • 选择 8 的依据:理想情况下链表节点数符合泊松分布,长度为 8 的概率仅为 0.00000006,此时转为树结构更高效。
    • 红黑树退化为链表的条件:节点数≤6(untreeify方法),避免频繁转换。
    • 选择64的原因是:时间和空间的平衡
      • 避免空间浪费:在小规模数组中,链表即使较长也不会树化,减少红黑树的空间开销。
      • 利用扩容优化冲突:优先扩容可以分散链表节点,降低树化的必要性。
      • 极端情况的容错:仅在数组足够大且冲突异常严重时才树化,确保最坏情况下的查询效率(O (logn))。
二、HashMap 的 put 过程

HashMap 的 put 过程通过 “哈希扰动索引计算冲突处理树化扩容” 的流程。

  1. 哈希值计算与扰动处理
    首先对 key 计算哈希值,通过 key.hashCode() 获取原始哈希,再将高 16 位与低 16 位异或((h = key.hashCode()) ^ (h >>> 16)),目的是让高低位特征混合,减少哈希冲突。

  2. 数组初始化与索引定位

    • 若数组(table)未初始化,首次插入时会触发 resize() 初始化(默认容量 16,阈值 12)。
    • 通过 (数组长度 - 1) & 哈希值 计算索引,例如数组长度 16 时,(15 & hash) 等效于 hash % 16,但位运算效率更高。
  3. 节点插入逻辑

    • 空桶直接插入:若目标桶(tab[i])为空,直接创建新节点存入。

    • 桶非空时的处理

      • 若桶中第一个节点的 key 与插入 key 相同(hash 相等且 equals 为 true),直接覆盖 value。

      • 若桶中节点是红黑树节点(TreeNode),调用红黑树的插入方法(putTreeVal)。

      • 若为普通链表,遍历链表查找相同 key:

        • 找到则覆盖 value;未找到则在链表尾部插入新节点(JDK 1.8 尾插法,避免环形链表)。
  4. 链表树化条件
    插入后若链表长度 ≥ 8 且数组长度 ≥ 64,触发 treeifyBin 方法将链表转为红黑树,查询效率从 O (n) 提升至 O (logn)。若数组长度 < 64,优先触发扩容而非树化。

  5. 扩容机制
    插入后若元素总数超过阈值(容量 × 负载因子,默认 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; }

核心步骤解析

  1. 哈希计算

java

体验AI代码助手

代码解读

复制代码

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

将 key 的 hashCode 高 16 位与低 16 位异或(扰动函数),减少低位冲突概率。

  1. 索引定位

    • 通过(n - 1) & hash计算数组索引(等效于hash % n,但位运算效率更高)。
  2. 树化条件

    • 链表长度≥8  数组长度≥64 时,链表转为红黑树。
    • 若数组长度<64,优先触发扩容(resize())而非树化。
  3. 扩容机制

    • 扩容阈值threshold = capacity × loadFactor(默认 16×0.75=12)。
    • 扩容时容量翻倍(如 16→32),重新计算所有元素的位置(无需重新 hash,利用hash & oldCap判断索引是否变化)。
三、HashMap 线程不安全的本质
  1. 数据覆盖(JDK 1.7/1.8 均存在) :

    • 多线程并发执行put时,若两个线程计算出相同索引,可能导致其中一个线程的数据被覆盖。

java

体验AI代码助手

代码解读

复制代码

if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 非原子操作,可能被覆盖

  1. JDK 1.7 的环形链表问题

    • 扩容时采用头插法(transfer方法),多线程并发扩容可能形成环形链表,导致后续查询死循环。
  2. JDK 1.8 的数据不一致

    • 虽然改用尾插法避免环形链表,但多线程下size计算可能不准确(如线程 A 判断无需扩容,线程 B 插入后触发扩容,导致数据丢失)。
四、延伸问题:对比 ConcurrentHashMap
  1. JDK 1.7 的分段锁机制

    • 将数据分为多个段(Segment),每个段独立加锁,并发度为 Segment 数量(默认 16)。
  2. 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 }

  1. 红黑树优化

    • 与 HashMap 类似,但树化操作通过synchronized保证线程安全。
五、困惑解答
  1. 为什么高 16 位异或低 16 位?
    数组长度通常较小(如 16),仅用低 16 位计算索引会导致高 16 位特征被忽略,异或操作让高低位特征混合,减少哈希冲突(例如 hashCode 高 16 位全 0 时,扰动后仍有高位特征)。
  2. 树化为何需要数组长度≥64?
    红黑树节点空间开销是链表的 2-3 倍,数组较小时优先扩容(分散节点),避免小数据量下树化的空间浪费。源码中 treeifyBin 方法会先检查数组长度,不足 64 则触发扩容。
  3. 扩容时如何重新定位元素?
    扩容后数组长度为原 2 倍,元素新索引 = 原索引 或 原索引 + 原容量,通过 hash & 原容量 判断:若结果为 0,索引不变;否则索引 + 原容量(利用二进制低位特征)。
六、总结
  1. 选择标准

    • 单线程场景:优先使用 HashMap。
    • 多线程场景:使用 ConcurrentHashMap,避免 HashMap。
  2. 性能优化

    • 预估初始容量(如new HashMap<>(1000)),减少扩容次数。
    • 自定义 key 类时重写hashCode()equals()方法,降低哈希冲突。
  3. 注意事项

    • 避免在多线程环境下对 HashMap 进行 put 操作,否则需外部同步(如synchronizedMap)。
    • 理解红黑树与链表的转换条件,避免频繁树化 / 退化影响性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值