【HashMap】HashMap死循环与元素丢失(一)

前一篇文章讲解了HashMap的实现原理,讲到了HashMap不是线程安全的。那么HashMap在多线程环境下又会有什么问题呢?java

公司项目的一个模块在线上运行的时候出现了死循环,死循环的代码就卡在HashMap的get方法上。尽管最终发现不是由于HashMap致使的,但却让我重视了HashMap在多线程环境下会引起死循环的这个问题,下面先用一段代码简单模拟出HashMap的死循环:数组

public class HashMapThread extends Thread
    {
        private static AtomicInteger ai = new AtomicInteger(0);
        private static Map<Integer, Integer> map = new HashMap<Integer, Integer>(1);
        public void run()
        {
            while (ai.get() < 100000)
            {
                map.put(ai.get(), ai.get());
                ai.incrementAndGet();
            }
        }
    }

这个线程的做用很简单,给AtomicInteger不断自增并写入HashMap中,其中AtomicInteger和HashMap都是全局共享的, 也就是说全部线程操做的都是同一个AtomicInteger和HashMap。开5个线程操做一下run方法中的代码:安全

public static void main(String[] args)
    {
        HashMapThread hmt0 = new HashMapThread();
        HashMapThread hmt1 = new HashMapThread();
        HashMapThread hmt2 = new HashMapThread();
        HashMapThread hmt3 = new HashMapThread();
        HashMapThread hmt4 = new HashMapThread();

        hmt0.start();
        hmt1.start();
        hmt2.start();
        hmt3.start();
        hmt4.start();
    }

多运行几回以后死循环就出来了,我大概运行了7次、8次的样子,其中有几回是数组下标越界异常 ArrayIndexOutOfBoundsException。这里面要提一点,多线程环境下代码会出现问题并不意味着多线程环境下必定会出现问题,但 是只要出现了问题,或者是死锁、或者是死循环,那么你的项目除了重启就没有什么别的办法了,因此代码的线程安全性在开发、评审的时候必需要重点考虑到。 死循环问题的定位通常都是经过jps+jstack查看堆栈信息来定位的:数据结构

看到Thread-0处于RUNNABLE,而从堆栈信息上应该能够看出,此次的死循环是因为Thread-0对HashMap进行扩容而引发的。 多线程

正常的扩容过程

先来看一下HashMap一次正常的扩容过程。简单一点看吧,假设我有三个通过了最终rehash获得的数字,分别是5 7 3,HashMap的table也只有2,那么HashMap把这三个数字put进数据结构了以后应该是这么一个样子的:并发

这应该很好理解。而后看一下resize的代码,上面的堆栈里面就有:性能

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;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }


    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

我总结一下这三段代码,HashMap一次扩容的过程应该是:spa

一、取当前table的2倍做为新table的大小线程

二、根据算出的新table的大小new出一个新的Entry数组来,名为newTablecode

三、轮询原table的每个位置,将每一个位置上链接的Entry,算出在新table上的位置,并以链表形式链接

四、原table上的全部Entry所有轮询完毕以后,意味着原table上面的全部Entry已经移到了新的table上,HashMap中的table指向newTable

这样就完成了一次扩容,用图表示是这样的:

扩容致使的死循环

既然是扩容致使的死循环,那么继续看扩容的代码:

void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

两个线程,线程A和线程B。假设第9行执行完毕,线程A切换,那么对于线程A而言,是这样的:

CPU切换到线程B运行,线程B将整个扩容过程所有执行完毕,因而就造成了:

此时CPU切换到线程A上,执行第8行~第14行的do…while…循环,首先放置3这个Entry:

咱们必需要知道,因为线程B已经执行完毕,所以根据Java内存模型(JMM),如今table里面全部的Entry都是最新的,也就是7的next是3,3的next是null。3放置到table[3]的位置上了,下面的步骤是:

一、e=next,即e=7

二、判断e不等于null,循环继续

三、next=e.next,即next=7的next,也就是3

四、放置7这个Entry

因此,用图表示就是:

放置完7以后,继续运行代码:

一、e=next,也就是说e=3

二、判断e不等于null,循环继续

三、next=e.next,即3的next,也就是null

四、放置3这个Entry

把3移到table[3]上去,死循环就出来了:

3移到table[3]上去了,3的next指向7,因为原先7的next指向3,这样就成了一个死循环。

此时执行13行的e=next,那么e=null,循环终止。尽管这次循环确实结束了,可是后面的操做,只要涉及轮询HashMap数据结构的,不管是迭代仍是扩容,都将在table[3]这个链表处出现死循环。这也就是前面的死循环堆栈出现的缘由,transfer的484行,由于这是一次扩容操做,须要遍历HashMap数据结构,transfer方法是扩容的最后一个方法。

OK,前面讲了5 7 3致使了死循环,如今看一下正常的顺序3 5 7,会发生什么问题。简单看一下,就不像上面讲得这么详细了:

这是扩容前数据结构中的内容,扩容以后正常的应该是:

如今在多线程下遇到问题了,某个线程先放7:

再接着放5:

因为5的next此时为null,所以扩容操做结束,3 5 7形成的结果就是元素丢失。

解决

把一个线程非安全的集合做为全局共享的,自己就是一种错误的作法,并发下必定会产生错误。因此,解决这个问题的办法很简单,有两种:

一、使用Collections.synchronizedMap(Mao<K,V> m)方法把HashMap变成一个线程安全的Map

二、使用Hashtable、ConcurrentHashMap这两个线程安全的Map

不过,既然选择了线程安全的办法,那么必然要在性能上付出必定的代价—-毕竟这个世界上没有十全十美的事情,既要运行效率高、又要线程安全。

相关文章
相关标签/搜索