文章目录
-
- [一、JDK1.7 HashMap](#一、JDK1.7 HashMap)
- [二、JDK1.8 HashMap](#二、JDK1.8 HashMap)
-
- 1、底层结构
- 2、插入规则:尾插法
- 3、扩容机制
- 4、链表升级红黑树(树化)
-
-
- 4.1转换触发条件(关键阈值)
- [4.2. 为什么选择 8 和 6?(泊松分布)](#4.2. 为什么选择 8 和 6?(泊松分布))
- 4.3.性能对比总结
-
一、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、扩容机制
- 默认容量 16,负载因子 0.75,达到阈值(容量 * 0.75)触发扩容
- 新数组容量 = 原数组 ×2
transfer()方法遍历旧数组所有链表,逐个节点迁移到新数组,头插迁移- 迁移源码核心逻辑
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.性能对比总结
- 冲突少、链表很短时:两者性能差距不大,JDK1.7 链表内存开销略小;
- 冲突严重、链表很长:JDK1.8 优势巨大,红黑树把查询从 O (n) 优化到 O (logn);
- 并发场景最大区别 :JDK1.7 并发扩容会出现环形链表死循环;JDK1.8 解决死循环,但依旧不是线程安全;
- 代价:JDK1.8 红黑树节点占用更多内存,并且树需要左旋、右旋、变色维护平衡;所以只有链表足够长才启用红黑树;
- 核心优化两点:尾插法解决死循环;红黑树优化长链表查询性能。