HashMap死循环问题追踪

HashMap死循环问题追踪

简介

HashMap在设计之初并没有考虑多线程并发的情况,多线程并发的情况下理论上应该使用ConcurrentHashMap,但是程序中经常会无意中在多并发的情况下使用了HashMap,如果是jdk1.8以下的版本,有可能会导致死循环,打满cpu占用,下面基于jdk1.7源码分析下原因。

HashMap的原理

我们从put看起

    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry 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;
    }
  • HashMap基于Hash表来存储数据,冲突的键通过拉链法形成一条链表
  • 插入的时候,首先判断在链表中有没有key相同的项存在,如果有,则直接进行替换,并返回旧值。
  • 如果key不存在,则需要在链表中新加一个项,进入addEntry方法。

addEntry方法长这样:

    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);
    }
  • 如果当前key的数量大于扩容阈值而且当前要放的位置已经有值,则会进行resize进行扩容,这里可以看出,如果hash表的长度为4,按照hashmap双倍扩容的策略,在hash表里的数据量大小为8的时候才会进行扩容,并且保证当前放的这个位置已经有值。平均hash每个位置会存储两个值,应该是综合扩容效率和查找效率设计的。
  • 确认hash表之后,则会新建一个entry放入hashmap,没什么特殊处理。

下面就是resize方法:

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

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }
  • 首先判断容量是否已经到达MAXIMUM_CAPACITY,这里是为了避免双倍扩容导致int越界,如果已经到达最大值,则直接返回。
  • 如果没有,则申请新的容量为之前两倍的数组,并将之前的数据转移到新的数组中。

然后就是关键方法transfer:

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry e : table) {
            while(null != e) {
                Entry 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;
            }
        }
    }
  • trans方法设计比较巧妙,每次取旧的key链表的头,放入新的key链表的头,这样会导致一个情况出现:
    假设原本是A->B->C的链表,旧链表为null,则第一次之后旧链表会变成B->C,新链表为A。
    第二次旧链表变成C,新链表为B->A。
    第三次旧链表为null,新链表为C->B->A,整个链表的顺序被颠倒。
  • 这种颠倒在单线程的情况下不会出现任何问题,方法简单有效,但是在多线程下,则会出现问题,下面分析一下。

死循环原因

  • 现在假设有thread1,thread2两个线程,旧链表的情况为A->B->null。
  • 当thread1执行到
    Entry next = e.next;这句时,发生线程切换,thread2开始扩容,此时对线程A来说,存在以下关系。
thread1:
e=A
next=B

旧链表:
A->B->null

新链表:
null
  • thread2扩容完成,线程切换回Thread1,此时存在如下关系:
thread1:
e=A
next=B

旧链表:
null

新链表:
B->A->null
  • thread1执行下一句e.next = newTable[i];,执行完变成如下关系:
thread1:
e=A
next=B

旧链表:
null

新链表:
B->A->B(出现循环)
  • thread1继续执行newTable[i] = e;,执行完变成如下关系:
thread1:
e=A
next=B

旧链表:
null

新链表:
A->B->A
  • thread1继续执行e = next;,执行完变成如下关系:
thread1:
e=B
next=B

旧链表:
null

新链表:
A->B->A
  • thread1已经进入死循环。

jdk1.8优化

那么对于jdk1.8是如何做的呢,1.8的resize比较复杂,核心点在下面:

        Node loHead = null, loTail = null;
        Node hiHead = null, hiTail = null;
        Node next;
        do {
            next = e.next;
            if ((e.hash & oldCap) == 0) {
                if (loTail == null)
                    loHead = e;
                else
                    loTail.next = e;
                    loTail = e;
            }
            else {
                if (hiTail == null)
                    hiHead = e;
                else
                    hiTail.next = e;
                    hiTail = e;
            }
        } while ((e = next) != null);
  • 这里oldCap是2的整数倍,通过判断这一位0/1来决定放在新的链表的位置,如果是0则放在原位置,是1则放在原位置+oldCap的位置。
  • loHead和loTail形成链表1,hiHead和hiTail形成链表2,直接将原有元素一一迁移到新的链表,没有颠倒等任何动作发生,因此不会出现死循环。
  • 但是如果有两个线程同时操作,则有可能出现数据丢失的情况,每个线程都是将原有数据+自己的数据形成新的链表。

你可能感兴趣的:(java)