ConcurrentHashMap 源码解析

101 阅读4分钟

上一篇文章,我们提到过HashMap是 非线程安全的,以下图为例说明. 图一 image.png

假设有A,B两个线程,在执行put方法时,是如图所示的步骤 1.数组长度-1 & hash运算计算出数组索引下标 2.判断该索引位置是否为空,如果为空 ,new一个Entry 对象,不为空则判断索引指针所指向的下一个节点是否为空,去形成链表 3.对索引位置赋值.

按图所示,锁乳线程AB并行执行步骤4,两个线程得到的结果都是该索引位置为null,这时执行步骤5 ,线程A先执行,线程B后执行,就会出现key2将key1 覆盖掉的情况,6的位置GETKey的时候就只能拿到key2所对应的值,那么怎么才能顺序执行呢?不错,就是串行,提到串行,首先想到的肯定是加锁,比如,在put方法上 synchronized修饰,就可以解决这种并发问题,这也是HashTable的解决方式

图二 image.png

图三 image.png

但是再看第一张图,直接在put方法上加锁,相当于锁住了全局,假如线程Aput到索引1的位置 线程Bput到索引2 的位置,那么本身他们是不是不是冲突的?所以Hashtable的处理方式就造成了性能瓶颈,如果对整个方法去加锁,就造成了资源浪费,基于此,JDK1.7的ConcurrentHashMap就引入了分段锁的概念,去优化这个问题.

图四 image.png

1.7中ConcurrentHashMap的结构,是有一个segment数组,其中每一个segment对象持有一个Entry数组,如上图所示,每一个segment就是一把锁,当k1,k2同时向s1中的Entry数组中put时,会产生竞争关系,先获取到s1的线程先执行(锁的逻辑一会我们看源码,先理解流程),k1,k2分别向s1,s5时,他们没有竞争关系,就不会产生阻塞,导致性能变差.接下来,因为我安装的JDK是1.8的,所以1.7的源码这里就不贴了,简单说一下,底层是通过一个while(!tryLock)的自旋锁的方式去向node节点添加数据,这个设计很巧妙,一般的锁线程抢不到锁的时候是阻塞的,只能等待.但是这种自旋他可以在那不到锁的时候去做一些其他的判断,1.7中就是在获取锁的代码块里去查询Node[index]所指向的下一节next点的数据进行一些判断,没有浪费线程空转期的时间片.接下来再看下在JDK1.8中 ConcurrentHashMap 的底层实现.下面是put方法的源码,请看源码我添加注释的地方,下面会一一说明

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) { // 一
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//二
            if (casTabAt(tab, i, null,//三
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)//四
            tab = helpTransfer(tab, f);
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {//五
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {//六
                                pred.next = new Node<K,V>(hash, key,
                                                          value, null);
                                break;
                            }
                        }
                    }
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            if (binCount != 0) {
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);//七
    return null;
}

可以看出,1.8的put方法是通过自旋(一)+cas(三) 这种方式去实现锁的,当线程执行put方法时,先通过tabAt(二)判断该索引位置是否为null,如果为null,则通过cas方式去生成一个新的Node对象并赋值给该索引节点.如果存在,走到(四),走到这里代表f一定不为null,判断f的hash值是否等于MOVED(-1),hash等于-1 代表当前正在扩容,如果为true那就让当前线程加入进去帮助扩容,如果不是走到5,这段代码是不是很熟悉?tabAt判断索引位置与f是否相等,如果是自旋进行值覆盖,否则找到指针指向的下一节点生成一个新的Node对象赋值(六),最后说一下binCount,这个参数主要是记录HashMap的Size,用来扩容和转化红黑树用的.

感谢收看,欢迎批评指正.