并发编程从零开始(八)-ConcurrentHashMap

并发编程从零开始(八)-ConcurrentHashMap

5.5 ConcurrentHashMap

HashMap通常的实现方式是“数组+链表”,这种方式被称为“拉链法”。ConcurrentHashMap在这个基本原理之上进行了各种优化。

首先是所有数据都放在一个大的HashMap中;其次是引入了红黑树。

其原理如下图所示:

image-20211027165449068

如果头节点是Node类型,则尾随它的就是一个普通的链表;如果头节点是TreeNode类型,它的后面就是一颗红黑树,TreeNode是Node的子类。

链表和红黑树之间可以相互转换:初始的时候是链表,当链表中的元素超过8并且数组长度超过64时,把链表转换成红黑树;反之,当红黑树中的元素个数小于6时,再转换为链表。

image-20211027173514379

jdk1.7升级到1.8,ConcurrentHashMap的变化:

  • 锁方面: 由分段锁(Segment继承自ReentrantLock)升级为 CAS+synchronized实现;
    【注】:CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?
    答:synchronized之前一直都是重量级的锁,性能差,但是后来java官方(jdk1.5还是1.6)对它进行了优化:针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程,然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的(偏向锁–>CAS轻量级锁–>自旋–>重量级锁)。
  • 数据结构层面: 将Segment变为了Node,减小了锁粒度,使每个Node独立,由原来默认的并发度16变成了每个Node都独立,提高了并发度;
  • hash冲突: 1.7中发生hash冲突采用链表存储,1.8中先使用链表存储,后面满足条件后会转换为红黑树来优化查询;
    [注]: Hashmap中的链表大小超过八个时会自动转化为红黑树,当删除小于六时重新变为链表,为啥呢?
    答:根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
  • 查询复杂度: jdk1.7中链表查询复杂度为O(N),jdk1.8中红黑树优化为O(logN));

下面从构造方法开始,一步步深入分析其实现过程:

1. 构造方法分析

image-20211027174045721

image-20211027174110730

在上面的代码中,变量cap就是Node数组的长度,保持为2的整数次方。tableSizeFor(...)方法是根据传入的初始容量,计算出一个合适的数组长度。具体而言:1.5倍的初始容量+1,再往上取最接近的2的整数次方,作为数组长度cap的初始值。

这里的 sizeCtl,其含义是用于控制在初始化或者并发扩容时候的线程数,只不过其初始值设置成cap。

2. 初始化

在上面的构造方法里只计算了数组的初始大小,并没有对数组进行初始化。当多个线程都往里面放入元素的时候,再进行初始化。这就存在一个问题:多个线程重复初始化。下面看一下是如何处理的。

image-20211027174455711

通过上面的代码可以看到,多个线程的竞争是通过对sizeCtl进行CAS操作实现的。如果某个线程成功地把 sizeCtl 设置为-1,它就拥有了初始化的权利,进入初始化的代码模块,等到初始化完成,再把sizeCtl设置回去;其他线程则一直执行while循环,自旋等待,直到数组不为null,即当初始化结束时,退出整个方法。

3. put实现分析

image-20211027174633213

image-20211027174659738

image-20211027174726515

上面的for循环有4个大的分支:

第1个分支,是整个数组的初始化,前面已讲;在put操作的时候,才会进行初始化操作,与hashmap同样为懒加载。

第2个分支,是所在的槽为空,说明该元素是该槽的第一个元素,直接新建一个头节点,然后返回;

第3个分支,说明该槽正在进行扩容,帮助其扩容;

第4个分支,就是把元素放入槽内。槽内可能是一个链表,也可能是一棵红黑树,通过头节点的类型可以判断是哪一种。第4个分支是包裹在synchronized (f)里面的,f对应的数组下标位置的头节点,意味着每个数组元素有一把锁,并发度等于数组的长度。

上面的binCount表示链表的元素个数,当这个数目超过TREEIFY_THRESHOLD=8时,把链表转换成红黑树,也就是 treeifyBin(tab,i)方法。但在这个方法内部,不一定需要进行红黑树转换,可能只做扩容操作,所以接下来从扩容讲起。

4.扩容

扩容的实现是最复杂的,下面从treeifyBin(Node<K,V>[] tab, int index)讲起。

image-20211027175041010

在上面的代码中,MIN_TREEIFY_CAPACITY=64,意味着当数组的长度没有超过64的时候,数组的每个节点里都是链表,只会扩容,不会转换成红黑树。只有当数组长度大于或等于64时,并且链表长度大于等于8(putVal方法的末尾进行了比较),才考虑把链表转换成红黑树。

tryPresize(int size)内部调用了一个核心方法 transfer(Node<K,V>[] tab,Node<K,V >[] nextTab),先从这个方法的分析说起。

该方法十分复杂,建议看后面的文字解读。

image-20211027175518062

文字解读:

  1. 扩容的基本原理如下图,首先建一个新的HashMap,其数组长度是旧数组长度的2倍,然后把旧的元素逐个迁移过来。所以,上面的方法参数有2个,第1个参数tab是扩容之前的HashMap,第2个参数nextTab是扩容之后的HashMap。当nextTab=null的时候,方法最初

    会对nextTab进行初始化。这里有一个关键点要说明:该方法会被多个线程调用,所以每个线程只是扩容旧的HashMap部分,这就涉及如何划分任务的问题。

    image-20211027175617668

  2. 上图为多个线程并行扩容-任务划分示意图。旧数组的长度是N,每个线程扩容一段,一段的长度用变量stride(步长)来表示,transferIndex表示了整个数组扩容的进度。

    stride的计算公式如上面的代码所示,即:在单核模式下直接等于n,因为在单核模式下没有办法多个线程并行扩容,只需要1个线程来扩容整个数组;在多核模式下为 (n>>>3)/NCPU,并且保证步长的最小值是 16。显然,需要的线程个数约为n/stride。

    image-20211027175706325

    transferIndex是ConcurrentHashMap的一个成员变量,记录了扩容的进度。初始值为n,从大到小扩容,每次减stride个位置,最终减至n<=0,表示整个扩容完成。因此,从[0,transferIndex-1]的位置表示还没有分配到线程扩容的部分,从[transfexIndex,n-1]的位置表示已经分配给某个线程进行扩容,当前正在扩容中,或者已经扩容成功。

    因为transferIndex会被多个线程并发修改,每次减stride,所以需要通过CAS进行操作。

    image-20211027175745063

    image-20211027175808948

  3. 在扩容未完成之前,有的数组下标对应的槽已经迁移到了新的HashMap里面,有的还在旧的HashMap 里面。这个时候,所有调用 get(k,v)的线程还是会访问旧 HashMap,怎么处理

呢?

下图为扩容过程中的转发示意图:当Node[0]已经迁移成功,而其他Node还在迁移过程中时,如果有线程要读取Node[0]的数据,就会访问失败。为此,新建一个ForwardingNode,即转发节点,在这个节点里面记录的是新的 ConcurrentHashMap 的引用。这样,当线程访问到ForwardingNode之后,会去查询新的ConcurrentHashMap。

  1. 因为数组的长度 tab.length 是2的整数次方,每次扩容又是2倍。而 Hash 函数是hashCode%tab.length,等价于hashCode&(tab.length-1)。这意味着:处于第i个位置的元素,在新的Hash表的数组中一定处于第i个或者第i+n个位置,如下图所示。举个简单的例子:假设数组长度是8,扩容之后是16:

若hashCode=5,5%8=0,扩容后,5%16=0,位置保持不变;

若hashCode=24,24%8=0,扩容后,24%16=8,后移8个位置;

若hashCode=25,25%8=1,扩容后,25%16=9,后移8个位置;

若hashCode=39,39%8=7,扩容后,39%8=7,位置保持不变;

……

image-20211027180003886

正因为有这样的规律,所以如下有代码:

image-20211027180027473

也就是把tab[i]位置的链表或红黑树重新组装成两部分,一部分链接到nextTab[i]的位置,一部分链接到nextTab[i+n]的位置,如上图所示。然后把tab[i]的位置指向一个ForwardingNode节点。

同时,当tab[i]后面是链表时,使用类似于JDK 7中在扩容时的优化方法,从lastRun往后的所有节点,不需依次拷贝,而是直接链接到新的链表头部。从lastRun往前的所有节点,需要依次拷贝。

了解了核心的迁移函数transfer(tab,nextTab),再回头看tryPresize(int size)函数。这个函数的输入是整个Hash表的元素个数,在函数里面,根据需要对整个Hash表进行扩容。想要看明白这个函数,需要透彻地理解sizeCtl变量:

当sizeCtl=-1时,表示整个HashMap正在初始化;

当sizeCtl=某个其他负数时,表示多个线程在对HashMap做并发扩容;

当sizeCtl=cap时,tab=null,表示未初始之前的初始容量(如上面的构造函数所示);

扩容成功之后,sizeCtl存储的是下一次要扩容的阈值,即上面初始化代码中的n-(n>>>2)=0.75n。

所以,sizeCtl变量在Hash表处于不同状态时,表达不同的含义。明白了这个道理,再来看上面的tryPresize(int size)函数。

image-20211027180150048

tryPresize(int size)是根据期望的元素个数对整个Hash表进行扩容,核心是调用transfer函数。在第一次扩容的时候,sizeCtl会被设置成一个很大的负数U.compareAndSwapInt(this,SIZECTL,sc,(rs << RESIZE_STAMP_SHIFT)+2);之后每一个线程扩容的时候,sizeCtl 就加 1,U.compareAndSwapInt(this,SIZECTL,sc,sc+1),待扩容完成之后,sizeCtl减1。

posted @ 2021-10-27 18:26  会编程的老六  阅读(87)  评论(0编辑  收藏  举报