图解JDK1.7中HashMap头插法扩容造成的死循环问题

博客围绕Java扩容展开,先给出扩容源码,接着举例说明。假设两个线程同时扩容并进行数据迁移,线程A完成整个扩容后,线程B继续执行,经三轮循环后形成死循环。

扩容源码

先来看一下扩容的源码

void resize(int newCapacity) {
   
   
  Entry[] oldTable = table;
  int oldCapacity = oldTable.length;
  if (oldCapacity == MAXIMUM_CAPACITY) {
   
   
    threshold = Integer.MAX_VALUE;
    return;
  }

  //根据新的长度创建好的newTable
  Entry[] newTable = new Entry[newCapacity];
  
  // initHashSeedAsNeeded(newCapacity)这个返回值为false
  // 转移
  transfer(newTable, initHashSeedAsNeeded(newCapacity));
  table 
<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]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值