美文网首页javaJava 杂谈技术干货
ConcurrentHashMap的实现原理与使用(一):多线程

ConcurrentHashMap的实现原理与使用(一):多线程

作者: 小草莓子桑 | 来源:发表于2017-06-09 02:01 被阅读782次

    在一次面试经验中,被问及了ConcurrentHashMap,当时说的不好,回来翻书、上网总结了点东西,供大家学习和交流,话说面试真是可以学到一些东西,好了闲话少说,进入正题吧。

    本文使用的jdk环境为1.7.0_67

    1.多线程环境下的下HashMap为什么会导致程序死循环

    众所周知

    在多线程的环境,HashMap的put操作会导致程序死循环,例如如下代码:

    /**
     * @Description: HashMap出现死循环.
     * @Author: ZhaoWeiNan .
     * @CreatedTime: 2017/6/7 .
     * @Version: 1.0 .
     */
    public class Demo1 {
    
        public static void main(String[] args){
    
            final Map<String,String> map = new HashMap<>();
            //开启10000个线程,在map里面put值
            for (int i = 0 ; i < 10000 ; i ++){
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        map.put(UUID.randomUUID().toString(),"");
                    }
                }).start();
            }
        }
    }
    
    很明显程序进入了死循环,cpu利用率一直高居不下,真希望买的股票是这个状态

    分析原因

    HashMap详细的原理就不给大家标示,大家可以去百度看看,以后有时间在为大家总结,这里简单说一下。
    HashMap是由数组和链表组成的,贴一点HashMap的源码:

        /**
         * The table, resized as necessary. Length MUST Always be a power of two.
         */
       //Entry<K,V>组成的数组table
        transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
    
    //Entry<K,V>有以下属性
    static class Entry<K,V> implements Map.Entry<K,V> {
            //key 键
            final K key;
            //value 值
            V value;
            //链表元素的尾,指向下一个元素
            Entry<K,V> next;
            //由hash算法得出的hash值
            int hash;
    

    当加入元素时候,会使用hash算法算出这个key在table中的索引,然后看table该索引上是否存在元素,如果不存在,把该Entry<K,V>插入到table的这个索引位置上,如果存在元素,就说明发生了冲突,然后就从table该索引位置上的节点为头的链表上遍历比较,如果key的hash值和链表中节点的hash相等,并且key相等,就把value存到链表元素的value中,把原来的value返回出来,如果没有相等的,就在链表的最末端的节点后面新加一个节点。

     public V put(K key, V value) {
            if (table == EMPTY_TABLE) {
                inflateTable(threshold);
            }
            if (key == null)
                return putForNullKey(value);
            //通过hash算法计算key的hash,hash算法源码就不给出了
            int hash = hash(key);
            //通过算出的hash值,算出key在数组table中的索引i
            int i = indexFor(hash, table.length);
            //遍历链表节点,去比较hash和key与节点 e.key 是否相等
            for (HashMap.Entry<K,V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    //如果有相等的节点,把 value 赋值到节点的value上,返回原来的节点e的value
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
    
            modCount++;
            //没有冲突或者相等的节点就新加一个节点
            addEntry(hash, key, value, i);
            return null;
        }
    

    分析addEntry方法:

    void addEntry(int hash, K key, V value, int bucketIndex) {
            if ((size >= threshold) && (null != table[bucketIndex])) {
                //当size大于初始化的threshold的时候,需要调用resize方法扩容
                resize(2 * table.length);
                hash = (null != key) ? hash(key) : 0;
                bucketIndex = indexFor(hash, table.length);
            }
    
            createEntry(hash, key, value, bucketIndex);
        }
    

    分析扩容方法resize,该方法主要做了扩容,并且创建了个新的Entry<K,V>数组table,并且调用transfer方法把老的数组table中的元素转存到新的数组table中去:

    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方法,把老数组中的元素转存到新数组中去
            transfer(newTable, initHashSeedAsNeeded(newCapacity));
            table = newTable;
            threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
        }
    

    再分析转存方法transfer,在这有循环,有链表的next节点赋值,可以猜测是因为单链表形成了环造成的死循环

    void transfer(Entry[] newTable, boolean rehash) {
            //把旧的数据转存到新的table中
            int newCapacity = newTable.length;
            for (Entry<K,V> e : table) {
                while(null != e) {
                    //在这有循环,有链表的next节点赋值,
                    Entry<K,V> next = e.next;         //(1)
                    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;
                }
            }
        }
    

    分析执行过程:

    //假设map只能放置两个元素,其中的threshold为1
    Map<Integer,String> map = new HashMap<>(2);
    //并且为了方便分析,
    //假设hashMap,计算索引的方法为偶数的index为0,奇数的index为1
    //现在添加两个元素
    map.put(1,"A");
    map.put(3,"B");
    

    那么,现在map的存储情况如下:


    画工粗糙,请见谅

    当我们再次put 一组数据的时候,map.put(2,"C")时,由于计算出索引是0,所以添加的时候需要扩容,此时如果在多线程环境下,假设:线程1执行到transfer方法(1)位置处,线程2获得了cpu执行权,线程2进行了扩容,执行了转存方法transfer把旧的table变成新的table(在线程2的私有栈中)吗,在写到内存中

    //先获取table中的元素e
        for (Entry<K,V> e : table) {
            while(null != e) {
                //进入while循环,获取节点e的next节点,直到next节点为null
                //循环结束
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //计算出e节点在新table中的索引
                int i = indexFor(e.hash, newCapacity);
                //把节点e转存到新table中
                //冲突链表转存的方式为头插法
                //即的节点e的next节点作为节点e的头
                //换种方式讲,在新的table中,节点e变成了next
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    

    执行过程如图:


    画工粗糙请见谅

    此时,线程1获取cpu执行权,按照上述过程进行数据转存执行过程如下,第一次进入循环中:

    while(null != e) {
                //第一次进入循环时
                /*假设此时线程1读取的线程1栈中的数据
                冲突链的结构还是 k:1 v:A(e) -> k:3 v:B(next) -> null
                k:1 v:A 为当前节点e,e.next为节点k:3 v:B,节点k:3 v:B的next为null
                */
                Entry<K,V> next = e.next;
                //next 为 节点k:3 v:B
                ......
            }
    

    第二次进入循环中:

    for (Entry<K,V> e : table) {
            while(null != e) {
                //第二次进入循环
                /*假设此时线程1读取的线程是线程2写入到内存中的数据
                  此时,节点e为 k:3 v:B,但是线程2中节点e k:3 v:B的next
                  指向了节点 k:1 v:A,所以获取的next节点不为空,
                  循环没有结束,
                  其实,冲突链已经成了环,所以while无法结束,程序陷入了死循环。
                */
                Entry<K,V> next = e.next;
                ......
            }
        }
    

    根据上述分析,HashMap在多线程情况下,put的时候,扩容条用转存方法transfer时,冲突链可能会形成环,导致while循环无法结束,所以导致文章开头所描述的情形,创建一万个线程,每个线程put一个随机UUID,执行过程中,有时会发生死循环的现象。
    欢迎大家来交流,指出文中一些说错的地方,有错误的地方希望大家多多提出来,让我加深认识。
    ConcurrentHashMap的实现原理与使用,放到下几篇文章里面说吧。
    谢谢大家!

    相关文章

      网友评论

        本文标题:ConcurrentHashMap的实现原理与使用(一):多线程

        本文链接:https://www.haomeiwen.com/subject/epwafxtx.html