老生常谈,HashMap的死循环

Posted 占小狼的博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了老生常谈,HashMap的死循环相关的知识,希望对你有一定的参考价值。

占小狼 转载请注明原创出处,谢谢!

问题

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

如果是在单线程下使用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是否已经存在

 
   
   
 
  1. public V put(K key, V value) {

  2.    if (key == null)

  3.        return putForNullKey(value);

  4.    int hash = hash(key);

  5.    int i = indexFor(hash, table.length);

  6.    // 如果key已经存在,则替换value,并返回旧值

  7.    for (Entry<K,V> e = table[i]; e != null; e = e.next) {

  8.        Object k;

  9.        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {

  10.            V oldValue = e.value;

  11.            e.value = value;

  12.            e.recordAccess(this);

  13.            return oldValue;

  14.        }

  15.    }

  16.    modCount++;

  17.    // key不存在,则插入新的元素

  18.    addEntry(hash, key, value, i);

  19.    return null;

  20. }

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

 
   
   
 
  1. void addEntry(int hash, K key, V value, int bucketIndex) {

  2.    if ((size >= threshold) && (null != table[bucketIndex])) {

  3.        resize(2 * table.length);

  4.        hash = (null != key) ? hash(key) : 0;

  5.        bucketIndex = indexFor(hash, table.length);

  6.    }

  7.    createEntry(hash, key, value, bucketIndex);

  8. }

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

3、扩容实现

 
   
   
 
  1. void resize(int newCapacity) {

  2.    Entry[] oldTable = table;

  3.    int oldCapacity = oldTable.length;

  4.    ...

  5.    Entry[] newTable = new Entry[newCapacity];

  6.    ...

  7.    transfer(newTable, rehash);

  8.    table = newTable;

  9.    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

  10. }

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

 
   
   
 
  1. void transfer(Entry[] newTable, boolean rehash) {

  2.    int newCapacity = newTable.length;

  3.    for (Entry<K,V> e : table) {

  4.        while(null != e) {

  5.            Entry<K,V> next = e.next;

  6.            if (rehash) {

  7.                e.hash = null == e.key ? 0 : hash(e.key);

  8.            }

  9.            int i = indexFor(e.hash, newCapacity);

  10.            e.next = newTable[i];

  11.            newTable[i] = e;

  12.            e = next;

  13.        }

  14.    }

  15. }

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

案例分析

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

老生常谈,HashMap的死循环

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

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

第一步,移动节点a

老生常谈,HashMap的死循环

第二步,移动节点b

老生常谈,HashMap的死循环

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

第三步,移动节点c

老生常谈,HashMap的死循环

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

老生常谈,HashMap的死循环

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

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

总结

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

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

END。 

我是占小狼。 如果读完觉得有收获的话,记得关注和点赞

每一次的思考

都想与你分享

以上是关于老生常谈,HashMap的死循环的主要内容,如果未能解决你的问题,请参考以下文章

多线程下HashMap的死循环问题

HashMap简单源码及多线程下的死循环

疫苗:Java HashMap的死循环

深入理解JAVA集合系列三:HashMap的死循环解读

深入理解JAVA集合系列三:HashMap的死循环解读

JDK1.7源码分析集合HashMap的死循环