JDK1.7 与 JDK1.8 HashMap 底层原理对比 + 数组并发扩容死循环详解

文章目录

一、JDK1.7 HashMap

1、底层结构

数组 + 单向链表

  • 数组(Entry \[\] table),数组中的每一个位置称为桶 bucket
  • hash 冲突时,新节点挂载到桶上形成单向链表
  • 节点:Entry<K,V>,包含 hash、key、value、next 指针

2、插入规则:头插法

新元素发生哈希冲突时,新节点放在链表头部

复制代码
原有链表:A→B→C
插入D之后:D→A→B→C

特点:新进来的节点先被访问;扩容迁移链表时,链表顺序会反转。

3、扩容机制

  1. 默认容量 16,负载因子 0.75,达到阈值(容量 * 0.75)触发扩容
  2. 新数组容量 = 原数组 ×2
  3. transfer()方法遍历旧数组所有链表,逐个节点迁移到新数组,头插迁移
  4. 迁移源码核心逻辑
java 复制代码
do {
    Entry<K,V> next = e.next; //保存下一个节点,局部变量快照
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];    //头插,指向新桶的首节点
    newTable[i] = e;
    e = next;                //跳到刚才保存的next节点继续循环
} while (e != null);

4、并发扩容死循环

产生死循环 3 个必要条件:

1)JDK7 头插法扩容迁移

2)多线程同时触发扩容

3)节点为堆共享对象,next 指针可以被多个线程修改

完整复现(原始链表)

假设原始链表如下, A→B→null

1.线程T1在当前栈中创建临时扩容数组newTable16

获取node1节点A和子节点B,此时 T1 被 CPU 挂起

2.线程T2生成新数组,头插反转链表,新链表变成 B→A→null ,此过程如下图

3.线程T1获取到时间片继续执行,

计算A节点hash和对应的新下标(假设为2)

头插法将A节点的next指针指向newTable中的ListNode2节点,此时是null

将ListNode2赋值为A节点,

然后开始执行子节点B,此过程如下图

4.线程T1获取B节点,同时去堆中获取B节点的子节点,此时由于线程2执行完了,导致此时获取到的子节点是A

计算B节点hash和对应的新下标(假设为2)

头插法将B节点的next指针指向newTable中的ListNode2节点,此时是A

将ListNode2赋值为B节点,

然后开始执行子节点A,此过程如下图

5.线程T1获取A节点,同时去堆中获取A节点的子节点,此时获取到的子节点是null

计算A节点hash和对应的新下标(同样为2)

头插法将A节点的next指针指向newTable中的ListNode2节点,此时是B

将ListNode2赋值为A节点,

由于子节点是null,结束循环,此时产生了循环链表,如下图

之后在进行查询和搜索时,就会发生死循环

二、JDK1.8 HashMap

1、底层结构

数组 + 单向链表 / 红黑树

节点类型改为Node<K,V>

  • 桶内节点数量<8:单向链表
  • 桶内节点数量≥8,且数组容量≥64:链表转为红黑树,查询时间复杂度 O (logn)
  • 节点减少到≤6,红黑树退化成链表

2、插入规则:尾插法

冲突节点追加到链表末尾,不会反转链表顺序

复制代码
原有链表:A→B→C
插入D之后:A→B→C→D

3、扩容机制

数组扩容逻辑移入resize()方法,不再有独立 transfer 方法

依然 2 倍扩容,迁移采用尾插

  • 原链表节点在新数组只有两种位置:原下标、原下标 + 旧容量
  • 迁移时保持链表原有先后顺序,不会反转链表

尾插为什么消除死循环?

即使线程中途暂停,恢复后遍历顺序不变,不会出现节点互相引用形成闭环。

⚠️JDK1.8 只是解决了环形链表死循环,HashMap 依然线程不安全,并发 put 依然存在数据覆盖、数据丢失问题,并发场景请使用 ConcurrentHashMap。

4、链表升级红黑树(树化)

4.1转换触发条件(关键阈值)

源码中定义了两个关键常量来控制转换:

java 复制代码
// 链表转红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;

// 红黑树退化回链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

// 允许树化的最小数组容量
static final int MIN_TREEIFY_CAPACITY = 64;

⚠️ 重要前提:双重检查

链表升级为红黑树必须同时满足以下两个条件:

  • 链表长度 ≥ 8 (TREEIFY_THRESHOLD)
  • 数组总容量 ≥ 64 (MIN_TREEIFY_CAPACITY)

为什么需要数组容量 ≥ 64?

如果数组本身很小(例如只有 16),此时出现长链表大概率是因为数组太小导致哈希分布不均,而不是哈希函数本身有问题。此时优先扩容(Resize)比转红黑树更能从根本上解决冲突问题。只有当数组已经足够大,某个桶依然堆积了大量节点时,才说明是真正的哈希冲突严重,此时转树才有意义。

4.2. 为什么选择 8 和 6?(泊松分布)

这两个阈值并非随意设定,而是基于统计学上的泊松分布:

  • 理想情况:在随机哈希码和合理负载因子下,桶中元素个数服从参数 λ=0.5 的泊松分布。
  • 防止抖动:设置 8 为升级阈值、6 为降级阈值,中间留出了 2 的缓冲带。这避免了在临界值附近频繁进行"链表⇄红黑树"的转换开销。
4.3.性能对比总结
  1. 冲突少、链表很短时:两者性能差距不大,JDK1.7 链表内存开销略小;
  2. 冲突严重、链表很长:JDK1.8 优势巨大,红黑树把查询从 O (n) 优化到 O (logn);
  3. 并发场景最大区别 :JDK1.7 并发扩容会出现环形链表死循环;JDK1.8 解决死循环,但依旧不是线程安全
  4. 代价:JDK1.8 红黑树节点占用更多内存,并且树需要左旋、右旋、变色维护平衡;所以只有链表足够长才启用红黑树;
  5. 核心优化两点:尾插法解决死循环;红黑树优化长链表查询性能。
相关推荐
geovindu1 小时前
CSharp: 万年历
开发语言·后端·c#·.net
我找到地球的支点啦1 小时前
Matlab系列(009) 一CRC循环冗余校验详解
开发语言·数据结构·算法·matlab·信息与通信
予昊1 小时前
从零实现“在线五子棋对战“:WebSocket 实时通信 + 段位匹配
java·开发语言·网络·websocket
2601_962203511 小时前
【SpringAI入门】初识SpringAI
java
weixin_461408582 小时前
Mybatis-flex小记
java·开发语言·mybatis
2601_962177132 小时前
【MySQL篇】聚合查询,联合查询
android·java·mysql
それども2 小时前
IDEA接入Claude完整流程
java·ai·intellij-idea
小鹿的周先生3 小时前
Spring-AI-第2篇-ChatClient 实战:使用 DeepSeek 完成第一次 AI 对话
java·人工智能·spring
青梅味猪大肠3 小时前
【深入浅出C++】为什么虚表指针可以解决菱形继承
开发语言·c++