ConcurrentHashMap 源码分析
1. 在阅读源码时做了大量的注释,并且做了一些测试分析源码内的执行流程,由于博客篇幅有限,并且代码阅读起来没有 IDE 方便,所以在 github 上提供JDK1.8 的源码、详细的注释及测试用例。欢迎大家 star、fork !
2. 由于个人水平有限,对源码的分析理解可能存在偏差或不透彻的地方还请大家在评论区指出,谢谢!
1. 前言
终于到这个类了,其实在前面很过很多次这个类,因为这个类代码量比较大,并且涉及到并发的问题,还有一点就是这个代码有些真的晦涩,不好懂。前前后后大概花了三天的时间看完的一些重要操作,接着今天来整理一下。
好了首先介绍一个个人的感受:
- 首先这个类很多操作和
HashMap
是类似的,但是麻烦就麻烦在锁分离技术
和并发处理
- 底层还是采用的
数组
+链表
+红黑树
来实现的,但是红黑树的TreeNode
改成了TreeBin
- 里面有很多 CAS (Compare And Swap)操作,比如说
unsafe.compareAndSwapInt(this, valueOffset, expect, update)
意思是如果valueOffset
位置包含的值与expect
值相同,则更新valueOffset
位置的值为update,并返回true,否则不更新,返回false。 - 不仅仅是 CAS 还有一些重量级的锁。也就是
synchronized代码块
用来保证操作同一数组元素下的节点的一致性,后面会看到。
2. 基本结构
1. 继承
AbstractMap
2. 实现
ConcurrentMap<K, V> 和 Serializable 没有克隆。应该也是出于安全考虑!
3. 属性
1. 基本属性
1 | // 最大容量,同 hashmap |
其中尤其要注意那个 sizeCtl
现在不知道他的意思后面可能就看不懂了!!
2. Node
和 HashMap
一样的。但是注意这个地方采用了 volatile
关键字,其他的真的一样,只有两个是 volatile
原因在于只有这两个是可变的,会变的。
1 | static class Node<K, V> implements Map.Entry<K, V> { |
3. Segment
这个以前是特别重要的一个数据结构,基本所有的查删改都是依靠他的,但是在 1.8
中他的作用被削弱了,里面什么方法都没有。
1 | static class Segment<K, V> extends ReentrantLock implements Serializable { |
4. TreeNode
由于直接继承了 Node 节点
所以相比 HashMap
中的 TreeNode
少了 before 和 after 属性。他的方法比较少主要是因为红黑树已经不用这个数据结构了而是采用的 TreeBin
,但是它存在是因为在转成红黑树的时候是先把 Node
封装成 TreeNode
然后再封装到 TreeBin
中的。
1 | static final class TreeNode<K, V> extends Node<K, V> { |
5. TreeBin
1 | static final class TreeBin<K, V> extends Node<K, V> { |
4. 主要方法概览
1. ctor-4
在构造方法中类似于 HashMap
的做法,在构造方法里面只进行一下参数的判断以及对一些属性进行赋值,但是没有对数组进行初始化。还是那个原理:延时加载,在 put
方法中肯定会对数组进行初始化。
在这里主要设置的值肯定就是我们前面提到的 sizeCtl
属性,当他为整数的时候就是阈值,我们也看到阈值字段但是没有见到那个字段被使用,主要就是被 sizeCtl
实现了!还有需要设置的属性就是负载因子和初始的数组大小,默认是 0.75
和 16
。
好具体说一下每一个方法的实现:
- 第一个无参的就是空方法,所有的值采用默认
- 有初始大小的,就按照 1.5*n 转成 2^n 这个格式。
- 如果传入一个 Map 就和 HashMap 一样,调用
putAll
方法,然后putAll
就是循环原来的数组,然后put
到新的数组中。 - 其他的就是手动设置大小和阈值。
1 | // 空方法,注释说会创建大小为 16 的数组,估计是延时加载 在 put 里面 |
2. size/sumCount
size
方法直接调用了 sumCount
,然后 sumCount
作用就是统计一下 cellCount
数组中的值的和,这时候我们会发现 cellCount
是一个类,自己定义了一个静态内部类,作用就是专门来统计节点的数量。可见统计节点在并发情况是一件很困难的事,这里还专门设计了一个类来进行统计。里面就一个字段 volatile 的 long 。
1 | public int size() { |
4. containsKey/Value(Traverser对象)
containsKey
这个方法比较简单,因为我们直接可以调用 get
方法就可以判断。因此它的实现就是调用了 get
方法。
containsValue
方法必然是需要遍历节点的,因为他需要一个个的比较元素的值。里面并没有像 get
一样遍历,而是采用了一个遍历器 Traverser
这个遍历器的主要作用是如果发现正在 transfer ,就切换到新的数组里面,获取最新插入的值。
1 | public boolean containsKey(Object key) { |
5. put/putVal
好了,现在就准备开始介绍最重要的,put 方法了。这个方法里面直接调用了 putVal ,其实 putVal 的逻辑有点复杂,就单看代码来说,代码量还是比较大的。
那么我们先介绍一下 putVal 的大概的逻辑然后再看代码。
首先就判断了 key 和 value 不能为空的情况,一开始觉得还挺奇怪的 HashMap 就允许 null 作为键值为什么 ConcurrentHashMap 却不行,这里我查了一下这段源码作者 Doug Lea 的解释是 “ 在普通的非并发的 HashMap 中我们可以存放 null 然后至于这个地方是否是真的为 null 还是由于键不存在呢,我们可以采用
containsKey
来判断。但是在并发情况下在调用方法时可能会发生引用关系的变化导致歧义 ”。感觉还是有点抽象难懂,我自己的理解就是:当我们调用map.get(key)
时候返回null
的时候假如根本不存在这个key
,但是我们由于不清楚这个地方的值到底是null
还是这个key
根本不存在,我们就会调用containsKey
来检查,但是在这个时间间隙中有其他线程往里面放了一个key -> null
导致我们检测的结果是有这个键值对,从而误判!然后我们前面也提到了,在构造方法中没有任何初始化表的操作,所以说我们在 putVal 中如果判断到表空,就需要进行初始化工作。这个初始化调用了一个
initTable()
这个方法稍后专门分析。接着就是利用 hash 值来获取数组的索引了,首先还是判断那个对应的位置有没有元素,如果没有的话就简单了,采用 CAS 操作添加一个新节点此时添加工作就完成了可以返回了。如果不是这种情况就继续往下看。
我们看头结点的 Hash 值是否是
MOVED
,如果是就说明当前的表正在进行transfer
我们就让当前线程去帮助transfer
。现在没看懂没关系等看到transfer
方法的时候就知道为什么了。否则的话就是一个正常的链表或者红黑树了,这时候我们就和 HashMap 一样,如果是一个链表我们就遍历链表,然后遇到相同的 key 进行 value 的替换,否则插入到链表的结尾。 如果是一个红黑树就执行红黑树的插入操作。 注意这个操作是在同步代码块中进行的,因为我们不能保证多线程的修改安全,但是这个代码块的锁是头结点,也就是数组有多少元素我们就可以同时操作多少把锁,这样并发数就是数组的长度。而前面定义的并发数没有实质的作用。
最后由于我们插入了一个节点需要判断一下当前的节点数是不是大于转红黑树的阈值(默认为8)。是则调用
treeifyBin
,这个方法也比较复杂,待会专门来说。
1 | // 直接调用 |
6. initTable
好,上面已经分析了关于 put 方法,我们还有两个问题没有解决,一个是初始化表,另外一个是转成红黑树,先分析表的初始化。那先回顾一下 HashMap 他是采用同样的延时加载然后在 resize
方法中进行了表的初始化工作。也就是 HashMap 的 resize
方法同时进行了初始化和扩容以及迁移的工作,在 ConcurrentHashMap 中划分的更细致,初始化就只进行初始化,因为他是并发的要考虑到更多。
一样的,我们先分析一下大致的思路。
首选先需要看是不是有别的线程在进行初始化,如果是我们就不要进行初始化了让出 cpu 资源让别的线程继续初始化。这个如何判断别的线程是否在初始化?就涉及到了前面的
sizeCtl
属性,当他是 -1 的时候就说明在进行表的初始化工作。显然当别的线程没有初始化,当前线程就要初始化。并且不让别的线程进行争夺,就把
sizeCtl
用 CAS 置为 -1,并开始初始化。初始化的工作有两个,一是 new 一个容量为 16 的新数组,其次设置扩容的阈值也就是
sizeCtl
的值,设置好了也说明初始化完毕。
1 | private final Node<K, V>[] initTable() { |
7. treeifyBin
行,解决了初始化接下来就要看看链表转红黑树的操作了。这个操作没有像我想的那样直接进行转树的操作,而是做了一系列的判断。
当链表长度大于等于 16 但数组长度小于 64 时,需要进行一次扩容操作,扩容操作又委托给了 tryPresize
扩容是预计扩容到原来的两倍。注意区分链表长度和数组长度不要弄混了!!!
接下来就是真正的链表转成树的操作了。
1 | private final void treeifyBin(Node<K, V>[] tab, int index) { |
8. tryPresize
继续分析上面遗留下来的问题,扩容操作。
首先判断表是否为空,空则进行初始化,代码同
initTable
如果
3*tableSize+1 < 扩容阈值
就不扩容,这个情况基本不会发生。将
sizeCtl
设置为下次要进行扩容的阈值,然后进行transfer
,这个方法里面才是真正的将数组大小扩充为原来的两倍并且进行数据迁移。
整体看起来还是以前的 HashMap 的套路,他的 resize
进行初始化、扩容、数据迁移。而这里把这三步拆成了三个方法分别来做,以保证高并发。
1 | private final void tryPresize(int size) { |
9. transfer
好了,该进入正题了,transfer
是这个类中最麻烦也是最精巧的一个方法,还是先说思路再阅读代码。
如果当前的
nextTab
是空,也就是说需要进行扩容的数组还没有初始化,那么初始化一个大小是原来两倍的数组出来,作为扩容后的新数组。我们分配几个变量,来把原来的数组拆分成几个完全相同的段,你可以把他们想象成一个个大小相同的短数组,每个短数组的长度是
stride
。我们先取最后一个短数组,用
i
表示一个可变的指针,可指向短数组的任意一个位置,最开始指向的是短数组的结尾。bound
表示短数组的下界,也就是开始的位置。也就是我们在短数组选择的时候是采用从后往前进行的。然后使用了一个全局的属性
transferIndex
(线程共享),来记录当前已经选择过的短数组和还没有被选择的短数组之间的分隔。那么当前的线程选择的这个短数组其实就是当前线程应该进行的数据迁移任务,也就是说当前线程就负责完成这一个小数组的迁移任务就行了。那么很显然在
transferIndex
之前的,没有被线程处理过的短数组就需要其他线程来帮忙进行数据迁移,其他线程来的时候看到的是transferIndex
那么他们就会从transferIndex
往前数stride
个元素作为一个小数组当做自己的迁移任务。
好,现在可能感觉有点乱,来总结一下。当前的数组的迁移被分为很多的任务包,每一个任务包中有 stride
个元素,然后这些任务包需要被从后往前的分配给不同的线程。分配过程依赖于共享的全局变量 transferIndex
。这样做的原因就是为了高并发,不得不佩服写这个类源码的大师! 下面用一张图在来总结一下上面所说的内容。
现在线程收到自己的任务包了,肯定就需要进行数据迁移的工作了。迁移工作就比较简单了,由于是需要对链表或红黑树节点进行操作,必须要对过程同步,锁还是头结点。进行节点迁移的时候,就是和 HashMap 一样,把原来的链表和树拆成两部分,分别放到 i 和 i+n 上。
我们还是主要看链表的拆分,采用的看 hash 值的某一特定位是 0 还是 1 来决定放在哪个位置,节点采用的头插法,也就是部分的节点的顺序是反的。为什么说部分是反的?那是因为他在里面一大段代码都在干一件事,去找原链表的最后一段特定位相同的完整序列保持顺序不变。这个比较难说清楚还是用一张图来说明一下。
1 | // 把节点拷贝到新数组里面 |
10. helpTransfer
这个方法主要是让其他线程在对表做操作的时候刚好遇到了,表在扩容,数据迁移。就让这些线程帮助完成这个数据迁移,也就是去领取 transfer
中的数据迁移的任务包。
1 | //1. putVal 当有 MOVED 节点的时候 2.replaceNode 也就是在 remove 中 当有 MOVED 节点的时候 |
11. tableAt/casTableAt/setTableAt
这些方法,不细看,里面主要就是封装了一些对表的 CAS 操作。只是用的比较多。
11. remove/replaceNode、
remove
方法也是调用了 replaceNode
方法,这个方法里面就是常规思路。如果看到有节点在 transfer
就调用 helpTransfer
, 由于需要操作节点需要进行同步。对树和链表进行删除操作。
1 | final V replaceNode(Object key, V value, Object cv) { |
12. clear
清空操作也要说一下,因为不再和以前一样就直接把所有元素置为 null 等待垃圾回收。因为这里涉及到表的大小统计,以及其他的线程同时在操作表可能会导致混乱。他是一个元素下的所有节点统计一遍然后修改容器的大小,也就是他是对每一个数组下的每一个节点进行回收的。显然回收节点需要同步。当然由于涉及到了修改表如果遇到了在扩容的情况也是需要帮助数据迁移。
1 | public void clear() { |
13. read/writeObject
他的序列化也是自己实现的,只存放 key 和 value,在反序列化的时候进行整个表的重建。
3.总结
这个类确实比较麻烦,但是整体思路确实和 HashMap 差不多,但是由于需要做到高并发,访问安全做了很多的更细节的操作,把原来 HashMap 中一步能做完的拆成好几步,并且并发度感觉比 1.7 版本的更高,因为在加锁的时候只加了一个元素,而 1.7 是加一个 Segment 的锁,锁粒度更小,并发度更高。以及在 transfer
中的任务分包真的很巧妙,加速扩容。其实写文字说明只是一个大概的流程,我对源码写了大量的注释,看完说明在看源码就会轻松很多,并且很多源码里面的细节在说明里面没有提到,所以了解大致思路后一定要仔细阅读源码。