数据结构--ConcurrentHashMap原理及解析
HashMap本身不是线程安全的,通常在多线程情况下可以去使用
HashTable
替代HashMap
使用,该类中基本所有的操作方法都采用synchronized
进行修饰,所以在高并发的情况下,每次只能有一个线程获取对象监视器锁
,并发性能太低。针对上述情况,就产生了
ConcurrentHashMap
这个类去解决上述问题,提高效率。
ConcurrentHashMap重要参数分析
table
:默认为null
,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂次方
1 |
|
nextTable
:默认为null,扩容时使用,大小为原数组的2倍。
1 |
|
sizeCtl
:该属性用来控制table
的初始化和扩容操作。
- -1:表示当前数组正在初始化
- -N:表示当前争优
N-1
个线程进行扩容操作 - 0:数组还未初始化
- N:1. table未初始化,表示table需要初始化的大小;2. table初始化完成,表示扩容阈值。源码观察可知该值始终是 table容量的0.75倍。
1 |
|
sun.misc.Unsage U
:利用该类实现CAS算法
,实现一种乐观锁的操作。
Node
:主要存放 key-value对,并且具有next域。可以保存key、value、hash值
的数据结构。
1 |
|
ForwardingNode
:一个特殊的节点,key、value、hash值
均为null
,存储着对nextTable
的引用
1 |
|
只有table发生扩容的时候,ForwardingNode
才有作用,作为一个占位符放在table
中表示当前节点为null
或者已经被移动。
ConcurrentMap源码解析
ConcurrentHashMap初始化
1 |
|
此时ConcurrentHashMap的初始化只是初始化了 table的容量,还未直接初始化table
。需要等到第一次调用put()
后执行。
ConcurrentHashMap插入数据 - put()
向ConcurrentHashMap中插入数据
1 |
|
put()
源码
1 |
|
put()
操作主要包括以下几项:
①int hash = spread(key.hashCode());
:计算Hash值
1 |
|
②tab = initTable();
:如果table尚未初始化,就需要进行初始化操作
1 |
|
table初始化的操作有且只有一个线程能够操作,其他线程通过Thread.yield()
让出CPU时间片等待初始化完成。
③f = tabAt(tab, i = (n - 1) & hash))
:获取hash值转换后得到的存储位置的头节点f
。无论链表头节点还是红黑树的根节点都是在数组上的。
1 |
|
在JMM
中,每个线程都有他自己的工作内存,里面存储着数据的副本,虽然table
是volatile
修饰的,但不能绝对保证拿到的就是最新的数据,利用U.getObjectVolatile
是直接取得指定内存的数据,可以保证每次拿到的都是最新的。
④casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null))
:由于发现存储位置上没有元素,则利用CAS直接插入新节点
1 |
|
利用CAS操作
直接将节点放入table对应位置中。但是如果CAS插入失败,意味着是一个并发操作,直接向下继续执行。
⑤helpTransfer()
:帮助数据迁移
1 |
|
⑥treeifyBin()
:当完成数据新节点插入后,会进一步对当前链表大小进行调整。当链表长度大于TREEIFY_THRESHOLD
阈值,默认8,会进行链表转换红黑树,也可能是仅仅做数组扩容。
1 |
|
⑦addCount(1L, binCount)
:table存储键值对数量增加,然后需要判断是否超过扩容阈值,若超过需要进行扩容操作。
1 |
|
ConcurrentHashMap扩容操作 - tryPresize()
由上述源码可知,触发扩容动作的情况有两个:
- 新增节点后,链表长度达到了8,就会调用
treeifyBin()
对其进行转换,但是如果此时存储的键值对数量如果未到64(最小树形化阈值
),就会触发tryPresize()
扩大数组长度至原来的两倍,并调用transfer()
进行数据迁移。- 新增节点后,会调用
addCount()
使存储数量 +1 ,还会去检测是否达到扩容阈值,达到时会触发transfer()
,重新调整节点的位置。
1 |
|
ConcurrentHashMap迁移数据 - transfer() 重要
将原来旧表的数据迁移至新表中。
迁移过程涉及并发操作。原数组长度为n,所以会出现n个迁移任务,让每个线程单独去负责每一个迁移任务,每做完一个任务在检测是否有其他没做完的任务。
transfer()
中利用了一个stride(步长)
,每个线程负责迁移一部分。
再调用到transfer()
的函数中观察到transfer(tab, null)
在一次调用过程中只会存在一次,然后其他调用的时候nextTable
已经初始化完毕,就不会在调用到空。
1 |
|
总结流程:
- 构建一个
nextTable
,它的容量是原来的两倍,这个操作只会执行一次。 - 根据hash值 计算对应的存储位置,然后根据
tabAt(i)
获得对应位置的头节点。 - 如果头节点为null,就在原table[i]放入
ForwardingNode
,代表当前位置已经迁移完毕。 - 如果头节点为链表节点,就构造一个反序链表,把他们分别放在
nextTable
中的i和i+oldCap
位置上。放入成功后,在table[i]放入ForwardingNode
,代表迁移完毕。 - 如果头节点为树节点,也做一个反序操作,并且判断是否需要重新转换成链表,再把处理后的结果分别放到
nextTable
中的i和i+oldCap
位置上。放入成功后,在table[i]放入ForwardingNode
,代表迁移完毕 - 遍历所有的节点就完成了数据迁移工作,让nextTable替代ConcurrentHashMap中的table,并更新
sizeCtl
为新数据容量的0.75倍,完成扩容。
ConcurrentHashMap获取数据 - get()
concurrentHashMap.get(“Android”);
源码解析:
1 |
|
总结流程:
- 首先计算key对应的
Hash值
,定为到table
上的对应位置,如果直接是头节点就返回 - 此时需要判断头节点的
hash值
hash值等于-1
:说明该节点为ForwardingNode
,表明此时正在执行扩容操作,调用其find()
从nextTable
寻找对应值hash值等于-2
:说明该节点是一个树节点,调用TreeBin.find()
去寻找对应值,内部存在着读写锁,可能红黑树正在旋转变色。hash值大于等于0
:说明该节点是一个链表节点,直接进行链表遍历寻找对应值即可。
- 如果都没有找到,就返回null
为什么
get()
不需要加锁?关键点在于
table
是由volatile
进行修饰的,这个关键字可以保证可见性以及有序性。如果对其声明的变量进行了写操作,JVM就会向处理器发送一条指令,将这个变量所在的缓存行数据写回到主内存。基于缓存一致性协议,其他线程去读取时,就要强制从主内存中读取。在数组进行扩容时可以保证可见性。对存储的节点
Node
的元素val
以及指针next
也是用volatile
进行修饰的,再、在多线程环境下对他们进行改变对其他线程也是可见的。
引用参考
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!