Java 面试之 ConcurrentHashMap

ConcurrentHashMap

数据结构

ConcurrentHashMap 与 HashMap 的数据结构相同,还是 数组加链表加红黑树 组成。存储数据的单元依旧是 Node 结构,Node 结构里有 key 字段、value 字段、next 字段、hash 字段,next 字段就是当发生哈希冲突时,当前桶位中的 node 与冲突 node 连成一个链表要用的字段

负载因子

ConcurrentHashMap 的负载因子是不可以修改的,字段是被 final 修饰的,是固定的 0.75

hash

Node对象的 hash 字段必须是 >= 0 的值。

hash的负值有其他的意义。

  1. 散列表在扩容的时候,会触发一个迁移数据的过程,(将原表的数据进行迁移,迁移到扩容后的散列表的逻辑)。老的散列表迁移完一个桶后,需要放一个标记节点(ForwardingNode),这个Node 的哈希值固定是 -1。

  2. 红黑树由一个特殊的节点来代理 ,是 TreeBin 结构,他本身也是继承 Node。其哈希值为 -2.

sizeCtl 字段

sizeCtl == -1

此时当前的散列表正在进行初始化,HashMap的初始化是使用时才进行初始化的,ConcurrentHashMap 也是延迟初始化,只不过其需要确保在并发的条件下,该散列表结构只能被创建一次,当多个线程都执行到initTable逻辑的时候,会使用 CAS 的方式去修改这个 sizeCrl 的值。期望值是0 ( sizeCtl默认为 0 ),更新之后为 -1。CAS 失败的线程,会进行 ”自旋检查“,检查这个 table 是否被初始化出来。每次自选检查之后,会让线程短暂的释放它所占用的 CPU。让当前线程去重新竞争 CPU 资源,把 CPU 资源让给更加饥饿的线程使用

sizeCtl > 0 并且散列表已经初始化完毕

此时 sizeCrl 表示下次触发扩容的阈值。

比如 sizeCtl = 12 时,当插入新数据的时候会检查容量,如果其 >= 12,就会触发扩容操作。

sizeCtl < 0 && sizeCtl != -1

表示当前散列表正处于扩容状态,高 16 位表示扩容标识戳低 16 位表示参与扩容工作的线程数量 +1

扩容标识戳的计算方式

扩容标识戳需要保证每个线程计算的戳一致,能标记出是从同一小表到同一大表的扩容

首先将当前 table (old) 的size 转换成二进制之后,从高位开始计数一共有多少个0。比如 size = 16,二进制为10000(5 个 0 ),int 类型是4个字节 32 位,可以得出 32-5=27,二进制为 11011。之后 再将其

0000 0000 0001 1011与 ( 16位 )1000 0000 0000 0000 进行按位或计算,得到,1000 0000 0001 1011,即为 16 -> 32 扩容标识戳

标识戳与当前表的长度是强相关的,不同的长度计算出的戳是不同的

低十六位

每个执行扩容任务的线程,在开始工作之前,都会更新 sizeCtl 的低 16 位,让低 16 位 + 1。

每个干活的线程最终因为分配不到任务而退出扩容任务之前,都会更新 sizeCtl 的低16位,让低16位 -1

如何保证线程安全

ConcurrentHashMap 采用的方式是 Synchronized 锁桶的头节点,来保证桶内的写操作是线程安全的。

如果 没有头节点(没有数据),此时是依赖 CAS 来实现线程安全,线程会使用CAS 的方式向 slot 里面写头节点数据。成功的话返回当前key的前值

失败的话,说明有其他线程竞争到了该 slot 位置,当前线程只能重新执行写逻辑,再次路由到这个 slot 位置时,slot 已经被其他线程 CAS 写成功过,此时当前线程会采用 synchronized 锁这个桶内的头节点,来保证写线程安全

hash 寻址算法

首先将 key.hashcode() ^(或) key.hashcode() >>> (右移)16 进行扰动运算,并且将符号位强制设为 0,使该hash 值成为一个正数。

高低位异或的目的:大部分情况下 散列表的数组不会太长,寻址算法 (table.length-1)& hash ,这种情况下有效参与寻址算法位有限,混合原始 hash 的高位和低位,以此来加大低位的随机性。而且混合后的低位也掺杂了高位的部分特征,这样高位的信息也被变相的保留了下来。具有增强散列性作用

ConcurrentHashMap 如何统计当前散列表的数据量

使用的是 LongAdder 来计算数据量。LongAdder 是 JDK 8的新特性,是 Doug lea编写的,不过此处并没有直接导入 LongAdder 而是将其的源码粘贴过来了

ConcurrentHashMap 为什么不采用 AtomicLong 统计数量

AtomicLong 的自增操作,是采用 CAS 实现的,CAS 在并发量小时性能还不错,当并发量大时,比如 100 个线程同时让 AtomicLong 自增,CAS 首先是比较期望值,如果期望值与内存的实际值是一致的,再执行替换操作,CAS 反映到内核层,实际是 cmpxchg 指令(com paer x chg),这个指令在执行的时候会检查当前平台是否为多核平台,如果是多核的话,cmpxchg 会通过锁总线的形式来保证同一时刻只能有一颗 CPU 去执行,如果是 100 个线程同时让 AtomicLong 去自增这个场景,这些线程反映到平台上仍然是串行通过的,而且一个线程 CAS 成功之后,在它后面的线程因为拿到的期望值都已经属于过期数据了,与实际内存的值不一致,会全部失败。此时会再读内存的最新值作为期望值,再尝试修改,直到成功位置

LongAdder 在高并发时将对单一变量的 CAS 操作分散为对数组 cells 中多个元素的 CAS 操作,取值时进行求和;而在并发较低时仅对 base 变量进行 CAS 操作。一种基于分布式思想的方式,本质就是以空间换时间

LongAdder的内部结构

LongAdder 最核心的有两个字段,一个是 long 类型的 base 字段,一个是 Cell[] 数组,Cell 结构中是 long 类型的 value 字段。

使用 LongAdder 时,如果没有产生过 CAS 失败时,数据会全部累加到 base 字段上。当某个线程与其他线程产生冲突, CAS 修改 base 字段失败时,就将 Cell[] 数组构建出来。再往后的所有的累计请求,就不再首先 base 字段了,而是根据分配给线程的哈希值,进行位运算,找到对应的cell,将累加的值通过 CAS 方式写入到 Cell 里面

ConcurrentHashMap 的扩容过程

首先,触发扩容条件的这个线程需要修改 sizeCtl ,根据扩容前的散列表长度,计算出扩容的唯一标识戳,是一个16位的值,最高位是1。sizeCtl 高16位存储扩容标识戳,低16位存储的值是参与扩容工作的 线程数+1。因为该线程就是触发扩容的线程,将其低16位直接设置成2,表示有一个线程正在进行扩容工作。

之后,该线程需要创建一个新的 table 大小是扩容前的两倍,并且需要告诉新表的引用地址到 map.nextTable 字段,因为后续协助扩容的线程需要知道将老表的数据迁移到哪。随后会保存老表的长度 map.transferIndex 字段,该字段记录老表的总迁移进度,迁移工作是从高位桶开始,一直迁移到下标是 0 的桶。

在迁移的过程中会创建 ForwardingNode 对象,这个 node 比较特殊,是用来表示指定 slot 已经被迁移完毕的。并且 ForwardingNode 里面有一个指向新表的字段,它还提供了一个查询方法,当我们查询时会碰到桶位,他是ForwardingNode节点,可以通过该节点的find方法,重新定向到新表的查询

散列表正在扩容时再来写请求如何处理

如果写操作访问的桶还没有被迁移,接下来拿到桶的锁,然后进行正常的插入操作就可以了,迁移桶位的时候也会加锁,所有这里其实是同步的,不存在并发问题

如果写操作访问的桶,头节点正好是 ForwardingNode 节点,碰到 fwd 节点说明当前正在扩容中,作为并发 map 扩容速度当然是越快越好,提升扩容最好的方式,其实就是多个线程去做扩容工作,恰好此时写操作的线程并不能去直接写数据(扩容中,无法写),所以 Doug Lea 设计成一个写操作线程协助扩容的逻辑

写线程进来后,根据全局 transferIndex 去规划当前线程任务区间,比如说,规划到下标 [256,240],这些 slot 归当前线程来搬运,当前线程即会将这些 slot 的数据搬运到新的 table,搬运完之后,再根据全局的 transferIndex 去分配下一批任务,直到线程再也分配不到任务时,此时扩容工作就基本完成了。当前线程即会返回到写数据逻辑中,最终数据会被写到新扩容的 table 中

收尾工作

每个执行扩容任务的线程,在退出时,都会更新 sizeCtl 的低16位,让它的值 -1。线程如果发现自己是最后一个退出扩容任务的线程,当sizeCtl -1之后的值等于 1,代表当前线程是最后一个退出的线程

该线程首先会重新检查一遍老表,检查是否有遗漏的 slot,判断条件是 slot 的值是不是 fwd 节点,如果是就跳过,如果不是,则当前线程就迁移这个 slot 的数据。之后,将新表的引用保存到 map.table 字段上,然后根据新表的大小,算出下一次扩容的阈值,保存到 sizeCtl 字段。

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

此时不能写数据,写数据之后可能会导致红黑树失衡,并触发自平衡操作,导致该树的结构发化。读线程在一颗正在发生结构变化的树上查询数据是不现实的。

TreeBin 对象有一个 int 类型的 state 字段,每个读线程在读数据之前,都会使用 CAS 方式将 state 的值 +4。读完数据之后,再使用 CAS 方式将 state 的值 -4。

写线程在写数据到红黑树结构之前,会检查 state 字段,看它的值是不是 0

  • 如果是 0 就说明当前该树没有读线程在访问数据。此时写线程会使用 CAS 的方式将 state 字段设置为 1,表示加了写锁(写锁为独占锁)。之后其他线程再来就不会访问该树了;

  • 如果不是 0,则说明该树有其他线程正在访问,此时写线程会把自己线程 Thread 引用暴露到 TreeBin 对象内,再将 state 的 bit 位的第二位设置为 1,表示有写线程处于等待状态,之后会使用 LockSupport.park() 接口将自己挂起。读线程结束时,会让 state -4,之后检查 state 的值是不是 2, 如果是 2 即说明有写线程在挂起等待,(写线程将 state 的第二位 设为 1,转换位十进制就是 2)。如果等于 2说明当前线程为最后一个读线程,并且有写线程在等待资源可用,那么读线程就会使用 unsafe.unpark 接口将等待的写线程唤醒。

正在写操作,来了读请求

TreeBin 结构保留了两个数据结构。一个是红黑树,一个是链表。当这个 state 表示写锁状态的时候,读请求不能到红黑树去查询,此时,读请求会直接去链表上查询数据

(整理自:https://www.bilibili.com/video/BV1xV41127u6?spm_id_from=333.999.0.0)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值