JDK1.8 ConcurrentHashMap之源码分析笔记

前言

JDK1.8 的ConcurrentHashMap相较于JDK1.7 的做了比较大的改动,取消了分段锁 的设计。JDK8改成了Node数组+链表/红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。

同时使用了CASsynchronized锁来保证线程安全,synchronized锁的粒度为桶中的头节点(链表Node结点或包装红黑树的TreeBin结点)。
(有先了解HashMap源码JDK1.7 的ConcurrentHashMap分析再来阅读会好些)
在这里插入图片描述

基本构造

重要属性

// 将链表转换成红黑树的阈值,链表长度超过阈值就会进行转换
static final int TREEIFY_THRESHOLD = 8;
// 将红黑树退化成链表的阈值,当某个桶中的节点数小于等于阈值就会转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
static final int MOVED     = -1; // 表示正在转移
static final int TREEBIN   = -2; // 表示是树节点
// 默认没初始化的数组,用来保存元素
transient volatile Node<K,V>[] table;
// 转移数据的时候使用的数组
private transient volatile Node<K,V>[] nextTable;
private transient volatile int sizeCtl;// 状态控制变量
  • sizeCtl 是最重要的属性了。它的取值比较多,不同值表示不同的场景。有:
    • -1 代表正在初始化
    • -N 表示有N-1个线程正在进行扩容操作
    • table还没初始化时,则表示初始化的容量
    • table初始化化,该值为容量的0.75倍,可以理解为类似HashMap的扩容阈值,超过此阈值就会进行扩容。从另一个角度看,也可以认为加载因子是0.75。
  • 元素节点hash值的含义:
    • hash == MOVED,MOVED即 -1 ,表示当前正在转移数据(扩容时把旧数据转移到新数组中)
    • hash == TREEBIN ,TREEBIN 即 -2,表示当前节点是树节点,在get方法中会用到
    • hash > 0,表示节点是个链表节点。

table操作的三个核心方法

这三个方法都是调用Unsafe类直接对内存进行操作的,效率较高。

 	// 获得在i位置上的Node节点。因为volatile关键字无法保证数组元素的可见性,所以需要调用getObjectVolatile
    static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
        return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
    }
	// 利用CAS操作设置i位置上的Node节点,保证只能有一个线程修改成功
    static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                        Node<K,V> c, Node<K,V> v) {
        return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
    }
	// 把Node节放置到i位置,放置完成后对其他线程立即可见。当i位置为空时调用此方法
    static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
        U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
    }

构造函数

public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               // 相当于取 (1.5*initialCapacity +1)的最近的2的n次方
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;
}

可以看到,构造函数里对用户传入的初始容量重新做了计算,并取最近的2的n次方。和HashMap一样,ConcurrentHashMap的容量也总保证是2的n次方。计算之后把新容量赋值给 sizeCtl,这就是sizeCtl的第一个使用场景:table未初始化时,表示初始化容量。

初始化table

这个操作比较简单,就是初始化一个指定大小的数组。如果有多个线程创建时,会使用CAS保证只有一个线程能创建成功。

private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    while ((tab = table) == null || tab.length == 0) {
        // 说明其他线程正在进行初始化,那么让出当前线程的CPU使用权
        if ((sc = sizeCtl) < 0)
            Thread.yield(); // lost initialization race; just spin
        // CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁,且正在初始化
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                if ((tab = table) == null || tab.length == 0) {
                    // DEFAULT_CAPACITY 默认初始容量是 16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    // 初始化数组,长度为 16 或初始化时提供的长度
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    // 将这个数组赋值给 table,table 是 volatile 的
                    table = tab = nt;
                    // 如果 n 为 16 的话,那么这里 sc = 12
                    // 其实就是 n - n/4 = 0.75 * n
                    sc = n - (n >>> 2);
                }
            } finally {
                // 设置 sizeCtl 为 sc,我们就当是 12 吧
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

put方法

put 的主要流程是:

  1. 计算 key 的hash值
  2. 判断数组是否为空,为空则进行初始化
  3. 根据hash值得到key在数组中对应的索引,取得该位置的节点
  4. 如果该位置为空,则直接CAS放入新节点
  5. 如果该位置的节点的hash等于MOVED,说明当前正在迁移数据,那么当前线程也先去帮忙,等到迁移完成后,再把数据添加进去。
  6. 否则就取得该节点的监视器锁,判断该节点如果为链表,则使用尾插法插入,如果是红黑树则调用红黑树的方法插入。插入完成后判断链表长度是否超过树化阈值,超过则将该链表转换成红黑树。
  7. 最后更新容器的size,并判断是否需要扩容。
public V put(K key, V value) {
    return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 对key的hashCode重新计算,得到 hash 值
    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();

        // 计算该 hash 值对应的数组下标,得到第一个节点 f
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 如果数组该位置为空,
            //    用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了
            //    如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                break;                   // no lock when adding to empty bin
        }
        // hash 等于 MOVED时,说明现在在扩容,那么当前线程先去帮忙转移数据,扩容完再把数据添加进来
        else if ((fh = f.hash) == MOVED)
            // 帮助数据迁移,后面会介绍
            tab = helpTransfer(tab, f);

        else { // 到这里就是说,f 是该位置的头结点,而且不为空,那么就可以添加数据了

            V oldVal = null;
            // 获取数组该位置的头结点的监视器锁
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
                        // 用于累加,记录链表的长度
                        binCount = 1;
                        // 遍历链表
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            // 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
                            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) {
                // 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
                if (binCount >= TREEIFY_THRESHOLD)
                    // 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
                    // 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    // 更新容器的size,判断是否需要扩容。(在这里暂不分析,也是大有文章)
    addCount(1L, binCount);
    return null;
}

扩容流程

接下来我们来看看ConcurrentHashMap里牛逼又比较难理解的部分:扩容。线程在执行put等操作时,如果发现当前容器正在扩容,那么都会先放下自己手里的工作,一起先帮忙迁移数据,实现了并发迁移,等到迁移完毕,再继续操作自己的数据。ConcurrentHashMap也是做翻倍扩容的,每次扩容后长度是原来的2倍。

扩容:tryPresize

接下来我们来看扩容方法tryPresize,它会尝试把数组扩大到给定的容量。

// treeifyBin调用此函数时,传进来的参数 size 已经是原数组长度的2倍了。
// putAll 传过来的size是要插入的元素数量
private final void tryPresize(int size) {
    // c 的取值为(size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方)。
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    // sizeCtl>=0 说明此时没有其他线程在初始化或迁移数据,那么我就来做这个工作
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;

        // 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c;
            if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        sc = n - (n >>> 2); // 0.75 * n
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // 再次检查tab是否为原引用,不是的话则说明已经完成扩容了,不用再操作了
        else if (tab == table) {
            // resizeStamp(n)的大致意思是记录扩容时的信息,在transfer方法可以用来判断迁移操作的状态
            int rs = resizeStamp(n);

            if (sc < 0) {
                Node<K,V>[] nt;
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                // 2. 用 CAS 将 sizeCtl 加 1,表明当前线程加入了迁移数据的工作,然后执行 transfer 方法
                //    此时 nextTab 不为 null
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
            //  此时得到的结果是一个比较大的负数
            //  调用 transfer 方法,此时 nextTab 参数为 null,用于初始化迁移数组
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

接下来就是扩容的重头戏:transfer方法。

数据迁移:transfer

这个方法的功能就是把原数组的数据迁移到新数组 nextTable 中去,迁移完成后再把 nextTable 赋值给table。

transfer会在多个地方被调用,可能会被多个线程同时调用,但它是线程安全的。其他方法在调用transfer时,会保证第一个发起数据迁移的线程,nextTable 参数传入为null,使transfer可以先对nextTable初始化。之后其他线程再调用时,nextTable不会为空。

首先我们要了解 transfer 是如何工作的。假设原数组长度为n,那么也就是有n个迁移任务。如果让每个线程每次只分配一个迁移任务是最简单的,但这样的话分配次数就多了,比较消耗资源。所以我们可以让每个线程每次搬运一定数量的任务,也就是说把所有迁移任务分成一个一个任务包,每个包里有特定数量的任务,每个线程负责搬运一个包,搬完了再看看还有没有需要帮忙继续搬运的。这样就能提高搬运效率。

作者使用了stride这个概念,叫做步长,表示分配给一个线程一次搬运的任务数量。步长会根据CPU核数计算得出一个较优数值。除此之外,我们还需要一个全局调度者(总指挥)来指明每个线程要从哪开始搬运任务包,transferIndex就相当于总指挥。

第一个发起数据迁移的线程会把 transferIndex 指向数组中最后的一个位置n从后往前stride个任务就是属于该线程的,然后将 transferIndex 指向新的位置n-stride,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,因为一个线程在迁移完自己的任务后,还会继续检查是否有剩下的任务没迁移,如果有的话自己再进行下一次迁移。

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;

    // stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
    // stride 可以理解为”步长“,相当于一个线程分配到 stride 个任务
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range

    // 如果 nextTab 为 null,先进行一次初始化
    //    前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
    //       之后参与迁移的线程调用此方法时,nextTab 不会为 null
    if (nextTab == null) {
        try {
            // 容量翻倍
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        // nextTable 是 ConcurrentHashMap 中volatile修饰的属性
        nextTable = nextTab;
        // transferIndex 也是 ConcurrentHashMap 中volatile修饰的属性,用于控制迁移的位置
        transferIndex = n;
    }

    int nextn = nextTab.length;

    // ForwardingNode 翻译过来就是正在被迁移的 Node
    // 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
    // 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
    //    就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
    //    所以它其实相当于是一个标志。
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);


    // advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
    boolean advance = true;
    // 所有迁移任务是否完成的标志,这里是指数组中全部的位置是否都被迁移完成
    boolean finishing = false; // to ensure sweep before committing nextTab

    /*
     * 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
     * 
     */

    // i 是位置索引,bound 是边界,注意是从后往前
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;

        // advance 为 true 表示可以进行下一个位置的迁移了
        //   简单理解:i 指向了 transferIndex,bound 指向了 transferIndex-stride
        while (advance) {
            int nextIndex, nextBound;
            // 如果 i 还没超过迁移边界,则跳出,继续下面的迁移
            // 如果已迁移完成,直接跳出
            // 当 --i < bound 时说明此次的任务包已经搬运好了,如果 finishing 还是false,那么会继续搬运下一个任务包 
            if (--i >= bound || finishing)
                advance = false;

            // 将 transferIndex 值赋给 nextIndex
            // 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了,也就是说不用再分配任务了
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // 分配当前线程此次迁移任务的边界 bound ~ i
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                // 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // 当 i 等于在下列取值范围时,说明原数组中所有位置都迁移好了,但还需要再次检查,防止有的没迁移
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 所有的迁移操作已经完成
            if (finishing) {
                nextTable = null;
                // 将新的 nextTab 赋值给 table 属性,完成迁移
                table = nextTab;
                // 重新计算 sizeCtl:n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }

            // 在tryPresize说过,sizeCtl 在第一次发起迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
            // 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
            // 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                // 当前线程的任务结束,方法退出。(当前正在迁移的线程数量大于1个)
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;

                // 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
                // 此时说明只剩下一个迁移的线程,那么该线程会重新检查一遍数组
                // 因为在迁移过程中,可能会有其他在null的位置插入了节点,所以要把新插的节点也迁移过去
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        // 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
        // 注意这里CAS可能失败,失败后 advance 为false,会重新检查该位置
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        // 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            // 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    // 头结点的 hash 大于 0,说明是链表的 Node 节点
                    if (fh >= 0) {
                        // 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
                        // 需要将链表一分为二,
                        //   找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
                        //   lastRun 之前的节点需要进行克隆,然后分到两个链表中
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 其中的一个链表放在新数组的位置 i
                        setTabAt(nextTab, i, ln);
                        // 另一个链表放在新数组的位置 i+n
                        setTabAt(nextTab, i + n, hn);
                        // 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
                        //    其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
                        setTabAt(tab, i, fwd);
                        // advance 设置为 true,代表该位置已经迁移完毕,继续迁移下一个
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {
                        // 红黑树的迁移
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        // 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;

                        // 将 ln 放置在新数组的位置 i
                        setTabAt(nextTab, i, ln);
                        // 将 hn 放置在新数组的位置 i+n
                        setTabAt(nextTab, i + n, hn);
                        // 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
                        //    其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
                        setTabAt(tab, i, fwd);
                        // advance 设置为 true,代表该位置已经迁移完毕
                        advance = true;
                    }
                }
            }
        }
    }
}

transfer分析完了,总结一下主要步骤:

  1. 如果传入参数 nextTab 为null,那么会先初始化 nextTable 数组,长度为原长的2倍。
  2. 接着根据CPU核数计算线程的步长stride,即每个线程一次需要迁移的任务数量。更新transferIndex到下一个迁移位置。
  3. 开始依次迁移每个位置的数据。
    3.1 如果该位置为空,则放入ForwardingNode节点
    3.2 如果该位置不为空,当节点的hash等于MOVED,说明该位置已经迁移完毕,则跳过,进行下一个位置的迁移
    3.3 否则说明该位置可迁移,使用synchronized锁住头节点,如果头节点是链表节点,那么将该链表分成两部分,一部分迁移到新数组中的 原位置,另一部分迁移到 原位置+原数组长度 的位置。类似JDK7的ConcurrentHashMap迁移。如果是红黑树,那么会把红黑树一分为二,如果切开后的长度小于8,那么转换成链表再插入到新数组里。
  4. 该位置迁移完成后,在该位置放置ForwardingNode节点,用于说明该位置已经迁移完毕。
  5. 回到循环判断是否还需要迁移下一个任务包。
  6. 如果当前线程是最后一个在迁移的线程,那么该线程会从后往前重新扫描一遍原数组,看还有没有节点没迁移的。

get方法

get就灰常简单了。过程如下:

  1. 计算key的hash值
  2. 通过hash值得到key在数组中的位置
  3. 判断该位置是否为空,为空则直接返回
  4. 不为空的话判断头节点的hash值,大于0则说明是链表,那么会遍历链表寻找是否有key和hash都相同的节点,找到了就返回value。
  5. 如果头节点hash值小于0说明正在扩容或者该位置是红黑树,调用e.find(h, key)查找。
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;
        }
        // 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
        else if (eh < 0)
            // 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
            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操作是没有加锁的(除了红黑树操作),为什么可以这么做呢?因为链表节点Node的value和next属性都是用volatile修饰的,保证了可见性。而红黑树就需要加锁,因为树旋转时可能会改变根结点或者其链接。

计数

借鉴了分段锁的相关思想:将原本所有线程对一个变量进行的线程安全的更新操作,扩展为不同线程对多个不同的计数单元的线程安全的操作,以减少更新时的冲突。

实现

使用一个long型名为baseCount 变量和一个CounterCell数组类型的名为counterCells的变量一起来记录size。counterCells是计数单元的数组,其大小始终为2的n次方倍,目的是使取余运算更加高效。每个桶中的位置存储的是CounterCell类型变量,记录了在桶中的线程需要增加的size的值。每个线程首先会CAS更新baseCount,如果更新失败,再将值更新到对应的计数单元上。
每个线程都能生成一个随机数,然后用这个随机数当这个线程的哈希码,通过这个哈希码,就能把这个线程对应到counterCells数组中的一个位置,再把要更新的值更新到这个位置上的CounterCell的value值上面。最后计数总数时,再在baseCount基础上累加上CounterCell数组里的所有值就可以了。
cellsBusy是一个用volatile修饰的变量,通过CAS原子更新的方式,充当了自旋锁的作用,当该值为0时,表示该锁可以使用,当该值为1时,表示某个线程获取了锁。

大致流程

  1. 判断数组是否为空,为空的话 则尝试获取CELLSBUSY CAS锁来初始化数组。初始化成功则将值叠加到对应的计数单元中,完成之后返回;当初始化失败时,通过CAS尝试将值叠加到基础计数变量baseCount中,CAS失败则继续循环
  2. 如果数组存在,通过探针 hash 定位桶中的位置,如果桶中为空,通过CELLSBUSY CAS 锁新建节点并插入数组,如果成功,结束循环,如果失败(其他线程可能在 初始化 或者 扩容 或者 也在新建节点)转到第 5 步
  3. 如果定位到桶中有值,通过 CAS 修改,如果成功,结束,如果失败向下走
  4. 如果数组大小小于 CPU 核数,尝试获取CELLSBUSY锁来扩容数组,每次数组扩容时,其大小为先前大小的2倍,同时将旧计数单元数组的每个元素直接复制到新表中。如果获取锁失败,转第 5 步。
  5. 重新计算探针 hash。探针通过ThreadLocalRandom.getProbe()生成,它可以给线程生成一个随机数作为线程的唯一标志,并且这个方法对于同一个线程每次生成的值是一样的。使用了 ThreadLocalRandom,相当于每个线程都有一个 Random,都有自己的种子,这样就不会存在多线程竞争修改种子,提高并发时的效率。

ConcurrentHashMap为何采用该方式来实现size()方法?我能想到的一个原因是ConcurrentHashMap并不需要精确的节点数目的值,由于ConcurrentHashMap该数据结构是为并发而生的,为此,获取精确的节点数目的值本身意义并不大。当你消耗了性能,获取了此时此刻的节点数目的精确值,随后还是可能会被其他线程修改,导致上一刻的值无法使用,为此获取一个“大概”值便是一个较好的选择

总结

学习大佬Doug Lea写的源码真的能学到很多东西,也有很多让人大呼牛逼的部分。有点小时候拆解玩具观察内部构造的乐趣。

其他问题

  1. 在扩容的时候,可不可以对数组进行读写操作呢?
    是可以的。当在进行数组扩容的时候,如果put要操作的位置为空,那么会CAS放入新节点,但此时可能会失败。如果当前位置还没被迁移,那么get操作可以直接读取该位置的值,如果当前位置已迁移完成的话,会调用e.find方法查询。

参考

  1. Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析
  2. 深入理解 ConcurrentHashMap(JDK1.8)_MeteorChenBo 的博客
  3. ConcurrentHashMap节点数量统计原理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值