【HashMap】扩容机制中尾插法造成环形链表导致死循环问题

博客详细分析了在多线程环境下,HashMap进行扩容操作时可能出现的环形链表及死循环问题。通过源码解析,展示了线程安全问题如何导致头插法形成环形链表,以及死循环的具体产生过程,涉及线程挂起、恢复和扩容机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

同花顺

这两个不同之处,在源码上都可以体现出来。

第一点不同,非常容易理解。

源码中,首先获取旧的数组,之后遍历。

遍历的第一个就是A,之后将A放到新数组的Index位上。

遍历的第二个是B,之后将B放到新数组的Index位上。

慢慢的A就被挤到最下边了,形成了顺序倒置。

这个是很好理解的,比较难以理解的是第二个不同。

环形链表以及死循环是如何产生的。

1、指针顺序不同

头插法与尾插法和栈与队列非常相似。

栈是先进后出,队列是先进先出。

这种比喻其实是不太形象的,糊涂般的这么理解一下即可。

在这里插入图片描述

2、头插法导致环形链表

这种问题的产生,归根结底还是线程安全问题,没有保证原子性,正在执行的线程受到了其他线程或其他因素的干扰。

在解释的过程中,我采取源码+图解+注释的方式解释说明,环形链表是如何产生的。

场景如下:

线程T1与线程T2共同put一个hashmap,同时触发了扩容机制。

在整个过程中,线程T1是真正正儿八经干活的,线程T2是来捣蛋的。

线程T2严重影响了线程T1的执行,导致死循环,罪魁祸首。

首先,看一下源码,简单介绍一下之后,再一步一步的走一遍源码。

一共分为四部分源码,从put一步一步走下去,重点是最后一步,前三部分可以直接忽略。

public V put(K key, V value)
{
    ......
    int hash = hash(key.hashCode());
    int i = indexFor(hash, table.length);
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    //----------------------------增加节点。
    addEntry(hash, key, value, i);
    return null;
}

void addEntry(int hash, K key, V value, int bucketIndex)
{
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    if (size++ >= threshold)
        //----------------------------判断是否满足扩容条件,满足则扩容。
        resize(2 * table.length);
}
void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    //----------------------------创建新数组
    Entry[] newTable = new Entry[newCapacity];
    //----------------------------旧数组数据迁移到新数组。
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
   
 void transfer(Entry[] newTable, boolean rehash) {
 		//新数组的容量,扩容后每一个桶的位置是会发生改变的。
 		//具体的Index有一个公式决定,其中一个决定因素为新数组容量大小。
        int newCapacity = newTable.length;
        //遍历旧数组
        for (Entry<K,V> e : table) {
        	//数组中每一个元素下边为链表,直到链表中每一个元素都遍历后,下一个节点next为空时,跳出循环,进入数组中的下一个元素桶的遍历。
            while(null != e) {
                //旧数组中的当前元素的下一个元素。
                Entry<K,V> next = e.next;              
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //判断扩容后的桶的Index。
                int i = indexFor(e.hash, newCapacity); 
                //当前元素指向新数组的index位number1置。
                e.next = newTable[i];                  
                //把旧数组的这个元素,乾坤大挪移到新数组。
                newTable[i] = e;                       
                //让旧数组的这个元素的下一个元素作为下次循环的主角,直到旧数组的这个元素的下一个元素为空,这个桶就算是遍历完了,遍历下一个桶去吧。
                e = next;                              
            }
        }
    }

3、死循环的产生

线程在运行过程中,争取CPU的执行权,就会导致线程T1竞争失败,由T2线程执行完毕后,T1才会执行。

死循环的产生不是每次碰到扩容,每次碰到多线程扩容就会产生,是凑巧行为。

在这里插入图片描述

第一次循环

在这里插入图片描述
T1挂起,T2执行完毕

   
 void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
            	//线程T1执行到这里,挂起,T1记录数据为e=A,next=B。
                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;                              
            }
        }
        //线程T2已经完成了旧数组到新数组的迁移,所有的遍历循环执行完毕,进入死亡状态。
    }

T1恢复运行状态

   
 void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
            	//线程T1执行到这里,挂起,T1记录数据为e=A,next=B。
                Entry<K,V> next = e.next;              
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //经过计算,Index位于T2计算的Index位一致,都是1。
                int i = indexFor(e.hash, newCapacity); 
                //这里已经受到了T2的影响,新数组的1号位上已经有数据了。真正导致死循环的也是这一行代码,指针互指。旧数组中的e(A)指向新数组上1号位,建立联系。
                e.next = newTable[i]; 
                //将旧数组中的A元素放到新数组的1号位上。        
                newTable[i] = e;                       
                //将next的值赋给e,开启下一轮循环,此时e=B。
                e = next;                              
            }
        }
    }

在这里插入图片描述

第二次循环

 void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
            	//接上次循环的数据e为B,这里next是谁。
            	//在第一次循环时,T1将这行代码执行完挂起使用的是旧数组中的数据,所以它的第一次循环也是旧数组的,但是此时,T2已经执行完毕,再次受到了T2线程的影响,B.next=A,所以next=A。
            	//最简单的理解方式为旧数组是一次性筷子,已经被T2用过了,没了,数据B的next在已有数据中只能为A。
                Entry<K,V> next = e.next;              
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); 
                //让B指向A,下边再头插赋值。
                e.next = newTable[i];
                //使用头插法将e=B,插入新数组的头部。
                newTable[i] = e;                    
                //将next复制给e,开始下一轮循环,此时e=A。   
                e = next;                              
            }
        }
    }

在这里插入图片描述

第三次循环

问题的出现就是在第三次循环中。

 void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
            	//接上一次循环,e为A,看上图得出,next = null = A.next;
                Entry<K,V> next = e.next;              
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity); 
                //这里让A指向了新数组的1号位B。
                //但是现在B已经指向A了,又让A指向B是什么鬼?
                //A.next=B; B.next=A,环形链表出现。
                e.next = newTable[i];
                newTable[i] = e;                    
                e = next;                              
            }
        }
    }

在这里插入图片描述

4、总结

两个线程,线程A,线程B。

在并发的时候原来的顺序被另外一个线程a颠倒了,而被挂起线程b恢复后拿扩容前的节点和顺序继续完成第一次循环后。

又遵循a线程扩容后的链表重新排列链表中的顺序,最终形成了环。

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值