并发下诡异的HashMap

Posted 一缕清风007

tags:

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

最近研读《Java高并发程序设计》葛一鸣、郭超编著,读到2.8.3时,题目便是并发下诡异的HashMap,摘抄如下:

-----------摘抄开始--------------

HashMap同样不是线程安全的。当你使用多线程访问HashMap时,也可能会遇到意想不到的错误。不过和ArrayList不同,HashMap的问题似乎更加诡异。

package cn.baokx;

import java.util.HashMap;
import java.util.Map;

public class HashMapMultiThread {
	static Map<String,String> map = new HashMap<String,String>();
	public static class AddThread implements Runnable{
		int start = 0 ;
		public AddThread(int start){
			this.start = start;
		}
		@Override  
		public void run() {
			for (int i = 0; i < 100000; i+=2) {
				map.put(Integer.toString(i), Integer.toBinaryString(i));
				
			}
		}
	}
	
	public static void main(String[] args) throws InterruptedException {
		
		Thread t1 = new Thread(new HashMapMultiThread.AddThread(0));
		Thread t2 = new Thread(new HashMapMultiThread.AddThread(1));
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(map.size());
	}
}

上述代码使用t1和t2两个线程同时对HashMap进行put()操作,如果一切正常,我们期望得到的map.size()就是100000.但实际上,你可能会得到以下三种情况(注意,这里使用JDK7进行试验):

第一:程序正常结束,并且结果也是符合预期的。HashMap的大小为100000.

第二:程序正常结束,但结果不符合预期,而是一个小于100000的数字,比如98868.

第三:程序永远无法结束。

对于前两种可能,和ArrayList的情况非常类似,因此,不必过多解释。而对于第三种情况,如果是第一次看到,我想大家一定会觉得特别惊讶,因为看似非常正常的程序,怎么可能就结束不了呢?

注意:请读者谨慎尝试以上代码,由于这段代码很可能占用两个CPU核,并使它们的CPU占有率达到100%。如果CPU性能较弱,可能导致死机。请先保存资料再进行尝试。

打开任务管理器,你会发现,这段代码占用了极高的CPU,最有可能的表示是占用了两个CPU核,并使得这两个核的CPU使用率达到100%。这非常类似死循环的情况。

使用jstack工具显示程序的线程信息,如下所示。其中jps可以显示当前系统中所有的java进程。二jstack可以打印给定的java进程的内部线程及其堆栈。

我们会很容易找到我们的t1、t2和main线程:

可以看到,主线程main处于等待状态,并且这个等待是由于join方法引起的,符合我们的预期,二t1和t2两个线程都处于Runnable状态,并且当前执行语句为HashMap.put()方法。查看put()方法的第498行代码,如下所示:

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;
            }
        }
可以看到,当前这两个线程正在遍历HashMap的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。 但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到破坏,链表成环了!当链表成环时,上述的迭代就等同于一个死循环,如图2.9所示,展示了最简单的一种环状结构,Key1和Key2互为对方的next元素。此时,通过next引用遍历,将形成死循环。

这个死循环的问题,如果一旦发生,着实可以让你郁闷一把。本章的参考资料中也给出了一个真实的按理。但这个死循环的问题在JDK8中已经不存在了。由于JDK8对HashMap的内部做了大规模的调整,一次规避了这个问题。但即使这样,贸然在多线程环境下使用HashMap一人会导致内部数据不一致。最简单的解决方案就是使用ConcurrentHashMap代替HashMap。

-----------摘抄结束--------------

以上内容为书上内容的摘抄,看完以后对红色标注的部分"但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到破坏,链表成环了!"无法理解,到底是什么情况下导致链表成环了呢?书中并没有展开,通过自己最近几天的查询和学习,确认了链表成环的原因,在此分享出来。

HashMap内部通过一个Entry<K,V>[] table来存储数据,当调用put方法时,根据key的hashcode进行Hash计算,得出一个数组下标,然后将Entry对象放至相应位置,在这种情况下可能发生不同的hashcode进行Hash计算得到的下标相同的情况,这种情况下,会将Entry进行链式存储(Entry内部本身定义了Entry类型的next属性,据此实现链式存储),和ArrayList一样,table的长度会有一个初始值(HashMap默认为16)和load_factor(默认0.75f),当数组的“使用率(即不为空的元素数量/长度)”达到load_factor时,就需要对数组进行扩容,扩容的时候需要定义一个新的数组,并把旧的数组中的Entry分配到新的数组中。

对于HashMap内部的存储方式,可参考博客,http://blog.csdn.net/baokx/article/details/51426899,里面讲的比较详细。

源代码分析如下所示:

    //注意put方法是有返回值的
    public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //HashMap允许有一个key为null的键值对
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        //计算下标
        int i = indexFor(hash, table.length);
        //循环下标处的Entry链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //hash值相等且key==或equals匹配,则替换value值并把旧值返回
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        //若循环Entry链表未匹配到,则加一个Entry
        addEntry(hash, key, value, i);
        return null;
    }

    //新增一个Entry
    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);
    }


    //扩容
    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];
        //将旧数组中的Entry数据转移至新的数组
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 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;
            }
        }
    }


注意:上面的transfer方法,在多线程的情况下,会出现 newTable [ i ].next=newTable[i]的情况,这也是文中提到的链表成环的原因。

为了验证我们的判断,在创建数组的时候未数组提供初始化容量和影响因子,目的是不让扩容的情况发生,代码如下:

public class HashMapMultiThread {
	static Map<String,String> map = new HashMap<String,String>(120000,1.0f);
	public static class AddThread implements Runnable{
		int start = 0 ;
		public AddThread(int start){
			this.start = start;
		}
		@Override  
		public void run() {
			for (int i = 0; i < 100000; i+=2) {
				map.put(Integer.toString(i), Integer.toBinaryString(i));
				
			}
		}
	}
	
	public static void main(String[] args) throws InterruptedException {
		
		Thread t1 = new Thread(new HashMapMultiThread.AddThread(0));
		Thread t2 = new Thread(new HashMapMultiThread.AddThread(1));
		t1.start();
		t2.start();
		t1.join();
		t2.join();
		System.out.println(map.size());
	}
}

通过验证,不再出现死循环的问题(若觉得这样修改仍不能确定是由于代码修改导致不再死循环,可以增加线程数,再对比修改前后的运行结果)。

以上就是对此疑问的分析。

以上是关于并发下诡异的HashMap的主要内容,如果未能解决你的问题,请参考以下文章

高并发下的HashMap

高并发下,HashMap会产生哪些问题?

漫画:高并发下的HashMap

HashMap并发下死循环问题解析

HashMap底层详解-003-resize并发下的安全问题

详解并发下的HashMap以及JDK8的优化