数据结构--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 协议 ,转载请注明出处!