并发集合类:ConcurrentHashMap源码解析

1.总体架构

类注释信息

  1. 所有操作都是线程安全的,在使用ConcurrentHashMap的过程中不需要额外加锁
  2. 多个线程同时进行put、remove操作时不会阻塞
  3. 迭代过程中,即使Map的结构被修改也不会抛出ConcurrentModificationException
  4. 除了数组、链表和红黑树外,新增加了转移节点,目的是为了保证扩容时的线程安全

结构

ConcurrentHashMap的底层数据结构和方法大体与HashMap一致,但是两者在继承关系上使无关的。
image
ConcurrentHashMap与HashMap相同点:

  • 数组、链表结构几乎相同,底层对数据结构操作的思路是相同的(思路相同,但实现不同)
  • 都实现了Map接口,继承了AbstractMap抽象类,所以大多数方法也是相同的。当从HashMap切换到ConcurrentHashMap时,无需关心二者的兼容问题

不同点:

  • 红黑树结构略有不同,HashMap中红黑树的节点是TreeNode,TreeNode通过属性维护着红黑树结构,还实现了红黑树相应的方法。ConcurrentHashMap中红黑树被拆分为两块,TreeNode仅维护属性和查找功能,新增了TreeBin,用于维护红黑树结构,同时负责根节点的加锁和解锁
  • 新增ForwardingNode(转移节点),扩容时会使用到,该节点能够保证扩容时的线程安全

2.源码解析

构造方法

1)指定初始化大小的构造方法

ConcurrentHashMap的构造方法如下,

public ConcurrentHashMap(int initialCapacity) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException();
  //如果传入的初始化容量值超过最大容量的一半,那么sizeCtl会被设置为最大容量。
  //否则通过tableSizeFor方法就算出一个2的n次方数值作为size
    int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
               MAXIMUM_CAPACITY :
               tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
    this.sizeCtl = cap;
}

上面是有参数的构造方法,sizeCtl变量在ConcurrentHashMap创建时表示的是当前底层数组的容量。该变量在整个类中具有多种含义,具体见下方对put方法的说明。

如果对未来存储的数据量有预估,可以指定哈希表的大小,避免频繁的扩容操作。tableSizeFor 方法确保底层数组的大小永远都是 2 n 2^n 2n。如果数组大学不是 2 n 2^n 2n ,那么 hash 算法计算的下标发生的碰撞概率会大大增加。tableSizeFor 方法确保了返回大于传入参数的最小 2 n 2^n 2n

注意tableSizeFor 方法传入的参数不是 initialCapacity,而是 i n i t i a l C a p a c i t y × 1.5 + 1 initialCapacity×1.5+1 initialCapacity×1.5+1。这样做是为了保证在默认0.75的负载因子下,能够足够容纳initialCapacity数量的元素。

2)tableSizeFor方法

private static final int tableSizeFor(int c) {
    int n = c - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

以参数c=9为例进行推演,

1)int n = 9-1 = 8 对应2进制数字 1000
2)n >>> 1 = 0100 与n进行位&运算 1000 | 0100 = 1100
3)n >>> 2 = 0011 与n进行位&运算 1100 | 0011 = 1111

如果 c足够大,可使得 n 很大,那么运算到 n |= n >>> 16 时,整数类型变量n 的 32 个二进制位都为 1。上面的逻辑总结起来把整型变量 n 有数值的 bit 位全部置为 1。就得到了一个肯定大于等于 n 的值。最后一行代码,最终返回的是 n+1,原因是所有位都是 1 的二进制数字表示的值为 2 n − 1 2^n-1 2n1,+1 后得到的就是一个 2 n 2^n 2n的值。

3)构造方法流程总结

  1. 构造函数中并不会初始化底层数组
  2. 构造函数中类成员变量sizeCtl表示底层数组大小
  3. 构造对象时传入的initialCapacity 并不是底层数组实际大小。数组的大小为 i n i t i a l C a p a c i t y × 1.5 + 1 initialCapacity×1.5+1 initialCapacity×1.5+1 后,向上取最小的 2 n 2^n 2n的值。如果超过最大容量一半,那么就是最大容量

put方法

1)put方法概览

ConcurrentHashMap在put方法的思路上与HashMap是相同的,但是在线程安全方面写了很多保障代码,具体思路如下,

  1. 调用put方法时,如果发现底层数组为空,则对数组进行初始化
  2. 通过hash值得到底层数组对应位置,判断数组该位置上是否存在元素。如果该位置目前没有元素,CAS进行创建,CAS创建失败则继续自旋,直至成功;如果该位置存在元素,则进入步骤3
  3. 如果该位置上存在节点对象,且该对象为转移节点(说明正在扩容),就会一直自旋等待扩容完成后再新增节点。如果该节点不是扩容节点,则进入步骤4
  4. 锁定该节点,保证其余线程不能操作。如果该节点是链表节点,则新增值添加到链表尾部。如果是红黑树节点,则使用红黑树的方式新增
  5. 新增完成后再检查一次是否需要扩容

具体源码如下,

final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    // 计算key的hash值
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;

		// 如果table数组为空,则初始化
		// 空的时候不上锁
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {	// 如果数组该位置上没有元素
        	//cas 在 i 位置创建新的元素,当 i 位置是空时,即能创建成功,结束for自循,否则继续自旋
            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 {
            V oldVal = null;
            synchronized (f) {
            	//这里再次判断 i 索引位置的数据没有被修改
                //binCount 被赋值的话,说明走到了修改表的过程里面
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) { // 链表
                        binCount = 1;
                        // 遍历链表,如果key已存在则依据onlyIfAbsent决定是否替换
                        // 如果不存在,则将新元素添到队尾
                        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;
                            }
                        }
                    }
                    // 如果是数组该位置是红黑树
                    // TreeBin 持有红黑树的引用,并且会对其加锁,保证其操作的线程安全
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        // putTreeVal方法在给红黑树重新着色旋转的时会锁住红黑树的根节点
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                       value)) != null) {
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
             //binCount不为0且oldVal有值说明新增成功了
            if (binCount != 0) {
            	// 链表是否需要转化成红黑树
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                if (oldVal != null)
                    return oldVal;

				// 这个break只有在链表或红黑树新增节点失败时候才执行,一般不会执行
                break;
            }
        }
    }
    // 检查底层数组是否需要扩容
    addCount(1L, binCount);
    return null;
}

上述代码整体思路如下方流程图,
image
为保证线程安全,更新节点的过程在上方代码中被封锁在synchronized代码块中,同步代码块的锁对象f是数组该位置上链表的头节点或红黑树的根节点

  • hash值映射位置在数组中是链表
    i. 遍历该链表
    ii. 如果存在同样hash值的节点,根于onlyIfAbsent的指定决定是否要覆盖
    iii. 如果不存在同样hash值的节点,创建新节点对象放在链表尾部
  • hash值映射位置在数组中是红黑树
    i. 通过TreeBin对象的putTreeVal方法存放键和值

保存完成后依据binCount变量的大小觉得是否将链表转换为红黑树,binCount变量用于保存链表节点的数量。

2)底层数组初始化的线程安全—initTable方法

数组初始化时,保证线程安全所采取的措施,

  1. 通过自旋保证一定可以初始化成功
  2. 通过CAS设置SIZECTL变量的值来保证同一时刻只能有一个线程对数组进行初始化
  3. CAS成功争锁后会再次判断数组是否已经完成初始化。如果已经完成初始化就不会再次执行初始化

故ConcurrentHashMap类通过自旋+CAS+双重检验的方式保证了数组在初始化时的线程安全。初始化数组的源码如下,

//初始化 table,通过对 sizeCtl 的变量赋值来保证数组只能被初始化一次
private final Node<K,V>[] initTable() {
    Node<K,V>[] tab; int sc;
    //通过自旋保证初始化成功
    while ((tab = table) == null || tab.length == 0) {
        // sizeCtl小于0代表有线程正在初始化table,释放当前 CPU 的调度权
        // table创建完成后,while循环跳出。if中同时还把sizeCtl的值赋值给了sc
        if ((sc = sizeCtl) < 0)
            Thread.yield(); 
        // 以CAS方式修改sizeCtl为-1,表示本线程已经开始创建table
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                // 争锁后再次判断数组没有被初始化
                if ((tab = table) == null || tab.length == 0) {
                	//如果sc有值,那么使用sc的值作为table的size,否则使用默认值16
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    @SuppressWarnings("unchecked")
                    Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                    table = tab = nt;
                    // 0.75*n,对应load factor
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

上面代码中变量sizeCtl格外重要,该值同时拥有多个含义,

  • -1,表示ConcurrentHashMap的底层数组正在被创建
  • -N,表示有 N-1个线程正在复制table数组
  • 在 table 被初始化前,代表根据构造函数传入的值计算出的应被初始化的大小
  • 在 table 被初始化后,则被设置为 table 大小 的 75%,代表 table 扩容的阈值

initTable方法中使用到了第1和4中sizeCtl的含义,第3个含义在构造方法中被使用。第2个含义在扩容方法中使用。

3)新增节点时的线程安全

新增节点时线程安全方面在原有思路上进行了4处优化,

  1. 通过自旋保证新增成功
  2. 如果新添加的节点是数组元素(hash值对应的数组的位置为null),通过CAS新增
    此处的代码非常严谨,线程争锁过程中,原本为null的位置可能已经添加了新元素,此时CAS失败。失败后会再次for循环,执行数组该索引处已存在元素时对应的代码
  3. 数组当前索引处已存在节点对象,则锁住该节点,进行后续添加工作
    image
  4. 红黑树添加节点后恢复平衡过程中锁住红黑树的根节点,保证同一时刻当前红黑树只能被一个线程操作
    image

4)数组扩容或链表转换红黑树—treeifyBin方法

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
      // 如果底层数组长度小于64,那么选择数组扩容,而不是把链表转为红黑树
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
      // 将数组index位置的链表转为红黑树。首先验证数组该位置不为null且是节点对象
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            synchronized (b) {
              	// 再一次确定数组该位置没有被修改。确认后开始转换
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    // 从头节点开始将链表节点转换成树节点
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val, null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    // TreeBin代表红黑树,将TreeBin保存在数组的index位置
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

tryPresize方法实现了对数组的扩容,传入的参数 size 是原来数组大小的一倍。假定原来数组大小为 16,则传入的 size 参数值为 32。以此数值为例分析源代码,

// size为32,sizeCtl为原大小16的3/4,也就是12
private final void tryPresize(int size) {
  	// 根据tableSizeFor方法计算出满足要求的数组大小
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
        tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    // 初始时sc和sizeCtl均为12,进入while循环
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        // 若底层数组table还未初始化,这是由于putAll操作不调用initTable方法,而是直接调用tryPresize方法
        if (tab == null || (n = tab.length) == 0) {
            // putAll第一次调用时,假设putAll进来的map只有一个元素,那么size传入1,计算出c为2.而sc和sizeCtl都为0,因此n=2
            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=2
                        sc = n - (n >>> 2);
                    }
                } finally {
                  //sizeCtl设置为2.第二次循环时,因为sc和c相等,都为2,进入下面的else if分支,结束while循环。
                    sizeCtl = sc;
                }
            }
        }
        // 扩容已经达到C值,结束扩容
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        // 数组已经存在,那么就对已有table进行扩容
        else if (tab == table) {
            int rs = resizeStamp(n);
            // sc小于0,说明别的线程正在扩容,本线程协助扩容
            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;
                // 未达上限,参与扩容,更新sizeCtl值。transfer方法负责把当前数组数据移入新的数组
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    transfer(tab, nt);
            }
            // 本线程为第一个扩容线程,transfer第二个参数传入null,代表需要新建扩容后的数组
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

5)底层数组扩容时的线程安全—transfer方法

ConcurrentHashMap和HashMap都会在put方法的最后判断数组是否需要扩容。但是二者扩容的过程完全不同,ConcurrentHashMap中对应的方法为transfer方法。该方法被put方法代码中的addCount方法调用。transfer方法的主要思路是,

  1. 首先将原数组的元素全部拷贝到扩容后的新数组上。拷贝方向是从尾到头的方向
  2. 拷贝数组时,拷贝到哪个位置就会先将该位置的节点上锁,保证该位置不会被其他线程操作。每次成功拷贝一个节点到新数组后,把原数组中该节点赋值为转移节点,表示该节点在扩容过程中已经添加到新数组里
  3. 当数组上节点为转移节点时,如果put方法要在该位置添加新节点,就会一直等待,直至扩容完成
  4. 全部拷贝完成后,直接把新数组赋值给原数组的变量table

transfer方法源码如下,

// tab:原数组,nextTab:新数组
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // 原数组的长度
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    // 如果新数组为空,初始化,大小为原数组的两倍,n << 1
    if (nextTab == null) {            // initiating
        try {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        transferIndex = n;
    }
    // 新数组的长度
    int nextn = nextTab.length;
    // 转移节点,如果原数组上是转移节点,说明该节点正在被扩容
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false;
    // 无限自旋,i 的值会从原数组的最大值开始,慢慢递减到 0
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            // 结束循环的标志
            if (--i >= bound || finishing)
                advance = false;
            // 已经拷贝完成
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // 每次减少 i 的值
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        // 任意条件满足说明拷贝结束了
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 拷贝结束,直接赋值,因为每次拷贝完一个节点,都在原数组上放转移节点,所以拷贝完成的节点的数据一定不会再发生变化。
            // 原数组发现是转移节点,是不会操作的,会一直等待转移节点消失之后在进行操作。
            // 也就是说数组节点一旦被标记为转移节点,是不会再发生任何变动的,所以不会有任何线程安全的问题
            // 所以此处直接赋值,没有任何问题。
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                // 进行节点的拷贝
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        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);
                        }
                        // 在新数组位置上放置拷贝的值
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        // 在老数组位置上放上 ForwardingNode 节点
                        // put 时,发现是 ForwardingNode 节点,就不会再动这个节点的数据了
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    // 红黑树的拷贝
                    else if (f instanceof TreeBin) {
                        // 红黑树的拷贝工作,同 HashMap 的内容,代码忽略
                        …………
                        // 在老数组位置上放上 ForwardingNode 节点
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

扩容过程一定要记住的几个关键点,

  • 拷贝过程中,会将原数组中该索引位置的节点上锁
  • 拷贝成功后,把原数组中的节点设置为转移节点。这样做的目的是扩容过程中,其他线程的put方法不会对原数组中的值进行改动
  • 拷贝方向是从原数组尾部到头部

get方法

get方法比较简单,整体思路和HashMap是相同的,

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    // 计算hashcode
    int h = spread(key.hashCode());
    // 底层数组非空且索引处节点元素非空
    // 否则该key对应的值不存在,返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        // 若数组索引处第一个节点的哈希值和计算所得的hash值相等,直接返回该节点
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //如果是红黑树或者转移节点,使用对应的find方法
        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;
}

总结

ConcurrentHashMap 中,通过大量的 CAS 操作加上 Synchronized 来确保线程安全。对 ConcurrentHashMap 的学习把重点放在哈希算法和扩容上,面试的时候是考察的重点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值