HashMap1.7 扩容时产生死链

本文详细剖析HashMap 1.7的扩容机制,解释为何扩容可能导致死链,并通过实际场景演示其产生过程。理解头插法如何引发并发问题,以及如何在并发编程中避免这一问题。

目录

HashMap1.7 扩容机制

扩容时产生死链

原因

产生过程


HashMap1.7 扩容机制

HashMap1.7 扩容机制:当 put 一个新键值对时;发生了哈希碰撞(hash值相同)并且加上新键值对后,键值对总数达到了阈值(容量 * 0.75)

① 如:容量为 16 的HashMap,每个位置都放了一个键值对;它并不会扩容,因为没有发生哈希碰撞

② 提问:容量为 16 的HashMap,最多可以放多少个键值对?

答:26个

在 0 号位置先加入 11 个键值对(其他位置都可以),然后将剩余空位置填满;结果为 11+15=26;假如再 put 一个新键值对,这时才会发生哈希冲突并且总数达到阈值。

扩容时产生死链

原因

        ① HashMap1.7 添加元素为头插法

        ② HashMap 线程不安全,在并发情况下触发了扩容

产生过程

场景:假设有一个已经达到阈值并且再添加一个新键值对就会发生哈希碰撞(触发扩容)的 HashMap,现在有两个线程往里添加新键值对,此时可能会出现死链

如图:

 因为 1.7 为头插法;所以下标为 1 的位置,顺序与插入顺序相反。

假设此时:

        Thread0 插入 key 的 hash 为 21 的键值对(扩容前在 5,扩容后会放到位置 21)

        Thread1 插入 key 的 hash 为 22 的键值对(扩容前在 6,扩容后会放到位置 22)

两个线程的操作都会使 HashMap 扩容,并且假设 HashMap 在 Thread0 时扩容完成。 

此时 1,33 还是会在原来的下标位置,转移到新数组执行的操作为:

 

        第一次循环:

        e                1

        next           33

        e.next        null

        newTable[1]         1 -> null

        第二次循环

        e                33

        next           null

        e.next        1

        newTable[1]         33 -> 1 -> null

Thread0: 33  -> 1 -> null

假设此时 Thread1 已经获取到 e 与 next 的值 并且刚开始迁移

        第一次循环(e 与 next 已经发生改变):

        e                1 -> null

        next           33 -> 1 -> null

        e.next        null

        newTable[1]         1 -> null

        第二次循环

        e                33

        next           1 

        e.next        1

        newTable[1]         33 -> 1 -> null

        第三次循环

        e                 1

        next            null

        e.next         33 -> 1 -> null

        newTable[1]        1 -> 33 -> 1 -> null

由此产生了死链

<think>我们正在讨论Java HashMap 1.7版本扩容过程的详细解释。注意,用户要求详细解释扩容过程,并且我们有一些引用材料可以参考。 引用[1][2][3][4][5]都涉及到HashMap 1.7扩容,特别是引用[3][4][5]详细描述了扩容过程以及多线程下产生循环的问题。 根据引用材料,我们可以总结扩容过程如下: 1. 触发条件:当元素数量达到阈值(threshold)并且当前桶中已经有元素(即发生哈希碰撞),进行扩容(引用[2])。 2. 扩容操作:创建一个新的Entry数组,大小为原数组的2倍,然后将原有Entry数组中的元素重新计算位置(重新哈希)并转移到新数组中(引用[3][4])。 3. 转移过程:使用transfer方法,遍历原数组的每个桶(即每个表),对每个表进行遍历,将每个节点重新计算在新数组中的位置(引用[3][4])。 4. 重新哈希:计算每个节点在新数组中的索引(indexFor方法),然后使用头插法将节点插入新数组的对应位置(引用[3][5])。 关键点: - 头插法:在转移表节点,采用头插法。即每次将节点插入到新数组对应桶的头部。这会导致表顺序反转(引用[5])。 - 多线程问题:头插法在并发扩容可能导致环形表,从而在后续操作造成循环(引用[3][4][5])。 详细步骤(单线程下): 1. 根据原数组长度计算新数组长度(原数组长度*2)。 2. 创建新数组。 3. 遍历原数组的每个桶(每个表): for (Entry<K,V> e : table) { while (e != null) { // 步骤a:记录下一个节点 Entry<K,V> next = e.next; // 如果需要重新哈希,则重新计算哈希值(rehash默认为false,一般不需要) if (rehash) { e.hash = (e.key == null) ? 0 : hash(e.key); } // 步骤b:重新计算该节点在新数组中的桶下标(索引) int i = indexFor(e.hash, newCapacity); // 步骤c:头插法 e.next = newTable[i]; // 将当前节点的next指向新数组中该桶的第一个节点(可能为null) newTable[i] = e; // 将新数组中该桶的头节点设为当前节点 e = next; // 处理下一个节点 } } 注意:头插法会导致表顺序反转。例如,原表为A->B->C,转移后在新数组中变为C->B->A。 多线程环境下为什么会产生环形表? 假设有两个线程同进行扩容,原表为:A->B->C(指向null)。 线程1执行到`Entry<K,V> next = e.next;`后挂起,此线程1记录e=A, next=B。 线程2执行完整个转移过程,新数组中该桶的表变为C->B->A(即头插法反转)。 然后线程1继续执行,此: e=A, next=B(线程1记录的) 重新计算A在新数组中的位置(假设仍在同一位置),然后执行: e.next = newTable[i]; // 此newTable[i]是线程2已经转移后的头节点(即C,但是注意线程2已经反转了表,所以原表A->B->C变为C->B->A,而线程1执行,线程2已经将新数组的该桶设置为C,并且C->B->A) 因此A.next指向了C。 然后newTable[i]=A,这样新数组中该桶的头节点变为A,而A.next=C,而C后面连接的是B->A(因为线程2的结果是C->B->A,而线程1又将A插入到头部,所以形成了环?) 具体形成环的过程(详细): 初始状态:表为 A -> B -> C -> null 线程2成功扩容后,新数组中的表为 C -> B -> A -> null(因为头插法反转) 线程1继续执行(因为线程1已经记录了e=A, next=B): 第一步:处理节点A e = A, next = B 重新计算A的索引(假设和C在同一桶) e.next = newTable[i] -> 此newTable[i]是C(因为线程2已经将C放在该桶的头节点),所以A.next = C newTable[i] = A -> 现在该桶的头节点变成了A,而A指向C,而C在之前(线程2处理的结果)指向B,B指向A(因为线程2处理完后,B.next=A,而线程1将A.next改为指向C,但实际上线程2的B.next=A,所以现在有:A->C->B->A,形成一个环) 因此,当后续对该桶进行遍历,就会陷入循环。 注意:引用[3][4][5]都详细描述了这个问题。 现在我们根据用户的请求,详细解释扩容过程(重点在单线程下的正常扩容过程,并说明多线程下问题的原因)。 回答结构: 1. 触发扩容的条件 2. 扩容的步骤(重点在transfer方法) 3. 头插法的特点及其影响(反转表) 4. 多线程下产生问题的原因(简要描述) 注意:用户要求详细解释扩容过程,所以重点放在步骤上。 最后,生成相关问题。 </think>### Java HashMap 1.7 扩容过程详解 #### 1. **触发条件** 当元素数量达到阈值且目标桶非空触发扩容: ```java if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); // 扩容为原表2倍[^2] hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); ``` - **阈值计算**:$threshold = capacity \times loadFactor$(负载因子默认 0.75) - **扩容幅度**:新容量 = 旧容量 × 2 --- #### 2. **核心步骤:`resize()` 与 `transfer()` **(1) 创建新数组** ```java void resize(int newCapacity) { Entry[] newTable = new Entry[newCapacity]; transfer(newTable, rehash); // 数据迁移 table = newTable; // 替换原数组 threshold = (int)(newCapacity * loadFactor); // 更新阈值 } ``` **(2) `transfer()` 数据迁移** ```java void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { // 遍历旧数组每个桶 while (null != e) { // 遍历桶内表 Entry<K,V> next = e.next; // 保存下一节点 if (rehash) { e.hash = (null == e.key) ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 新桶下标 e.next = newTable[i]; // 头插法 newTable[i] = e; // 更新桶头节点 e = next; // 处理下一节点 } } } ``` --- #### 3. **关键操作解析** 1. **头插法(表反转)** - 操作顺序:`e.next = newTable[i]` → `newTable[i] = e` - 效果:原表 `A→B→C` 迁移后变为 `C→B→A`[^5] - 示例: ```mermaid graph LR 旧桶-->|A→B→C| 迁移 迁移-->|步骤1| e.next=null 迁移-->|步骤2| newTable[i]=A 迁移-->|步骤3| e=B; e.next=A --> newTable[i]=B --> C=B.next 迁移-->|结果| C→B→A ``` 2. **索引重定位** - 计算新下标:$i = hash \& (newCapacity-1)$ - 扩容后节点可能分配到新位置: $$ newIndex = oldIndex \quad or \quad oldIndex + oldCapacity $$ --- #### 4. **多线程锁问题(环形表)** 并发扩容,头插法+表截断会导致环形表: 1. **初始状态**:桶表为 `A → B → null` 2. **线程1挂起**:执行 `next = e.next` 后记录 `e=A, next=B` 3. **线程2完成扩容**:新表变为 `B → A → null` 4. **线程1继续执行**: ```java e.next = newTable[i]; // A.next = B(此B.next已指向A) newTable[i] = e; // 桶头变为A e = next; // e=B ``` 结果形成环形表 `A → B → A`[^3][^4],后续操作陷入循环。 --- #### 5. **与JDK 1.8的区别** | 特性 | JDK 1.7 | JDK 1.8 | |--------------|-----------------------|-----------------------| | **插入方式** | 头插法(反转表) | 尾插法(保持顺序) | | **数据结构** | 数组+表 | 数组+表/红黑树 | | **线程安全** | 并发扩容可能锁[^1] | 优化尾插法避免环形表 | > 示例:JDK 1.7的迁移过程源码见引用[3][4][5],多线程问题图解见引用[3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值