ConcurrentHashMap源码面试

https://www.bilibili.com/video/BV1xV41127u6

一、ConcurrentHashMap 存储数据结构

问:

  ConcurrentHashMap 存储数据结构是什么?你给我描述描述呗。

答:

  和普通的 hashMap 一样,即 数组 + 链表 + 红黑树, 存储单元 还是 Node 结构, Node结构有 Key, 有 Value, 还有指向下一个node 的 next 节点 和 hash值, next 字段是解决 hash冲突之后生成的链表作用。大概就这个样子。

 

二、ConcurrentHashMap 负载因子是否刻意修改

问:

  ConcurrentHashMap 负载因子可以修改吗?

答:

  普通的 HashMap 负载因子可以修改, 并发map 不可以修改。并发 map的负载因子是 final 修饰,为0.75.

 

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

问:

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

答:

  hash 字段 负值有其他意义。

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

  原散列表迁移完一个桶,需要放一个标记节点 ,叫ForwardingNode 节点。Node 的 hash值固定是 -1.

问:

  还有一种情况你想想

答:

  ..... 对, 还有红黑树的那种情况,在 JDK8 的 map 里面,红黑树,有一个特殊的节点来代理,是 TreeBin 结构。

  本身继承 Node, 它的hash值比较特殊,固定值是 -2。

问:

  ok哈,就一个代理节点 TreeBin, hash值是 -2 对吧。

  就是说 CRUD 碰到 hash -1 -2 就知道啥情况了。 

 

总结:

  hash值 为负数的情况下有两种;

  当 hash 为 -1, 表示扩容的过程,原散列表迁移完一个桶,需要放一个标记节点 ,叫ForwardingNode 节点。该的 hash值固定是 -1.

  当 hash 为 -2, 红黑树,有一个特殊的节点来代理,是 TreeBin 结构, hash值固定为 -2.

  

四、并发map 如何在并发条件下初始化散列表。

问:

  并发map 如何在并发条件下初始化散列表。

  其实这个 并发 map 还有一个非常难理解的字段是 SizeCtl 就 SizeControl 字段。 

  看起来就是普通的 int 字段, 但是表示含义特别多。如果这个搞不明白,源码基本弄不懂。

  我降低点难度分几种情况,提问你,可以吧。

  SizeCtl == -1 的时候,表示什么意思。

答: 

  SizeCtl == -1 的时候, 表示当前这个散列表正在初始化。hashMap 散列表结构是延迟初始化的(ps:使用时创建)

    并发Map 也一样,只不过确保在并发条件下,这个散列表结构只能被创建一次。

    当多个线程被执行到 initTable 逻辑时候,就会使用 CAS 方式区修改 sizeCtl 的值。CAS 采用的期望值是初始的0(ps: sizeCtl 默认值 0 )

    更新成功之后就是 -1, CAS 修改成功的线程 去真正执行创建散列表的逻辑。CAS 修改失败的,因为当前散列表在初始化,还没有办法进行写数据的操作。

    CAS失败的线程,会进行自选检查,检查这个table 是否被初始化出来。每次自旋后,会让线程短暂的释放占用的  CPU, 让线程重新竞争CPU 资源。

问:

  SizeCtl > 0 的时候,表示什么意思。( 初始化完散列表后, map.sizeCtl > 0 时, 表示什么。)

答:

  这时候表示 sizeCtl 下次触发的扩容的阈值

  比如说 sizeCtl = 12 时,插入新的数据时候,检查这个容量,发现 >= 12 就会触发扩容操作。

问:

  还有一种特殊情况,当 sizeCtl 是负数,但不是 -1 的时候,情况是什么?

答:

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

问:

  扩容标识戳必须保证每个线程计算出来的值一致。不然无法实现并发扩容。

  说一下扩容标识戳的计算方式

答:

  保证每个每个线程在扩容,散列表从小到大,每次翻倍计算的值一致

  从 16 扩容到 32, 每个线程计算出来的扩容唯一标识戳,都为同一个值。

  当前 table 的 size 转换为 二进制,从高位计算有多少个 0, 比如 size 为16 ,二进制后及时 10000,int 类型是 4 个字节 32 位,所以说

可以得出 32 - 5 = 27 的值,转换成 二进制的话。结果是 11011, 然后二级制数(0000 0000 0001 1011)再和二进制(1000 0000 0000 0000)进行按位或运算。

计算出来的二进制数就是扩容标识戳(ps: 1000 0000 0001 1011, 16 -> 32 扩容标识戳) 

  不同长度计算出来的戳不一样。戳和扩容之前的大小有关。

问:

  这个戳是与一个 1000 0000 0000 0000 进行按位或运算对吧? 这个计算出来的值正好最高位是 1,就表示这个 sizeCtl 这个值是一个负数。

 

总结:

   并发map 如何在并发条件下初始化散列表  

   sizeCtl  为-1, 散列表处于初始化

                  <0 但不等于 -1, 散列表处于扩容状态

                       >0, 触发的扩容的阈值

 

五、并发 map 怎么保证写数据安全,

问:

   并发 map 怎么保证写数据安全,

答:

  如果slot 有头节点,并发 map 使用 Synchronized 锁桶的头节点,保证桶内的写操作是线程安全的(桶内是串行化的)。

  ps:没有头节点,没有数据)如果内是空的,这时候,依赖CAS 实现线程安全。

  线程使用 CAS 方式向 slot 里面写头节点数据。成功的话,就返回,失败的话,说明有其他线程获取到这个 slot 位置。当前线程只能重新执行写逻辑,再次路由到这个 slot 位置的时候。再次写的时候,桶内的头结点,来保证写线程安全。桶内肯定是串行。

 

六、并发map 寻址算法

问:

  寻址算法简单说一下

答:

  并发map 和hashMap 区别不大。拿到 key 的 hashCode

  再进行扰动运算,让 hashcode 高 16 位 与 低 16 位 进行一个异或运算, 并且将符号位强制设置为0, 就是让整个 hash 值成为一个正数。

  高低位异或的目的很简单,因为大部分情况下,散列表数组不会太长,寻址算法,(table.length -1) & hash 值。

  在这种情况下,有效参与寻址算法位有限, 所以高低位异或之后,也能让高位的数值参加到寻址算法里,具有增强散位列的目的。

  因为 table.length 一定是 2 的 n 次方。所以 table.length - 1 转换成 二进制一定是 1111 .....

 

七、并发 map 如何统计当前散列表数据量

问: 

  并发 map 如何统计当前散列表数据量

答:

  在并发map 里面实际使用 LongAdder ,jdk 8. 

问:

   为什么不使用 atomicLong 这种原子类型

答:

  出于性能的考虑吧,因为 atomic Long 自增操作采用 CAS 实现,CAS 并发小的时候性能还不错,

  但是并发量大的情况下,100 个线程,首先CAS 比较期望值, 如果期望值一致,再执行替换操作,并且CAS 反映到内核层,

  其实是 cmpxchg 指令,这个指令在执行的时候会检查当前平台是否为多核平台,如果是多核, cmpxchg 会通过锁线程总线的形式保证同一

  时刻只能由一颗 cpu 执行。也就是如果 100 个线程同时执行,反映到平台上仍然是串行通过的。

    另外如果CAS 操作获取的期望值过期,则后面的线程都会失败。失败之后再去读内存里的最新值作为期望值,再尝试修改。直到成功。

    这样会浪费CPU 资源。

问:

  LongAdder 怎么解决 Atomic Long 大并发下的性能问题。

答:

  LongAdder遇到热数据,就将热数据拆分开,原来每个请求打到一个点上,现在拆分为几个点,这样冲突概率就小,性能得到提升。利用空间换时间。

问:

  LongAdder 内部结构

答:

  LongAdder 核心有两个字段

  一个是 Long 类型的 base 字段, 一个是 Cell 数组

  Cell 结构里面有 Long 类型的 value 字段。使用这个Long Adder 过程,如果没有发生并发失败, 数据会全部类加到base, 也是采用 CAS 的方式跟新base 字段,当某个线程与其他线程产生冲突,CAS 修改 base 字段失败的时候。就将cell[] 数据数据构建出来, 再往后累加请求,就不再首先base 字段,根据分配给线程的哈希值,进行一个位于运算。找到对应的一个 cell, 将累加的值 通过 CAS 的方式写入 Cell 里面。 

 

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

问:

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

答:

  首先,触发扩容条件这个线程,先修改 sizeCtl, sizeCtl 为 -1 表示正有线程初始化这个散列表,还有(<0)一种情况就是表示当前散列表正在扩容。

既然触发本次扩容的线程,那么当前线程就必须去修改 sizeCtl , 根据扩容前的散列表长度,计算出扩容唯一标识戳。是一个16位的值。SizeCtl 高16位存储扩容唯一标识戳,低16位存储值参与扩容工作的线程数+1. 因为线程是触发扩容线程,将 sizeCtl 低于16位直接设置成 2. 就表示一个线程可以工作。 

  这个线程需要创建一个新的 table, 大小大概就是扩容前的两倍, 需要告诉新表的引用地址到 map.nextTbale 字段, 因为后续的协助扩容线程需要直到将

老表的数据迁移到哪,再然后的话,保存老表的长度到 map.transferIndex 字段, 这个字段记录老表的总迁移进度。迁移工作从高位桶开始,一直迁移到下标为0的桶。

 

问:

  迁移完的桶怎么标记

答:

  迁移的时候会创建一个 ForwardingNode 对象,这个Node 比较特殊,用来表示指定 Slot 已经被迁移完毕的

问:

  ForwardingNode 除了标记指定的 slot 被迁移走之外,还有什么其他功能吗

答:

  ForwardingNode 里面有一个指向新表的字段,提供一个查询方法,当查询的时候会碰到桶位 是 fwd 节点

 现在要查询的数据已经被迁移到新表,这时候通过 fwd 节点提供的 find 方法, 重新定向到新表上查询。

问:

  假设这个散列表正在扩容中,又来了一个线程向里面写数据,怎么处理

答:

  分情况

    1、如果写操作的桶还没有被迁移,先拿到桶的锁,正常的插入操作就可以了。

     迁移桶位的时候也会加锁,所以说,这里是同步的,不存在并发问题。

    2、如果写操作访问的桶,头节点正好是 ForwardingNode 节点,情况比较复杂

     碰到 fwd 节点,说明当前正在扩容过程中,作为并发 map 扩容速度越快越好,提升扩容速度最好的方式就是用多几个线程去做扩容工作。此时无法写操作,于是有写操作

     线程协助扩容的逻辑。写线程进来后,根据全局 transferIndex 去规划当前线程任务区间。比如说规划到下表【256,240】,这些slot归当前线程来搬运, 当前线程就会将

       这些slot 的数据搬运到新的table, 搬运后,再根据全局 transferIndex 去分配下一批任务。直到线程分配不到任务时,扩容工作基本完毕。当前线程可以返回到写数据的逻辑里

             面。最终数据会被写道新扩容的table中。

问:

  扩容期间,扩容工作线程怎么维护sizeCtl 低 16 位。

答:

  开始工作前都会更新 sizeCtl 的低16位, 让低16位 +1, 表示进来一个线程(伙计)帮忙, 每个干活的线程,最终会因为分配不到任务推出扩容任务。在推出之前,会更新sizeCtl的低16位,让低16位再减一。

问:

  最后一个退出扩容任务的线程还有一些收尾工作需要去处理。

答:

  每个执行扩容任务的线程,在退出时,都会更新sizeCtl 低16 位,让值减去一。

  当 sizeCtl 低16位 减一之后值等于 一,当前线程就可以判定最后一个退出线程。

  收尾工作,重新检查一下老表,看一下是不是有遗漏的slot, 判断条件是 slot 值,是不是 fwd 节点。

    如果是就跳过,如果不是,当前线程就迁移这个slot 的数据,算是保障机制,然后将新表的引用保存到 map.table 字段上面。

  再根据新表的大小,算出下一次扩容的阈值,保存到 sizeCtl 字段。

 

十、某个桶已经升级为红黑树,并且当前红黑树上面有读线程访问,再来写请求怎么办。

问:

  某个桶已经升级为红黑树,并且当前红黑树上面有读线程访问,再来写请求怎么办。

答:

  这时候不能写,如果写会导致红黑树失衡,会触发自平衡操作,导致数的结构产生变化。

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

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

    如果是就说明当前这个树上面没有读线程在访问数据, 那么写线程就使用 CAS 的方式将 state 字段设置为 1, 表示加了写锁, 这时候其他线程再来就不能访问红黑树结构。

            如果如果不是 0, 则说明有读线程,正在树上面访问,写操作肯定不能进行,写线程会把自己的线程Thread 引用暴露到 TreeBin 对象内, 再让state的bit 位第二位置为1. 表示有写线程处于等待状态。然后写线程使用 LockSupport.park() 接口将自己挂起。

 

问:

  什么时候,把自己唤醒。

答:

  读线程结束时候,都会让 state -4, 减完之后读线程,会再检查下state 的值是不是2, 如果是2 ,就说明有写线程在挂起等待。因为写线程挂起之前让 state的bit 位第二位设置位1. 转换成十进制就是2. 如果等于 2, 说明当前读线程最后一个读线程。并且有一个写线程在等待资源可用,那么这个读线程就使用 LockSupport.unpark()将这个等待线程唤醒。

 

问:

  红黑树,在执行写操作,此时再来个读请求,怎么办

答:

  Treebin 对象保留两个数据结构,一个红黑数,一个链表。 当这个 state 表示写锁状态时,读请求,就不能在到红黑树上面查询。但是也不能因为写操作导致,读操作不能用。TreeBing 保留的链表结构就用上,读请求直接到链表上面去访问。

 

 

 

总结:

  hash 为负数的两种情况, 为-2 是红黑树

              为 -1 原散列表迁移完一个桶,需要放一个标记节点 ,叫ForwardingNode 节点。Node 的 hash值固定是 -1.

        sizeCtl  为-1, 散列表处于初始化

                  <0 但不等于 -1, 散列表处于扩容状态

                       >0, 触发的扩容的阈值

  迁移完的桶 的ForwardingNode作用 两个, 一个是标记已经迁移完,一个是指向新表的桶的位置

       并发map 使用LongAdder记录数据量,遇到热数据,就将热数据拆分开,原来每个请求打到一个点上,现在拆分为几个点,这样冲突概率就小,性能得到提升。利用空间换时间。

  TreeBin 的 int 类型 state 字段, 红黑树   读操作设为-4, 写操作等待则将设为2并挂起,读完,看到将state改为0,在运行写操作。

                                            

 

 

  

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  

 

posted @ 2020-10-14 14:28  抽象Java  阅读(466)  评论(0编辑  收藏  举报