栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

ConcurentHashMap解析

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

ConcurentHashMap解析

1、并发Map存储的数据结构

数组+链表+红黑树,存储数据的单元是Node结构,Node结构有key,value以及指向下一个Node的引用,还有hash字段

2、并发Map的负载因子可以修改吗?可以指定吗?

普通的 HashMap 的负载因子可以修改,但是 ConcurrentHashMap 不可以,因为它的负载因子使用final关键字修饰,值是固定的 0.75

3、Node对象的hash字段在一般情况下,必须是>=0,为什么?

散列表在扩容的时候,会触发一个迁移数据的过程,把原表的数据迁移到扩容后的散列表的逻辑

如果 Node.hash = -1,表示当前节点是 ForWardingNode节点(表示已经被迁移的节点)。
如果 Node.hash = -2,表示当前节点已经树化,且当前节点为 TreeBin 对象,TreeBin 对象代理操作红黑树。
如果 Node.hash > 0,表示当前节点是正常的 Node 节点,可能是链表,或者单个 Node。

4、简述 ConcurrentHashMap 中 sizeCtl 字段的作用

sizeCtl = -1,表示正在初始化。有线程正在对当前散列表(table) 进行初始化操作。
ConcurrentHashMap 的散链表是延迟初始化的,在并发条件下必须确保只能初始化一次,所以 sizeCtl == -1 就相当于一个"标识锁",防止多个线程去初始化散列表。

具体初始化操作就是在initTable()方法中,会通过 CAS 的方式去修改 sizeCtl 的值为 -1,表示已经有线程正在对散链表进行初始化,其他线程不可以再次初始化,只能等待!
如果 CAS 修改 sizeCtl = -1 操作成功的线程,可以接着执行对散链表初始化的逻辑。而 CAS 修改失败的线程,在这里会不断的自旋检查,直到散链表初始化结束。

这里 CAS 失败的线程会走如下逻辑,即自旋的线程会通过Thread.yield();方法,短暂释放 CPU 资源,把 CPU 资源让给更饥饿的线程去使用。目的是为了减轻自旋对CPU 性能的消耗。

sizeCtl = -n, 表示 n - 1 个线程正在进行扩容;
sizeCtl 的高 16 位表示扩容标识戳,低 16 位表示参与并发扩容线程数:1 + nThread, 即当前参与并发扩容的线程数量为 n 个。

扩容标识戳计算方式:

sizeCtl = 0 ,是默认值。
表示在真正第一次初始化散链表的时候使用默认容量 16 进行初始化。

sizeCtl > 0,表示下一次进行扩容的阈值。
例如sizeCtl = 12时,插入新数据会检查容量是否>=12,满足条件则触发扩容。
5、并发Map如何保证线程写数据安全?

 

CAS+锁

① 首先,先判断散链表是否已经初始化,如果没初始化则先初始化散链表,再进行写入操作。
② 当向桶位中写数据时,先判断桶中是否为空,如果是空桶,则直接通过 CAS 的方式将新增数据节点写入桶中。如果 CAS 写入失败,则说明有其他线程已经在当前桶位中写入数据,当前线程竞争失败,回到自旋位置,自旋等待。
如果当前桶中不为空,就需要判断当前桶中头结点的类型:
③ 如果桶中头结点的 hash 值 为 -1,表示当前桶位的头结点为 FWD 结点,目前散链表正处于扩容过程中。这时候当前线程需要去协助扩容。
④ 如果 ②、③ 条件不满足,则表示当前桶位的存放的可能是一条链表,也可能是红黑树的代理对象 TreeBin。这种情况下会使用 synchronized 锁住桶中的头结点,来保证桶内的写操作是线程安全的。

6、触发扩容条件的线程,执行的预处理工作都有哪些?

①首先,触发扩容条件的线程,要做的第一件事就是通过 CAS 的方式修改 sizeCtl 字段值,使其高 16 位为扩容唯一标识戳,低 16 位为(参与扩容的线程数 + 1),表示有线程正在进行扩容逻辑。
注意:这里高 16 位的扩容唯一标识戳是根据当前散链表的长度计算得来,其最高位是 1。那么最终得到的 sizeCtl 就应该是一个负数。
②然后,当前触发扩容条件的线程会创建一个新的散链表,大小为原来旧散链表的 2 倍。并且将新散链表的引用赋给 map.nextTable 字段。
③因为 ConcurrentHashMap 是支持多线程并发扩容的,所以需要让协助扩容的线程知道旧散链表数据迁移到新散链表的进度。为此 ConcurrentHashMap 提供了一个 transferIndex 字段,用于记录旧散链表数据的总迁移进度!迁移工作进度是从 高位桶开始,一直迁移到下标是 0 的桶位。

7、旧散链表中迁移完毕后的桶,如何做标记?

旧散链表中迁移完毕的桶,需要用 ForwardingNode 对象表示桶内节点,这种 Node 比较特殊,是用来表示当前桶中的数据已经被迁移到新散链表的桶中去了。

ForwardingNode 有哪些作用?
答:ForwardingNode 对象内部提供了一个用于向新散链表中查询目标数据的find()方法。
当此时某个线程刚好在旧散链表中查询目标元素时,ForwardingNode可以把find()转发到扩容后的nextTable上,而执行put()方法的线程如果碰到ForwardingNode节点,也会协助迁移。

8、如果散列表正在扩容时,再来新的写入请求该如何处理呢?

如果当前线程执行写入操作时,根据寻址算法访问到的桶中不是 FWD 节点(即,当前桶中数据没有被迁移)。那么此时先判断桶中是否为空,如果为空则 CAS 方式写入数据。而如果桶不为空,则可能是链表或者 TreeBin,这时候需要通过 synchronized 关键字锁住桶的头结点再进行写入操作。
而如果如果当前线程执行写入操作时,根据寻址算法访问到的桶中是 FWD 节点(即,当前桶中数据已经被迁移)。碰到 FWD 节点,说明此时散链表正在进行扩容,这时候需要当前线程也加入进去,去协助散链表扩容(helpTransfer(tab, f);协助扩容是为了尽量减少扩容所花费在数据迁移上的时间)。
当前线程加入到协助扩容中后,ConcurrentHashMap 会根据全局的transferIndex字段去给当前线程分配迁移工作任务(需要负责迁移旧散链表的桶位区间)。例如,让当前线程负责迁移旧散链表中 【0-4】桶位上的数据到新散链表。
一直到当前线程分配不到要负责迁移的任务时,则退出协助扩容,即扩容结束。这时候,当前线程就可以继续执行写入数据的逻辑了!
 

9、扩容期间,扩容工作线程如何维护sizeCtl的低16位呢?

每一个执行扩容任务的线程(包含协助扩容),它在开始工作之前,都会更新 sizeCtl的低 16 位,即让低 16 位 +1,说明又加入一个新的线程去执行扩容。
每个执行扩容的线程都会被分配一个迁移工作任务区间,如果当前线程所负责的任务区间迁移工作完成了,没有再被分配迁移任务区间,那么此时当前线程就可以退出协助扩容了,这时候更新 sizeCtl的低 16 位,即让低 16 位 -1,说明有一个线程退出并发扩容了。
如果 sizeCtl 低 16 位-1后的值为 1,则说明当前线程是最后一个退出并发扩容的线程。
最后一个退出的线程会做两件事:①重新检查一遍老表,看看有没有遗漏的slot,即:判断slot的值是不是fwd节点,是则跳过,不是则迁移这个slot的数据,属于一种保障机制;②将新表的引用保存到map.table字段上,然后根据新表的大小,算出下一次扩容的阈值,保存到sizeCtl字段。

10、最后一个退出扩容任务的线程需要执行哪些收尾工作?

当sizeCtl低16位减1之后的值等于1,就可以确定当前线程是最后一个扩容线程;

1、重新检查老表,判断slot的值是否是fwd节点,如果是就跳过,如果不是,当前线程就迁移这个slot的数据,算是最后做一次保障机制

2、将新表的引用保存到map.table字段上

3、根据新表的大小,算出下一次扩容的阈值,保存到sizeCtl

11、桶升级成红黑树,且当前红黑树上有读线程访问,再来写请求怎么办? 

写线程会被阻塞,因为红黑树比较特殊,新写入数据,可能会触发红黑树的自平衡,这就会导致树的结构发生变化,会影响读线程的读取结果。
在红黑树上读取数据和写入数据是互斥的,具体原理分析如下:

我们知道 ConcurrentHashMap 中的红黑树由 TreeBin 来代理,TreeBin 内部有一个 Int 类型的 state 字段。当读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。

如果写线程去向红黑树中写入数据时,会先检查 state 值是否等于 0,如果是 0,则说明没有读线程在检索数据,这时候可以直接写入数据,写线程也会通过 CAS 的方式将 state 字段值设置为 1(表示加了写锁)。

如果写线程检查 state 值不是 0,这时候就会park()挂起当前线程,使其等待被唤醒。挂起写线程时,写线程会先将 state 值的第 2 个 bit 位设置为 1(二进制为 10),转换成十进制就是 2,表示有写线程等待被唤醒。

反过来,当红黑树上有写线程正在执行写入操作,那么如果有新的读线程请求该怎么处理?
TreeBin 对象内部保留了一个链表结构,就是为了这种情况而设计的。这时候会让新来的读线程到链表上去访问数据,而不经过红黑树。

挂起等待的写线程后,什么时候再唤醒呢?
读线程在读取数据时,会使用 CAS 的方式将 state 值 +4(表示加了读锁),读取数据完毕后,再使用CAS 的方式将 state 值 -4。
当 state 值减去 4 后,读线程会先检查一下 state 值是不是 2,如果是 2 ,则说明有等待被唤醒的写线程在挂起等候,这时候调用 unsafe.unpark() 方法去唤醒该写线程。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/708697.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号