大话系列 - ConcurrentHashMap源码分析

  因为上文中已经详细的说明了HashMap,这里在聊ConcurrentHashMap的时候,相差不大的部分就不再重复了,因为实现的原理都相差不多。这里就只说一下put操作吧

一、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;

  首先是在ConcurrentHashMap(后面用CHM来代替)中是不允许key和value为null的,在方法的第一行就进行了检查。

  然后是hash函数,这个跟HashMap的稍微有点不同

    static final int spread(int h) {
        return (h ^ (h >>> 16)) & HASH_BITS;
    }

  这里的 HASH_BITS 是 0x7fffffff (不明白这个的可以看我之前的文章),也就是说在经过扰动函数处理之后,还要把最高位置为0 (这个问题跟为什么CHM不允许key和value不能为null一样,都是没有明确的解释,大家自己Google理解吧)。binCount是用来记录某一个数组链中元素的个数(其实也表示当前key是否存在hash冲突)

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
            }

  接下来是对table对象进行判断处理,与HashMap相同这里对table也是懒加载的方式,在第一次添加元素的时候才会初始化。else分支是如果通过hash值定位到数组的具体下标位置后,如果没有元素就直接把当前元素添加为数组中的第一个元素,这里比较好理解,下面看一下initTable方法

    /**
     * Initializes table, using the size recorded in sizeCtl.
     */
    private final Node<K,V>[] initTable() {
        Node<K,V>[] tab; int sc;
        while ((tab = table) == null || tab.length == 0) {
            if ((sc = sizeCtl) < 0)
                Thread.yield(); // lost initialization race; just spin
            else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if ((tab = table) == null || tab.length == 0) {
                        int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = tab = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
                break;
            }
        }
        return tab;
    }

  这里是再次判断table是否为空,因为在并发环境下有可能已经被其他线程初始化了,这里通过while循环来确保一定有一个线程来完成数组的初始化。接下来首先是判断sizeCtl如果小于0那么表示数组正在进行扩容等操作,通过yield来放弃CPU等待。然后是CAS方法,这里是用的Unsafe类中的方式,就是当前对象在SIZECTL偏移量的位置的值如果与sc相等,则把-1赋值给SIZECTL,这也就是上面为什么判断如果sizeCtl小于0的原因。

  接下来还是判断如果table此时还没有被初始化,那就用sizeCtl的大小创建一个Node数组,其实这里面的sizeCtl与HashMap中的threshold是相同的作用,是作为一个阈值标量存在的,当对象中的元素数量超过这个阈值的时候,就会触发扩容操作。

  在初始化table对象之后,执行了一句: sc = n - (n >>> 2) ,这里也是一个无符号右移操作,右移两位相当于n/4 ,这里为什么这么操作要说明一下,在HashMap中我们知道,threshold的值是数组的容量 * loadFactor,但是这里为什么跟HashMap中的不一样。这里我们需要看一下构造方法。

    public ConcurrentHashMap(int initialCapacity,
                             float loadFactor, int concurrencyLevel) {
        if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
            throw new IllegalArgumentException();
        if (initialCapacity < concurrencyLevel)   // Use at least as many bins
            initialCapacity = concurrencyLevel;   // as estimated threads
        long size = (long)(1.0 + (long)initialCapacity / loadFactor);
        int cap = (size >= (long)MAXIMUM_CAPACITY) ?
            MAXIMUM_CAPACITY : tableSizeFor((int)size);
        this.sizeCtl = cap;
    }

  这里我们看到虽然构造方法中可以传入loadFacotr,但是是上并没有将这个入参赋值给成员变量。所以咱们带着问题看一下CHM的局部变量有什么不同:

    /**
     * The load factor for this table. Overrides of this value in
     * constructors affect only the initial table capacity.  The
     * actual floating point value isn't normally used -- it is
     * simpler to use expressions such as {@code n - (n >>> 2)} for
     * the associated resizing threshold.
     */
    private static final float LOAD_FACTOR = 0.75f;

  通过上面的注释我们知道,构造方法中的参数只会影响初始容量,在初始化之后的负载因子不会被改变,所以这里也就能明白了 之前的操作,就是通过无符号位移的方式来减去1/4,这样剩余的sizeCtl的大小就是数组容量的75%,满足负载因子的要求。

  else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);

  还是回到put操作的流程中来,Node实例f当前指向的是数组中的第一个元素,fh用于记录当前Node元素的hash值,这里的判断如果hash值为MOVE(定义的状态常亮),那就说明当前对象在进行扩容迁移,然后通过方法名字也比较好了解,当前进程参与到迁移的过程中帮忙一起(这个方法后面再细说,先把流程走完)

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;
                }
            }

  这一段是比较长的,但是相对来说里面的逻辑也比较好理解。其实从相关科普文章中我们已经知道在Java8中对CHM进行了优化,已经不再是1.7中通过ReentrentLock采用分段锁来实现的了,因为Synchorized经过轻量级锁,偏向锁等一系列优化之后,性能已经不是很差了,所以这里简化代码的逻辑,直接采用synchorized的方式来实现。

  这里对数组中的第一个元素添加对象锁,然后判断fh是否大于0。解释一下为什么这么判断

   static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        volatile V val;
        volatile Node<K,V> next;

        Node(int hash, K key, V val, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.val = val;
            this.next = next;
        }

  这里是在数据中链表情况下保存元素的对象,可以看到其中保存了hash值。但是我们知道与HashMap一样,在满足一定条件之后,单链表结构会转换成红黑树的方式。那怎么判断当前元素中是单链表还是红黑树呢?

    * This map usually acts as a binned (bucketed) hash table.  Each
     * key-value mapping is held in a Node.  Most nodes are instances
     * of the basic Node class with hash, key, value, and next
     * fields. However, various subclasses exist: TreeNodes are
     * arranged in balanced trees, not lists.  TreeBins hold the roots
     * of sets of TreeNodes. ForwardingNodes are placed at the heads
     * of bins during resizing. ReservationNodes are used as
     * placeholders while establishing values in computeIfAbsent and
     * related methods.  The types TreeBin, ForwardingNode, and
     * ReservationNode do not hold normal user keys, values, or
     * hashes, and are readily distinguishable during search etc
     * because they have negative hash fields and null key and value
     * fields. (These special nodes are either uncommon or transient,
     * so the impact of carrying around some unused fields is
     * insignificant.)

  这部分内容摘自CHM的类注释中的第二段,英文好的同学还是建议直接看英文。

  简单的翻译一下就是在CHM中每一个被添加的元素其实都是Node的包装对象,Node对象也是在单链表情况的元素。但是有一些子类,TreeNode是一颗平衡树中用来保存元素的包装对象(也就是我们说的红黑树),然后平衡树的root是用TreeBin对象来封装的, ForwardingNode子类是在扩容迁移的时候数组中的第一个元素。

  这里除了TreeNode之外的其他子类都有一个特点,就是他们不持有用户添加的元素,也就是没有key、value和hash属性,这也就解释了为什么那里需要判断fh大于等于0,因为任何一个添加的元素的hash肯定是大于0的,只有在他们是特定节点的时候hash值是作为默认值而存在的。

                        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;
                                }
                            }
                        }

  所以这里如果fh大于等于0就表示当前数组下表中保存的是一个单链表的元素集合,然后通过next指针去遍历寻找,如果能找到hash和key相同的对象,就根据参数替换oldValue,如果找不到就在链表的最后添加当前元素。

                        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;
                            }
                        }

  那如果不是单链表结构,这里是通过TreeBin类型来判断的,就直接将当前对象添加到红黑树中即可

                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }

  其实上面两个分支的逻辑其实都还比较好理解,不过这里出现一个判断binCount的方法。上面我们知道binCount是用来统计数组下标中元素个数的,那么这里如果binCount不等于0就证明当前待添加的key存在hash碰撞了。先是判断如果binCount大于等于8那么尝试转化成红黑树,这个与HashMap中的逻辑相同。

        addCount(1L, binCount);

  但是在方法结束的最后有一个addCount的方式,其实从方法的入参中我们可以猜到,这个应该是用于统计当前CHM中元素个数的,带着猜想看一下里面的代码

    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                  U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }

  这出现了一个新的对象CountCell数组,这里就不卖官司了,还是看了一下类注释了解到,这里其实是参照了LongAddr里面的设计,采用一个数组来统计元素的个数。但是我们知道CHM只一个使用于并发场景中的集合对象,而AutomicLong在高并发的情况下,因为CAS自旋会大量占用CPU,导致更新的性能下降的比较厉害,所以在CHM中就采用LongAddr的方式来计算size数组,这里就不讨论LongAddr的实现了。

if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                int rs = resizeStamp(n);
                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }

  这部分的逻辑其实也是争议比较大的一部分了,Google中可以看到很多争吵,其实这里想说一下个人的看法,写代码这个东西真的是每个人一个思路。其他人有的时候还真的是不好理解,那在我们学习源码的过程中遇到十分不理解代码的时候,其实是可以放弃,没必要每一行都去理解。只要领会大概的意思就好了。

  书归正传继续说这段代码的逻辑,首先就是判断s是否大于sizeCtl,还有一些边界值的判断,其实就是为了判断是否满足扩容的条件。然后通过当前的数组大小n来获取一个Stamp ,这个方法需要好好说一下

    static final int resizeStamp(int n) {
        return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
    }

  首先n是目前数组的容量, Integer.numberOfLeadingZeros(n) 方法返回这个数的二进制串中从最左边算起连续的“0”的总数量。因为int类型的数据长度为32所以高位不足的地方会以“0”填充。而我们知道CHM的数组容量一个是2的N次幂,对应的二进制一定是1后面跟随N个0的形式,所以比如数组容量是16 那对应的二进制低16位就是:

0000 0000 0001 0000 , 那么numberOfLeadingZeros 方法返回的值就应该是27 。

RESIZE_STAMP_BITS 是成员变量16,减去1就是15,所以后面部分就是1带符号左移15位 , 那对应的二进制低16位就是:

1000 0000 0000 0000 , 然后是用这两个值进行位或操作 ,上面说的返回值是27 ,那27的二进制低16位就是:

0000 0000 0001 1101 ,经过位或之后(其实就是相加) : 1000 0000 0001 1101 。这个就是一个int值得低16位,此时的高16位全部都是0 。 

  说了这么多,那计算这个东西是用来做什么的呢?看一下下面这部分逻辑:

                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);

  这里通过CAS的方式设置sc的值,将刚才计算出的rs带符号左移 16位 ,到这里就是这个resizeStamp的精髓所在了,我们把刚才计算出来的rs的二进制拿过来:

0000 0000 0000 0000 1000 0000 0001 1101

左移动16位

1000 0000 0001 1101 0000 0000 0000 0000

这时可以看到原来的低16位已经被移动到高16位了,低16位现在都是0 ,再看上面的代码,加2,那最终sizeCtl的值就应该是:

1000 0000 0001 1101 0000 0000 0000 0010

这里总结一下这个函数的意义 : 这里是将32位的int值分成两部分,高16位用来记录当前对应被扩容的信息,比如原数组的大小等。低16位用来记录现在有多少个线程在进行扩容迁移操作,扩容的逻辑与HashMap中的相同,这里就不再重复了。

那么数组容量仍然是扩大为原来的两倍,这次有多个线程来帮忙迁移,那效率就比较快。

所以还是回来看刚才的if分支:

                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }

  这里的就是通过rs的值来来判断当前对象是都正在扩容迁移,已经是都可以帮助进行迁移。如果可以就领取一个数组下表进行迁移工作。这里可以看到CAS方式是加1的操作。

 

  说到这里 addCount 方法中的逻辑基本都理清楚了,说两个题外话:

  1. if (sc < 0) 里面的if判断条件 
  2. 初始化时为什么要+2 

  这两个问题在网上也是有很多的讨论,但是仍然没有一个合理的解释。大家看自己喜欢去理解就好了,这里我们不较真。

    final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
        Node<K,V>[] nextTab; int sc;
        if (tab != null && (f instanceof ForwardingNode) &&
            (nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
            int rs = resizeStamp(tab.length);
            while (nextTab == nextTable && table == tab &&
                   (sc = sizeCtl) < 0) {
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
                    transfer(tab, nextTab);
                    break;
                }
            }
            return nextTab;
        }
        return table;
    }

  最后一起看一下helpTransfer 方法, ForwardingNode 子类我们上面也说过,如果一个数组元素正在被迁移,那么就会用 ForwardingNode 添加到头结点中。所以这里也是通过结点的类型来判断状态的一种方式,然后下面的处理逻辑与addCount中大同小异。

 
posted @ 2021-08-11 20:44  SyrupzZ  阅读(35)  评论(0)    收藏  举报