(十)深度剖析ConcurrentHashMap原理及源码

跟着Mic学架构

内容目录

ConcurrentHashMap

ConcurrentHashMap是JDK1.5引入的一个并发安全且高效的HashMap,简单来说,我们可以认为它在HashMap的基础上增加了线程安全性的保障。实际上,关于HashMap的线程安全问题,各位读者应该有一些了解,在JDK1.7的版本中,HashMap采用的是数组+链表的数据结构来存储数据,在多个线程并发执行扩容时,可能造成环形链导致死循环和数据丢失的情况;在JDK1.8中,HashMap采用数组+链表+红黑树的数据结构来存储数据,优化了1.7版本中数据扩容的方案解决了死循环和数据丢失的问题,但是在并发场景下调用put方法时,有可能会存在数据覆盖的问题。

为了解决线程安全问题带来的影响,我们可以采用一些具备线程安全性的集合,比如HashTable,它使用了synchronized关键字来保证线程安全性;还有Collections.synchronizedMap,它可以把一个线程不安全的Map,通过synchronized互斥锁的方式来达到安全性。但是这些方法都有一个问题,就是线程竞争比较激烈的情况下,效率都非常低。原因是他们都是以及方法层面使用synchronized实现的锁的机制,导致所有线程在操作数据时,不管是put还是get都需要去竞争同一把锁。

各位读者看过前面章节中分析的synchronized就应该明白,性能和安全性这两者只能做好平衡,无法两者都满足,而ConcurrentHashMap在性能和安全性方面的设计和实现非常的巧妙,它既能保证线程安全性,在性能方面也远远优于HashTable等集合。

读者需要注意,笔者是基于JDK1.8版本来分析ConcurrentHashMap,之所以要强调是因为不同的JDK版本在实现上有差异。

正确理解ConcurrentHashMap的线程安全性

ConcurrentHashMap本身就是一个HashMap,因此在实际应用上,只需要考虑到当前场景是否存在多线程并发访问同一个Map实例,如果存在,则采用ConcurrentHashMap。但是,各位读者需要注意的是,ConcurrentHashMap的线程安全特性,只是保证多线程并发执行操作时,容器中的数据不会被破坏,但是对于涉及到多个线程的复合操作,ConcurrentHashMap无法保证业务行为的正确性。

举个例子,假设我们需要通过一个ConcurrentHashMap来记录每个用户的访问次数,如果针对指定用户已经有访问次数的记录,则进行递增,否则,则添加一个新的访问记录,代码如下。

private static final ConcurrentMap<String, Long> USER_ACCESS_COUNT = new ConcurrentHashMap<>(64);
public static void main(String[] args) throws InterruptedException {
    Long accessCount=USER_ACCESS_COUNT.get("mic");
    if(accessCount==null){
        USER_ACCESS_COUNT.put("mic",1L);
    }else{
        USER_ACCESS_COUNT.put("mic",accessCount+1);
    }
}

上述代码在多线程并发调用时,会存在线程安全问题,虽然ConcurrentHashMap对于数据操作本身是安全的,但是在上述代码中是一个复合操作,也就是读-修改-写,而这个三个操作不是原子的,所以当多个线程访问同一个用户mic时,很可能会覆盖相互操作的结果,造成记录的次数少于实际记录。

因此笔者想在这里说明的一个点是,虽然ConcurrentHashMap是线程安全的,但是对于ConcurrentHashMap的复合操作行为,需要我们去关注。当然,上述问题其实解决的方案有很多,比如我们针对这个复合操作进行加锁,但是ConcurrentHashMap提供了另外一个解决办法,就是使用ConcurrentMap接口定义的方法来做。

ConcurrentMap是一个可以支持并发访问的Map集合,相当于在原本的Map集合上新增了一些方法来扩展原有Map的功能,而ConcurrentHashMap实现了ConcurrentMap接口。

public interface ConcurrentMap<K, V> extends Map<K, V> {

    V putIfAbsent(K key, V value); 
    boolean remove(Object key, Object value);   
    boolean replace(K key, V oldValue, V newValue); 
    V replace(K key, V value);   
    //此处省略JDK1.8中的default方法
}

ConcurrentMap接口定义的四个方法都满足原子性,可以用在对ConcurrentHashMap的复合操作场景中,方法说明如下:

  • putIfAbsent: 插入数据到ConcurrentHashMap集合,如果插入的key不存在于集合中,则把当前数据保存并且返回null。如果key已经存在,则返回存在的key对应的value。
  • remove:根据keyvalue来删除ConcurrentHashMap集合中的元素,该删除操作必须保证key-value完全匹配,如果匹配成功返回true,否则返回false。
  • replace(K,V,V): 根据keyoldValue来替换ConcurrentHashMap中已经存在的值,新的值是newValue,该替换操作必须保证key-oldValue完全匹配,替换成功返回true,否则返回false。
  • replace(K,V):和replace(k,v,v)不同的点在于,少了对oldValue的判断,如果替换成功,返回替换之前的value,否则,返回null。

通过ConcurrentMap提供的这些方法,我们可以针对前面的代码进行线程安全性改造如下。

private static final ConcurrentMap<String, Long> USER_ACCESS_COUNT = new ConcurrentHashMap<>(64);
public static void main(String[] args) throws InterruptedException {
    while(true) {
        Long accessCount = USER_ACCESS_COUNT.get("mic");
        if (accessCount == null) {
            if(USER_ACCESS_COUNT.putIfAbsent("mic", 1L)==null){
                break;
            }
        } else {
            if(USER_ACCESS_COUNT.replace("mic", accessCount, accessCount + 1)){
                break;
            }
        }
    }
}

代码量看起来多了一些,主要是改造了原本的put方法,针对于第一次添加使用putIfAbsent,对于已经存在的数据的修改使用replace方法,由于这两个方法都能保证原子性,所以能够避免多线程并发的影响。同时,增加了一个while(true)实现一个类似自旋的操作,保证本次操作的成功执行。

另外,在JDK1.8中,ConcurrentMap引入了一些支持lambda表达式的原子操作,源码如下。

public interface ConcurrentMap<K, V> extends Map<K, V> {
    default V computeIfAbsent(K key,Function<? super K, ? extends V> mappingFunction) 
    default V computeIfPresent(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction)
    default V compute(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction)
    default V merge(K key, V value,BiFunction<? super V, ? super V, ? extends V> remappingFunction)
}

上述几个方法都是JDK1.8引入的default方法,这些方法的作用说明如下:

computeIfAbsent

判断传入的key是否存在来对ConcurrentMap集合进行数据初始化操作,如果存在,则不作任何处理。如果不存在,则调用mappingFunction计算出value值,然后把key=value存入到ConcurrentHashMap中。由于mappingFunction是一个函数式接口,所以它的返回值也会影响到存储结果。

  • 如果mappingFunction返回的value不为null,则把key=value存储进去。
  • 如果mappingFunction返回的value为null,由于ConcurrentHashMap不允许value为null,所以不会存储进去,返回null。

如果mic这个用户不存在,则通过下面这段代码会初始化mic这个用户的值为10

USER_ACCESS_COUNT.computeIfAbsent("mic",k->10L);

computeIfPresent

computeIfAbsent方法的作用相反,对已经存在的key对应的value值进行修改,如果key不存在,则返回null。如果key存在,则调用remappingFunction进行运算,根据返回value的情况作出不同的处理。

  • 如果remappingFunction返回的value不为null,则修改当前key的value为remappingFunction的值。
  • 如果remappingFunction返回的value为null,则删除当前的key,相当于调用了remove(key)方法。
  • 如果remappingFunction方法中抛出异常,原本key对应的value值不会发生变化。

如果我们想针对mic这个已经存在的用户的value进行修改,可以这样使用。

USER_ACCESS_COUNT.computeIfPresent("mic",(k,v)->v+1);

compute

compute相当于computeIfAbsentcomputeIfPresent的结合体,它不管key是否存在,都会调用remappingFunction进行计算。如果key存在,则调用remappingFunction对value进行修改;如果key不存在,同样调用remappingFunction方法进行初始化。

通过compute方法,我们可以把前面演示的那段很长的代码,通过一行代码就可以解决。

USER_ACCESS_COUNT.compute("mic",(k,v)->(v==null)?1L:v+1);

这段代码的含义是,如果mic这个key存在,则通过后面的lambda表达式对value进行v+1的修改,否则,初始化为1L

merge

这个方法翻译过来的意思是合并,对ConcurrentHashMap相同key的value值可以选择进行合并。它包含三个参数keyvalueremappingFunction函数式接口。它的作用是:

  • 当ConcurrentHashMap不存在指定的key时,把传入的value设置为key的值。
  • 当ConcurrentHashMap中存在指定的key时,执行remappingFunction方法,主意看BiFunction<? super V, ? super V, ? extends V>,这里接收的是两个value,第一个表示当前key的oldValue,第二个参数是表示新传入的value。使用者可以执行自定义逻辑返回最终结果并设置为key的值,它可以有几种使用方式,分别对应不同的使用场景。

    • 如果这么写(oldValue,newValue)->newValue,表示把当前key的value修改为newValue
    • 如果这么写(oldValue,newValue)->oldValue,表示保留oldValue不做修改。
    • 也可以通过(oldValue,newValue)->oldValue+newValue,对新老两个值进行合并。
    • 甚至可以`(oldValue,newValue)->null,删除当前的key。

针对merge,举一个比较简单的demo,针对一个集合中相同元素的key,进行累计合并,代码如下。

public static void main(String[] args) {
    ConcurrentMap<Integer,Integer> cm=new ConcurrentHashMap<>();
    Stream.of(1,2,8,2,5,6,5,8,3,8).forEach(v->{
        cm.merge(v,2,Integer::sum);
    });
    System.out.println(cm);
}

ConcurrentHashMap的数据结构

在JDK1.8中,ConcurrentHashMap采用数组+链表+红黑树的方式来实现数据存储,数据结构如下图所示。

相比较于JDK1.7,它做了如下改进:

  • 取消了 segment分段设计,直接使用Node数组来保存数据,并且采用Node数组元素作为锁的范围,进一步减少了并发冲突的范围和概率。
  • 引入红黑树设计,降低了极端情况下查询某个节点数据的时间复杂度,从O~(n)~降低到了O~(logn)~,提升了查找性能。

在前文提到过,ConcurrentHashMap为了在性能和安全性方面做好平衡,使用了一些比较巧妙的设计,主要体现在以下几个方面。

  • 分段锁的设计。
  • 多个线程协助实现并发扩容。
  • 高低位迁移设计。
  • 链表转红黑树以及红黑树转链表。
  • 降低了锁的粒度。

那么接下来,我们针对ConcurrentHashMap的核心源码做一个全面分析,帮助大家更好的了解ConcurrentHashMap的底层实现。

关于数据存储相关定义

ConcurrentHashMap采用Node数组来存储数据,该数组默认长度为16,代码如下。

private static final int DEFAULT_CAPACITY = 16;
transient volatile Node<K,V>[] table;

Node表示数组中的一个具体的数据节点,其定义如下。

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

Node实现了Map.Entry接口的对象,并且声明了几个成员属性:

  • hash:当前key对应的hash值。
  • key/val: 表示实际存储的key和value。
  • next:如果是链表结构,指向的下一个Node节点的指针。

前面说过,当链表长度大于等于8且Node数组长度大于64时,链表会转化为红黑树,红黑树的存储是采用TreeNode来实现,定义如下。

static final class TreeNode<K,V> extends Node<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;

        TreeNode(int hash, K key, V val, Node<K,V> next,
                 TreeNode<K,V> parent) {
            super(hash, key, val, next);
            this.parent = parent;
        }
    //省略....
}

Node数组初始化过程分析

Node数组的初始化过程是被动的,也就是当我们调用put方法或者Java8中ConcurrentMap提供的default方法时,如果发现Node[]没有被初始化,则会调用initTable方法完成初始化过程。

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();
        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
            }
            //省略....
        }
    //省略....
}

put方法实际调用的是putVal来做数据存储,putVal的上述代码中,逻辑说明如下:

  • 根据key计算出一个hash值。
  • 通过没有结束条件的for循环实现自旋锁。
  • 通过Node<K,V>[] tab = table把Node数组table赋值给一个临时变量tab,首先判断tabl是否为空,如果为空,则调用initTable()初始化。

initTable方法的代码如下。

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

initTable方法和一般初始化方法不同,因为它需要考虑到多线程并发的安全问题,实现逻辑如下:

  • while ((tab = table) == null || tab.length == 0)循环的退出条件是table初始化成功,否则一直循环,这里其实也是用到自旋锁的机制,因为多个线程调用initTable方法必然会产生竞争,而竞争的情况下如果不采用同步锁机制,就只能通过自旋的方式不断重试。
  • if ((sc = sizeCtl) < 0)这个是用来判断当前是否已经有其他线程正在进行初始化,如果由,则通过Thread.yield()把自己变成就绪状态,释放CPU资源。
  • 这段代码(U.compareAndSwapInt(this, SIZECTL, sc, -1))是用到了CAS原子操作修改sizeCtl变量来表示抢占到了锁,CAS会有两个返回值,true表示抢到了锁,false表示抢占失败,对于抢占失败的线程,继续进入下一次while循环重试。这段设计主要是避免多个线程同时出发初始化造成数据丢失的问题。
  • 再次通过if ((tab = table) == null || tab.length == 0)判断tab是否为空,再次判断的原因是在于sizeCtl不仅仅只有一种含义,我们可以看到在finally方法中会调用sizeCtl=sc重新赋值为一个扩容阈值,这个值一定是大于0的,意味着有可能存在通过Thread.yield让出CPU资源的线程再次执行到初始化table的方法中来。
  • 通过Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];Node数组的初始化,并且赋值给ConcurrentHashMap的全局Node数组table
  • 最后,使用sc = n - (n >>> 2);计算下次扩容的阈值,阈值的计算是当前数组容量的0.75倍,并且重新赋值给sizeCtl这个属性。实际上sizeCtl包含几种不同的含义

    • sizeCtl=-1,表示当前有线程抢占到了初始化数组的资格,正在初始化数组。
    • sizeCtl=-N,用sizeCtl值的二进制低16位来记录当前参与扩容的线程数量。
    • sizeCtl=0,表示数组未初始化并且在ConcurrentHashMap构造方法中没有指定初始容量。
    • sizeCtl>0,如果数组已经初始化,那么sizeCtl表示的是扩容的阈值(初始容量*0.75),如果未初始化,则表示数组的初始化容量。

完成初始化之后代码的分析之后,继续回到putVal方法如下部分。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    //省略....
    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
    }
    //省略....
}

通过(n - 1) & hash)来计算当前key在table数组中对应的下标位置,如果该位置还没有任何值,则把当前的key/value封装成Node,使用casTabAt方法修改到指定数组下标位置。这里用casTabAt是一种线程安全的更新机制,如果更新成功,则返回true,否则返回false继续下一次循环重试。

至此,对于数组初始化的过程就分析完成了,为了更好的理解,笔者基于图形的方式整理了整个初始化过程,如下图所示。

注: 扩容因子为什么设置为0.75呢?其实是一种时间和空间成本的折中考虑,在ConcurrentHashMap中有一段注释如下。

/** However, statistically, under
  * random hash codes, this is not a common problem.  Ideally, the
  * frequency of nodes in bins follows a Poisson distribution
  * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
  * parameter of about 0.5 on average, given the resizing threshold
  * of 0.75, although with a large variance because of resizing
  * granularity. Ignoring variance, the expected occurrences of
  * list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The
  * first values are:
  *
  * 0:    0.60653066
  * 1:    0.30326533
  * 2:    0.07581633
  * 3:    0.01263606
  * 4:    0.00157952
  * 5:    0.00015795
  * 6:    0.00001316
  * 7:    0.00000094
  * 8:    0.00000006
  * more: less than 1 in ten million
**/

理想情况下,bin中的节点频率遵循泊松分布(http://en.wikipedia.org/wiki/...),使用0.75作为负载因子,哈希膨胀的概率遵循约为0.5的泊松分布,也就是说可以降低节点在某一个特定桶中出现的概率。

另外,在注释中可以看到,当链表长度达到8的时候,也就是说哈希冲突出现8次的概率为0.00000006,几乎是不可能的事情,这也从另外一个层面去尽量避免链表转红黑树的出现。

单节点到链表的转化过程分析

使用put方法向ConcurrentHashMap存入数据时,是基于Key使用哈希函数计算后得到一个指定的数组下标进行数据存储,这种存储结构我们也称为哈希表。哈希表本身是一个有限大小的数据结构,所以对于任何hash函数,都可能会出现不同元素的key得到一个相同的哈希值从而映射到同一个位置的情况,这种情况我们成为hash冲突。而解决hash冲突有比较多成熟的方法,常见的方法是:

  • 开放寻址法,(ThreadLocal就是采用开放寻址中的线性探索),也就是说如果i这个位置被占用,那就探查i+1i+2i+3的位置。
  • 链式寻址,就是hash表的每个位置都连接一个链表,当发生hash冲突时,冲突的元素将会被加入到这个位置链表的最后。
  • 再hash法,就是提供多个不同的hash函数,当发生冲突时,使用第二个、第三个等。
  • ....

而ConcurrentHashMap中,解决hash冲突的方法就是基于链式寻址法,putVal方法中,解决hash冲突的代码如下。

final V putVal(K key, V value, boolean onlyIfAbsent) {
    //省略部分代码....
    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;
                    }
                }
            }
        }
       //省略部分代码....
}

这段代码比较长,但是整体逻辑并不算复杂,简单分析一下核心代码。

  • 使用synchronized (f) 对当前数组位置的节点加锁,这种锁的力度控制在单个数据节点上,在16位长度的数组,理论上可以支持16个线程并发写入数据。
  • 接着,通过fh >= 0f instanceof TreeBin判断当前节点是链表还是红黑树,因为针对两种不同的结构,数据的处理方式也不同。
  • 通过for (Node<K,V> e = f;; ++binCount),从链表的头节点开始往下遍历,遍历的每个节点。

    • 如果存在相同的key((e.hash == hash &&((ek = e.key) == key ||(ek != null && key.equals(ek))))),则修改该key的value。
    • 否则,把当前的key-value插入到链表的最后一个节点。
  • 如果当前节点类型是红黑树,则通过((TreeBin<K,V>)f).putTreeVal(hash, key,value))完成key-value的存储或者修改。

综上所述,便是ConcurrentHashMap中基于链式寻址解决hash冲突的方法,通过图形方式表达如下图所示,调用put方法存入一对键值对,如果当前key计算得到的数组下标位置已经存在一个Node,并且该Node是链表类型,则添加到该链表的尾部。

扩容还是转化为红黑树?

当链表长度大于或者等于8的时候,ConcurrentHashMap认为链表已经有点长了,需要考虑去优化,而优化方式有两种。

  • 对数组进行扩容。当数组长度小于等于64,并且链表长度大于等于8(binCount >= TREEIFY_THRESHOLD)时,优先选择对数据扩容。
  • 把链表转化为红黑树。当数组长度大于64,并且链表长度大于等于8时,会把链表转化为红黑树。
final V putVal(K key, V value, boolean onlyIfAbsent) {
    //省略....
    if (binCount != 0) {
        if (binCount >= TREEIFY_THRESHOLD)
            treeifyBin(tab, i);
        if (oldVal != null)
            return oldVal;
        break;
    }
    //省略....
}

在上述代码中,binCount表示的是链表的个数,如果binCount>=TREEIFY_THRESHOLD(TREEIFY_THRESHOLD默认值为8),则调用treeifyBin方法进行后续处理。

treeifyBin

treeifyBin方法的主要作用是根据相关阈值来决定扩容还是把链表转化为红黑树。

private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n, sc;
    if (tab != null) {
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        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;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

上面这段代码的逻辑说明如下:

  • (n = tab.length) < MIN_TREEIFY_CAPACITY判断当前数组的长度是否小于64,如果是,则调用tryPresize进行扩容。
  • 否则,构建一个TreeNode保存到插入到红黑树中。

tryPresize

tryPresize是用来实现扩容的方法,代码如下。

private final void tryPresize(int size) {
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
    tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        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);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        else if (tab == table) {
            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;
                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);
        }
    }
}

我们把tryPresize这个方法的代码分四个部分来看。

第一个部分

对扩容的大小size进行判断。

private final void tryPresize(int size) {
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
    tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    //省略部分代码...
}

其中, (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY是用来判断当前要扩容的目标大小size的值,如果大小为MAXIMUM_CAPACITY的一半,则直接设置扩容大小为MAXIMUM_CAPACITY,否则通过tableSizeFor来计算当前size的最小的幂次方,也就是说如果当前传入的size不等于2的n次幂,那么通过tableSizeFor就可以整形成离size最近的一个幂次方的值。

第二个部分

判断table是否初始化,这部分代码和前面分析的initTable方法一样。至于这里为什么会有这样一个判断,原因是在ConcurrentHashMap中的putAll方法中,有调用tryPresize进行初始化功能。

private final void tryPresize(int size) {
    //省略....
    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);
                }
            } finally {
                sizeCtl = sc;
            }
        }
    }
    //省略....
}

第三个部分

通过(c <= sc || n >= MAXIMUM_CAPACITY)进行扩容判断,判断的逻辑有两个。

  • c<=sc,说明table数组已经被其他线程完成了扩容,则不需要再进行扩容。
  • n >= MAXIMUM_CAPACITY,说明table数组已经达到了最大容量,无法再扩容了。
private final void tryPresize(int size) {
    //省略部分代码...
    else if (c <= sc || n >= MAXIMUM_CAPACITY)
                break;
    //省略部分代码...
}

第四个部分

正式开始执行扩容操作,这部分代码也有两个比较核心的逻辑。

  • 如果当前已经有其他线程在执行扩容,也就是sc<0,并且当前线程可以协助扩容的情况下,调用transfer方法“协助扩容”。
  • 如果当前没有其他线程在进行扩容,则当前线程成为第一个执行transfer方法的线程,两次都是调用同一个方法,但是第一次扩容时调用transfer时,第二个参数nextTab是空,nextTab表示扩容之后的新的table数组,如果为null,表示首次发起扩容。
private final void tryPresize(int size) {
    //省略部分代码....
    else if (tab == table) {
        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;
            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);
    }
    //省略部分代码....
}

上述代码中有一些比较有意思的设计,笔者简单说明一下。

  • resizeStamp这个方法,实际上是根据当前数组长度n来生成一个和扩容有关的扩容戳,它的具体实现如下。

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

    其中,Integer.numberOfLeadingZeros 这个方法是返回无符号整数n最高位非0位前面的0的个数,什么意思呢?比如10这个数字的二进制是0000 0000 0000 0000 0000 0000 0000 1010

    那么这个方法的返回值就是28。另外通过| (1 << (RESIZE_STAMP_BITS - 1)运算,把前面计算出来的28的二进制第16位设置为1。

    因此resizeStamp会返回一个32位的ing类型的值,它的格式是0000 0000 0000 0000 1xxx xxxx xxxx xxxx

  • 接着我们看如果是第一次扩容,会通过U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2)这段代码,修改sizeCtl的值为rs << RESIZE_STAMP_SHIFT) + 2,表示此时只有一个线程在执行扩容。

    rs << RESIZE_STAMP_SHIFT) + 2这行代码理解起来不难,相当于把上一个步骤返回的扩容戳的二进制数据左移16位,相当于原本的二进制的低位变成了高位,再到低位增加2来表示当前有一个线程正在扩容。

    为了方便理解,举一个具体的例子,加入当前table的长度是16,也就是n=16

    • 通过resizeStamp方法得到一个二进制数据0000 0000 0000 0000 1000 0000 0001 1100
    • 对上述二进制左移16位并且+2,得到1000 0000 0001 1100 0000 0000 0000 0010,由于高位是1,所以转化为十进制一定是一个负数,这也是前面笔者在讲解sizeCtl的含义时提到的。

    最终,这个二进制数字包含两部分的含义,

    • 高16位1000 0000 0001 1100表示扩容标记,由于每次扩容时n的值都不同,因此能保证每次扩容时这个标记的唯一性。
    • 低16位0000 0000 0000 0010表示并行扩容的线程数量。

    之所以要这么复杂的设计,最根本的原因是ConcurrentHashMap支持并发扩容,也就是允许多个线程同时对一个数组进行扩容(笔者在后续的小节中会详细分析)。因此这么设计的好处是为了保证每次扩容时都生成一个唯一的扩容戳,以及记录并行扩容的线程数量。

  • 基于上面两个点分析之后,就明白为什么sc < 0表示有其他线程正在扩容,因为sc的值一定是一个负数。并且当前如果已经有线程正在扩容,而且允许当前线程来协助扩容的情况下,会通过U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)方法,来增加一个扩容线程,本质上是在sizeCtl的二进制低位增加1来记录一个扩容线程。

至此,ConcurrentHashMap扩容前置的一些基本操作就分析完成了,接下来跟着笔者一起分析并发扩容的核心代码。

阅读 37

并发编程
并发编程

《Spring Cloud Alibaba 微服务原理与实战》、《Java并发编程深度理解及实战》作者。 咕泡教育联合创始...

322 声望
800 粉丝
0 条评论
你知道吗?

《Spring Cloud Alibaba 微服务原理与实战》、《Java并发编程深度理解及实战》作者。 咕泡教育联合创始...

322 声望
800 粉丝
文章目录
宣传栏