HashMap的死循环

问题

最近的几次面试中,我都问了是否了解HashMap在并发使用时可能发生死循环,导致cpu100%,结果让我很意外,都表示不知道有这样的问题,让我意外的是面试者的工作年限都不短。

由于HashMap并非是线程安全的,所以在高并发的情况下必然会出现问题,这是一个普遍的问题,虽然网上分析的文章很多,还是觉得有必须写一篇文章,让关注我公众号的同学能够意识到这个问题,并了解这个死循环是如何产生的。

如果是在单线程下使用HashMap,自然是没有问题的,如果后期由于代码优化,这段逻辑引入了多线程并发执行,在一个未知的时间点,会发现CPU占用100%,居高不下,通过查看堆栈,你会惊讶的发现,线程都Hang在hashMap的get()方法上,服务重启之后,问题消失,过段时间可能又复现了。

这是为什么?

原因分析

在了解来龙去脉之前,我们先看看HashMap的数据结构。

在内部,HashMap使用一个Entry数组保存key、value数据,当一对key、value被加入时,会通过一个hash算法得到数组的下标index,算法很简单,根据key的hash值,对数组的大小取模 hash & (length-1),并把结果插入数组该位置,如果该位置上已经有元素了,就说明存在hash冲突,这样会在index位置生成链表。

如果存在hash冲突,最惨的情况,就是所有元素都定位到同一个位置,形成一个长长的链表,这样get一个值时,最坏情况需要遍历所有节点,性能变成了O(n),所以元素的hash值算法和HashMap的初始化大小很重要。

当插入一个新的节点时,如果不存在相同的key,则会判断当前内部元素是否已经达到阈值(默认是数组大小的0.75),如果已经达到阈值,会对数组进行扩容,也会对链表中的元素进行rehash。

实现

HashMap的put方法实现:

1、判断key是否已经存在

public V put(K key, V value) {    if (key == null)        return putForNullKey(value);    int hash = hash(key);    int i = indexFor(hash, table.length);    // 如果key已经存在,则替换value,并返回旧值    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++;    // key不存在,则插入新的元素    addEntry(hash, key, value, i);    return null;}

2、检查容量是否达到阈值threshold

void addEntry(int hash, K key, V value, int bucketIndex) {    if ((size >= threshold) && (null != table[bucketIndex])) {        resize(2 * table.length);        hash = (null != key) ? hash(key) : 0;        bucketIndex = indexFor(hash, table.length);    }    createEntry(hash, key, value, bucketIndex);}

如果元素个数已经达到阈值,则扩容,并把原来的元素移动过去。

3、扩容实现

void resize(int newCapacity) {    Entry[] oldTable = table;    int oldCapacity = oldTable.length;    ...    Entry[] newTable = new Entry[newCapacity];    ...    transfer(newTable, rehash);    table = newTable;    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);}

这里会新建一个更大的数组,并通过transfer方法,移动元素。

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;        }    }}

移动的逻辑也很清晰,遍历原来table中每个位置的链表,并对每个元素进行重新hash,在新的newTable找到归宿,并插入。

案例分析

下面我们分析下,在并发情况下,循环链是如何产生的,假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1.

插入第4个节点时,发生rehash,假设现在有2个线程同时进行,线程1在执行到 Entry<K,V>next=e.next;时,cpu时间片用完了,这时线程2继续执行。

很不巧,a、b、c节点rehash之后又是在同一个位置7,线程2开始移动节点

第一步,移动节点a

第二步,移动节点b

注意,这里的顺序是反过来的

第三步,移动节点c

这个时候线程2的CPU时间片用完,线程1获得执行,在线程1中,这时,变量e指向的是节点a,newTable[7]目前因为线程2的执行,已经指向了节点c,所以执行 e.next=newTable[i];之后,节点a指向了节点c,形成了下面这种情况

当当的当,循环链已经形成。

这个时候,如果执行一个get方法,刚好hash到数组7的位置,其中又没有符合的key,就陷入了Infinite Loop,死循环就这么产生了。

总结

所以在并发的情况,发生扩容时,可能会产生循环链表,在执行get的时候,会触发死循环,引起CPU的100%问题,所以一定要避免在并发环境下使用HashMap。

曾经有人把这个问题报给了Sun,不过Sun不认为这是一个bug,因为在HashMap本来就不支持多线程使用,要并发就用ConcurrentHashmap。

END。 




https://mp.weixin.qq.com/s?__biz=MzIwMzY1OTU1NQ==&mid=2247483917&idx=1&sn=c22a3c448eec34b26c608cebe731a777&chksm=96cd4241a1bacb5780ea8d20f51ffbb853746bc4a1fce2ac9add28aec1f9001d76c2c7dc590e&mpshare=1&scene=1&srcid=0119L6b5NZ3bwe7uOP45rkYX&key=993591f1aaa1ed7266f7c641a5c9b141dc92a02a31da11c38f179f04c1b493dd9d823d05e73045d78d69fbd7547536c300098ac87a59b5df5334140a83e83110fcdbfb801bf2509472ac1d680591b869&ascene=0&uin=MTA2NzUxMDAyNQ%3D%3D&devicetype=iMac+MacBookAir6%2C2+OSX+OSX+10.10.5+build(14F2511)&version=11020012&lang=zh_CN&pass_ticket=MAPamN4eX6rJUWLTj0emuhgkPzizCQWki2TXH6Gd%2B7tR3HyXTeiCd08lQzxr4OVv​





————————————————————————————————————————————————————————


失误

在《老生常谈,HashMap的死循环》一文中描述了死循环的产生过程,后来发现分析过程有点问题,由于是半夜即时写的文章,脑子有点钝,人老了,可是我才18岁啊。

之前的问题出现在误以为线程1和线程2所操作的newTable是同一个,其实是两个不同的数组,低级失误,所以需要重新分析一遍。

修正

假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1.

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;        }    }}

以上是节点移动的相关逻辑。

插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。

假设 线程2 在执行到 Entry<K,V>next=e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。

线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点

第一步,移动节点a

第二步,移动节点b

注意,这里的顺序是反过来的,继续移动节点c

这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下:

这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。

Entry<K,V> next = e.next;int i = indexFor(e.hash, newCapacity);e.next = newTable[i];newTable[i] = e;e = next;

执行之后的引用关系如下图

执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系

变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下: 1、执行完 Entry<K,V>next=e.next;,目前节点a没有next,所以变量next指向null; 2、 e.next=newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环; 3、 newTable[i]=e 把节点a放到了数组i位置; 4、 e=next; 把变量e赋值为null,因为第一步中变量next就是指向null;

所以最终的引用关系是这样的:

节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。

另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。

总之,千万不要在多线程写时使用HashMap,单写多读是没有问题的。

我是占小狼,对之前的错误分析,深表歉意。

https://mp.weixin.qq.com/s?__biz=MzIwMzY1OTU1NQ==&mid=2247483925&idx=1&sn=5c3cc5aa95302a35a9de05affc418299&chksm=96cd4259a1bacb4fd330ebbbf0bf1cd2f360424565f4befb09191eed61d8b78c3444b1690009&mpshare=1&scene=1&srcid=0122Z10q2hmrfqwZzRV4hcw5&key=e58c60872eb97b112c3c8e76ce7641da6cb748d181a4258799768c0003bf6a83eba3905d97db0d1cb48f811b4810e5c455708a76174e8373bcf086cf7753eb6686834a2f0d328a62a6f02c1107e35a70&ascene=0&uin=MTA2NzUxMDAyNQ%3D%3D&devicetype=iMac+MacBookAir6%2C2+OSX+OSX+10.10.5+build(14F2511)&version=11020012&lang=zh_CN&pass_ticket=GW%2FulRvdJrLJuiFWWZHkWw0bLacp9KAoNQQVEYTL%2F6O6R6xrnjUtpnaM1K6QmEzf
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值