ConcurrentHashMap的并发原理

我们都知道jdk1.8之前ConcurrentHashMap是采用分段锁的机制允许多线程并发操作一个ConcurrentHashMap的方式来提高并发的,它的主要思想是优化HashTable中全局锁,让锁更加细粒度来达到更高的并发度的。
但是JDK1.8里面的ConcurrentHashMap则完全不一样,它用到了CAS思想,使用的是乐观锁思想,乐观锁认为对于同一个数据的并发操作是不会发生修改,在更新数据时会采用尝试更新不断重试的方式更新数据。synchronized锁则只锁一个节点,这样大大提高了并发性能,而且ConcurrentHashMap的数据结构和HashMap是一样的,不了解HashMap的结构可以参考下面这篇文章:

JDK1.8中的ConcurrentHashMap中比HashMap多了一个重要属性sizeCtl,它是一个控制标识符,为0时,表示table还未初始化,为正数时表示初始化或下一次扩容的大小;而如果sizeCtl为-1,表示正在进行初始化操作;而为-N时,则表示有N-1个线程正在进行扩容。

我们只需要理解ConcurrentHashMap的put和get两个方法就能理解它的并发原理。

1、put方法

先看put方法代码:

public V put(K key, V value) {
        return putVal(key, value, false);
    }
     /** Implementation for put and putIfAbsent */
    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; K fk; V fv;
            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)))
                    break;                   // no lock when adding to empty bin
            }
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else if (onlyIfAbsent // check first node without acquiring lock
                     && fh == hash
                     && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                     && (fv = f.val) != null)
                return fv;
            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);
                                    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;
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        addCount(1L, binCount);
        return null;
    }

核心逻辑在putVal方法里面,这个方法大致逻辑如下:
1、检查key和value都不能为空,算出key的hash值
2、如果table还没有初始化,则初始化table。
3、根据hash值取出table里的首节点,如果为空,就把当前值构造成节点通过cas插入当前桶中。如果插入成功就退出循环,返回值;如果插入失败则进行下一轮循环。
4、hash值对应的桶节点不为空,并且节点的hash值为-1,表示正在扩容,那么当前线程就加入扩容队伍帮助扩容
5、如果hash值匹配到的节点的hash值和value都和当前要put进去的值相等,并且设置了onlyIfAbsent为真,就直接返回当前存在的value值
6、以上情况都不满足,则对当前节点加锁,执行put链表和二叉树的逻辑,这个和hashMap的逻辑就是差不多的。

我们再来看看初始化table的代码:

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.compareAndSetInt(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;
    }

1、如果发现有其他线程在初始化,就让当前线程让出cpu,继续下一轮循环。
2、如果没有其他线程在初始化,就通过cas修改sizeCtl的值为-1,开始进行初始化操作。
这里通过线程让出cpu继续循环,不挂起当前线程,减少线程间切换,又通过CAS操作来修改控制变量sizeCtl,设置为-1,如果设置成功,就进入初始化逻辑块,如果设置失败线程又进入下一轮循环,下一轮循环进来发现sizeCtl小于零,主动让出CPU。

可以看出来这个方法里面大量使用了CAS操作,多个地方去掉了锁的使用,不让线程挂起,减少了线程上下文切换,因为一个线程上下文切换要消耗几万个cpu时钟周期,而一个CAS重试只需要几十个CPU时钟周期,即使CAS重试几十次都还是性能要好于线程切换的。

2、get方法

先上代码:

public V get(Object key) {
        Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
        int h = spread(key.hashCode());
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (e = tabAt(tab, (n - 1) & h)) != null) {
            if ((eh = e.hash) == h) {
                if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                    return e.val;
            }
            else if (eh < 0)
                return (p = e.find(h, key)) != null ? p.val : null;
            while ((e = e.next) != null) {
                if (e.hash == h &&
                    ((ek = e.key) == key || (ek != null && key.equals(ek))))
                    return e.val;
            }
        }
        return null;
    }

可以看到整个get分为三种情况:

1、获取到节点不为空,并且节点的hash值等于key的hash值,直接返回节点的val

2、获取到的节点不为空,但是头节点的hash值不等于key的hash值,并且节点是一个链表,就遍历链表,找到对应的数据

3、如果节点的hash值小于零,则节点是一个二叉树(也可能正在扩容),就从树中找到对应的值。

通看所有代码,整个过程都没有用到锁。

那么有些人可能会觉得奇怪这个get方法如何保证线程安全?

比如下面两种情况:

java ConcurrentHashMap 实际就用_初始化

这个疑虑就不用当心了,因为val这个字段在node类里面是volatile的,也就是线程直接是可见的。

还有一种情况就是,如果读线程找到了hash值相同的节点,但是写线程直接remove了呢?
我们去看看remove方法:

public V remove(Object key) {
        return replaceNode(key, null, null);
    }

    /**
     * Implementation for the four public remove/replace methods:
     * Replaces node value with v, conditional upon match of cv if
     * non-null.  If resulting value is null, delete.
     */
    final V replaceNode(Object key, V value, Object cv) {
        int hash = spread(key.hashCode());
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            if (tab == null || (n = tab.length) == 0 ||
                (f = tabAt(tab, i = (n - 1) & hash)) == null)
                break;
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                boolean validated = false;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        if (fh >= 0) {
                            validated = true;
                            for (Node<K,V> e = f, pred = null;;) {
                                K ek;
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    V ev = e.val;
                                    if (cv == null || cv == ev ||
                                        (ev != null && cv.equals(ev))) {
                                        oldVal = ev;
                                        if (value != null)
                                            e.val = value;
                                        else if (pred != null)
                                            pred.next = e.next;
                                        else
                                            setTabAt(tab, i, e.next);
                                    }
                                    break;
                                }
                                pred = e;
                                if ((e = e.next) == null)
                                    break;
                            }
                        }
                        else if (f instanceof TreeBin) {
                            validated = true;
                            TreeBin<K,V> t = (TreeBin<K,V>)f;
                            TreeNode<K,V> r, p;
                            if ((r = t.root) != null &&
                                (p = r.findTreeNode(hash, key, null)) != null) {
                                V pv = p.val;
                                if (cv == null || cv == pv ||
                                    (pv != null && cv.equals(pv))) {
                                    oldVal = pv;
                                    if (value != null)
                                        p.val = value;
                                    else if (t.removeTreeNode(p))
                                        setTabAt(tab, i, untreeify(t.first));
                                }
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                if (validated) {
                    if (oldVal != null) {
                        if (value == null)
                            addCount(-1L, -1);
                        return oldVal;
                    }
                    break;
                }
            }
        }
        return null;
    }

这个方法有点儿长,核心代码情况下面:

java ConcurrentHashMap 实际就用_初始化_02


其实remove方法是把原来节点的值给替换成null,然后再把这个节点的引用从链表中移除的。如果删除发生在return e.val这行代码之前,那么返回的val肯定是null。

从ConcurrentHashMap的源码中我们可以学到很多并发控制的优秀思想,我个人就总结两点:
(1)当其他线程需要修改,当前线程也需要修改时,可以循环CAS修改直到成功。
(2)当其他线程在修改,当前线程可以修改也可以不修改时,可以帮组其他线程修改,也可以让出cpu,循环等待。
这样可以减少很多线程上下文切换。