文章目录
HashMap通常的实现方式是“数组”+“链表”,这种方式被称为拉链法。
HashMap源码解读
ConcurrentHashMap在这个基本原理上进行了各种优化,在JDK1.7和JDK1.8中的实现有很大差异。我在这里只说在JDK1.8中的实现方式。ConcurrentHashMap是一个高效的并发HashMap,可以把它理解为一个线程安全的HashMap
1:JDK1.8中ConcurrentHashMap的实现方式
JDK1.8的实现有了很大变化,首先是没有了分段锁,所有的数据都存放在一个大的HashMap中,其次是引入了红黑树,原理如下图
如果头节点是Node类型,则尾随它的就是一个普通的链表,如果头节点是TreeNode类型,它的后面就是一颗红黑树, TreeNode 是Node的子类。
链表和红黑树之间可以相互转换:初始的时候是链表,当链表中的元素超过某个阈值时,把链表转换成红黑树:反之,当红黑树中的元素个数小于某个阈值时,再转换为链表。
那为什么JDK8要做这种改变呢?
JDK7原理图
在JDK7中的分段锁,有三个好处:
(1)减少Hash冲突,避免一个槽里有太多元素。
(2)提高读和写的并发度。段与段之间相互独立。
(3)提供扩容的并发度。扩容的时候,不是整个ConcurrentHashMap 一起扩容,而是每个Segment独立扩容。
针对这三个好处,我们来看一下 在JDK 8中相应的处理方式:
(1)使用红黑树,当一个槽里有很多元素时,其查询和更新速度会比链表快很多,Hash冲突的问题由此得到较好的解决。
(2)加锁的粒度,并非整个CourentHashMap,而是对每个头节点分别加锁,即并发度,就是Node数组的长度,初始长度为16,和在JDK 7中初始Segment的个数相同。
(3)并发扩容,这是难度最大的。在JDK 7中,一旦Segment的个数在初始化的时候确立,不能再更改,并发度被固定。之后只是在每个Segment内部扩容,这意味着每个Segment独立扩容,互不影响,不存在并发扩容的问题。但在JDK8中,相当于只有1个Segnment,当一个线程要扩容Node数组的时候,其他线程还要读写, 因此处理过程很复杂,后面会详细分析。
总结: JDK 8的实现方面降低 了Hah冲突,另一方面也提升了并发度。
下面从构造函数开始,一 步步深入分析其实现过程。
1.1 构造函数分析
下面是源代码分析情况
sizeCtl含义解释
注意:以上这些构造方法中,都涉及到一个变量sizeCtl,这个变量是一个非常重要的变量,而且具有非常丰富的含义,它的值不同,对应的含义也不一样,这里我们先对这个变量不同的值的含义做一下说明,后续源码分析过程中,进一步解释
sizeCtl为0,代表数组未初始化,且数组的初始容量为16
sizeCtl为正数,如果数组未初始化,那么其记录的是数组的初始容量,如果数组已经初始化,那么其记录的是数组的扩容阈值(数组的初始容量*0.75)
sizeCtl为-1,表示数组正在进行初始化
sizeCtl小于0,并且不是-1,表示数组正在扩容,-(1+n),表示此时有n个线程正在共同完成数组的扩容操作
1.2 初始化分析
在上面的构造函数里只计算了数组的初始大小,并没有对数组进行初始化。当多个线程都往里面放入元素的时候,再进行初始化。这就存在一个问题:多个线程重复初始化。下 面看下是如何处理的。
通过上面的代码可以看到,多个线程的竞争是通过对sizcCtl 进行CAS操作实现的。如果某个线程成功地把sizeCtl设置为-1,它就拥有了初始化的权利,进入初始化的代码模块,等到初始化完成,再把sizeCtl设置回去。其他线程则一直执行 while循环,自旋等待,直到数组不为null, 即当初始化结束时,退出整个函数。
因为初始化的工作量很小,所以此处选择的策略是让其他线程一直等待, 而没有帮助其初始化。
1.3 put方法分析
上面的for循环有4个大的分支:
第1个分支,是整个数组的初始化,
第2个分支,是所在的槽为空, 说明该元素是该槽的第一个元素, 直接新建一个头节点,
第3个分支,说明该槽正在进行扩容,帮助其扩容:
第4个分支,就是把元素放入槽内。槽内可能是一个链表,也可能是一棵红黑树, 通过头节点的类型可以判断是哪一种。 第4个分支是包裹在synchronized ()里面的,f 对应的数组下标位置的头节点,意味着每个数组元素有一把锁, 并发度等于数组的长度。
上面的binCount表示链表的元素个数,当这个数目超过TREEIFY _THRESHOLD = 8时,把链表转换成红黑树,也就是treeifyBin(tab, i)函数。但在这个函数内部,不定需要进行红黑树转换,可能只做扩容操作,所以接下来从扩容讲起。
1.4 get方法分析
1.5 addCount方法的分析
在1.3中,我们已经知道addCount方法做两件事情
那我们就来分析一下addCount方法是怎么完成这两件事的?大致了解一下
我们要先知道一下关于basecount变量的理解,这样我们看源码才会更好理解,由于作者水平有效,该方法只可以简单描述,重要的是下一节的transfer方法
1.6 ConcurrentHashMap的扩容机制
先给结论,再说源码。
- 在Java8中,如果一条链表的元素个数超过 TREEIFY THRESHOLD(默认是8),并且table的大小>= MIN TREEIFY CAPACITY(默认64),就会进行树化(红黑树)
上节,我们介绍put方法时,里面有一个treeifyBin方法,我们追溯源码
在tryPresize方法内部调用了一个核心函数transfer方法,我们先从这个方法说起
该方法过程由于作者水平有限,无法再继续进行讲述。
如上总结
- 扩容的基本原理,首先建一个新的 HashMap,其数组长度是旧数组长度的2倍,然后把旧的元素逐个迁移过来。所以,上面的函数参数有2个, 第1个参数tab是扩容之前的HashMap,第2个参数nexTab是扩容之后的HashMap.当nexTab= null的时候,函数最初会对nextTab进行初始化。一个关键点要说明:该函数会被多个线程调用,所以每个线程只是扩容旧的HashMap,这就涉及到划分任务的问题。
- 划分任务操作:旧数组的长度是N,每个线程扩容一段,一段的长度用变量stride (步长)来表示,transferIndex 表示了整个数组扩容的进度。stride的计算公式如上面的代码所示,即:在单核模式下直接等于n,因为在单核模式下没有办法多个线程并行扩容,只需要1个线程来扩容整个数组;人在多核模式下为(n>>>3)NCPU,并且保证步长的最小值是16。显然,需要的线程个数约为n/stride。
if (stride = (NCPU > 1) ? (n >>> 3) 1 NCPU : n) < MIN TRANSFER STRIDE)
stride = MIN TRANSFER STRIDE;
transferIndex是ConcurentHashMap的一个成员变量, 记录了扩容的进度。初始值为n,从大到小扩容,每次减STRIDE个位置,最终减至n<= 0,表示整个扩容完成。因此,从[0, transferIndex-1]的位置表示还没有分配到线程扩容的部分,从{transferIndex,n-1]的位置表示已经分配给某个线程进行扩容,当前正在扩容中,或者已经扩容成功。因为transferIndex会被多个线程并并发修改,每次减STRIDE,所以需要通过CAS进行操作,如下代码所示
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
- 在扩容未完成之前,有的数组下标对应的槽已经迁移到了新的HashMap里面,有的还在旧的HashMap里面。这个时候,所有调用get(k,v)的线程还是会访问旧HashMap,怎么处理呢?当Node[0]已经迁移成功,而其他Node还在迁移过程中时,如果有线程要读取Node[0]的数据,就会访问失败。为此,新建一一个 ForwardingNode,即转发节点,在这个节点里面记录的是新的ConcurrentHashMap 的引用。这样,当线程访问到ForwardingNode之后,会去查询新的ConcurentHashMap。
- 无法讲述
了解了核心的迁移函数,再回头看,tryPresize函数。这个函数的输入是整个Hash表的元素个数。在函数里面,根据需要对整个Hash表进行扩容。想要看明白这个函数,需要透彻地理解,sizeCtl变量。
sizeCtl含义解释
注意:以上这些构造方法中,都涉及到一个变量sizeCtl,这个变量是一个非常重要的变量,而且具有非常丰富的含义,它的值不同,对应的含义也不一样。
sizeCtl为0,代表数组未初始化,且数组的初始容量为16
sizeCtl为正数,如果数组未初始化,那么其记录的是数组的初始容量,如果数组已经初始化,那么其记录的是数组的扩容阈值(数组的初始容量*0.75)
sizeCtl为-1,表示数组正在进行初始化
sizeCtl小于0,并且不是-1,表示数组正在扩容,-(1+n),表示此时有n个线程正在共同完成数组的扩容操作
所以,sizeCtl 变量在Hash表处于不同状态时,表达不同的含义。明白了这个以后,再来看tryPresize(int size)函数。
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。
2:使用Debug追溯源码
能力有限,以后补充