【并发】11、ConcurrentHashMap源码分析

ConcurrentHashMap是线程安全的,他是写同步读无锁。 也就是put()的时候会加锁,get()的时候不会加锁

ConcurrentHashMap面试题

JDK7和JDK8ConcurrentHashMap 比较

在这里插入图片描述

JDK7ConcurrentHashMap是如何保证并发安全的

在这里插入图片描述

JDK7ConcurrentHashMap底层原理

在这里插入图片描述

JDK8是如何保证并发安全的

在这里插入图片描述

JDK8 put流程

在这里插入图片描述

1.7

ConcurrentHashMap底层是由两层嵌套数组来实现的:

  1. ConcurrentHashMap对象中有一个属性segments,类型为Segment[];
  2. Segment对象中有一个属性table,类型为HashEntry[];

在这里插入图片描述
在这里插入图片描述

属性

  • segment
    在这里插入图片描述
    segment继承了ReenTrantLock
    在这里插入图片描述
  • DEFAULT_INITIAL_CAPACITY hashEntry数组的长度默认为16
    在这里插入图片描述
  • DEFAULT_CONCURRENCY_LEVEL 并发级别
    在这里插入图片描述
    segment数组的大小就是通过并发级别算出来的>=并发级别的最大二次幂
    在这里插入图片描述
  • MIN_SEGMENT_TABLE_CAPACITY
    每个segment的容量最小要是2

构造器

在这里插入图片描述

  • 确定segment数组的长度 ssize
    通过并发级别concurrency 来算ssize为大于等于并发级别的最小二次幂
    在这里插入图片描述

  • 确定每个segment对应的hashEntry数组的长度 cap
    通过initialCapacity/ssize 算出每个segment应该对应的Entry个数,但是要进行向上取整操作才能保证把我们要求的initialCapacity都放下,而且要保证cap要大于MIN_SEGMENT_TABLE_CAPACITY即最小容量。此外,cap也必须是2的幂次方。
    在这里插入图片描述

  • new Segment对象 Segment<K,V> s0 new出第0个segment位置的放的对象

    为什么需要先创建一个segment对象放在第0个位置

    因为我们往Segment放hashEntry的时候用的是segment对象的put方法,那放之前肯定是要有segment对象存在的才能调用其put方法。如果我们每次都new segment对象的时候还要确定其长度各种,所以提前创一个s0,到时候直接用s0的属性即可。

  • new Segment数组

  • 用UNSAFE.putOrderedObject把Segment对象放到Segment数组的第0个位置

put()方法

在这里插入图片描述

  • 计算key的hashcode,通过hashcode得到在Segment的索引j

  • 通过UNSAFE.getObject得到Segment第j个位置的值s

  • 第j个位置是null,则ensureSegment 生成segment对象
    在这里插入图片描述

  • 调用segment的put方法
    在这里插入图片描述

    • 获取锁tryLock()
      • 获取到直接返回true
        tryLock() 获取到就返回true,获取不到就返回false不会像lock()一样获取不到就阻塞。因为tryLock()调的是nonfairTryAcquire。
        由于tryLock() 不会阻塞 所以可以通过while(tryLock())在等锁的过程,提前把 HashEntry给new好。

      • 获取不到调用scanAndLockForPut
        在这里插入图片描述

        • entryForHash
          根据hash算出hashEntry对应的索引,获取到HashEntry对象,没有就返回null
          在这里插入图片描述

        • 通过retries的值控制走的逻辑

          • 遍历链表,看key相等就换value,到最后都没有就创建node
          • 尝试次数到64就用lock() 阻塞
          • 尝试的过程中发现当前索引的值已被改变,就又走第一个逻辑遍历有没有相等的key
    • entryAt获取HashEntry数组第i个位置上的值
    • 遍历HashEntry数组第i个位置的node
      • 不为null,就对比key的值,相同则替换value
      • 为null 就new 一个 HashEntry(头插法),通过setEntryAt将创建好的对象放到HashEntry数组的第i个位置
      • rehash 大于阈值就扩容
        new 一个原来两倍大的数组
        第一次循环把链表尾端连续的新idx相同的一串直接拿过去(类似蜘蛛纸牌)
        第二次循环再依次遍历单个的移
        在这里插入图片描述

1.8

属性

  • nextTable
    扩容时用来存数据的临时中间表
    在这里插入图片描述
  • sizeCtl 用来标志table初始化和扩容的,不同取值代表不同的含义
    代码里的sc就是这个sizeCtl
    • 0:table还没有初始化
    • -1:table正在初始化
    • 小于-1:表示table正在扩容
    • 大于0:初始化完成后,代表table最大存放元素的个数,即要扩容的阈值,默认为0.75*n
  • Moved为-1时,表示当前table正在扩容
  • MIN_TRANSFER_STRIDE:默认16,table扩容时,每个线程最少迁移的槽位个数
  • baseCount 统计size
    在这里插入图片描述
  • counterCells
    利用counterCells来计数,统计所有添加的元素的个数
    在这里插入图片描述
    在这里插入图片描述

内部类TreeBin

TreeBin是ConcurrentHashMap的内部类,new 一个TreeBin就是一个红黑树对象
在这里插入图片描述

为什么1.8 HashMap不需要这个TreeBin对象呢

因为1.8ConcurrentHashMap是要给对象加锁的,但是红黑树的根节点会改变的,如果按1.8HashMap的结构的话,线程A给在firstNode加锁后,万一根节点变化了,那加的锁相当于没用了,这样别的线程就也可以改当前红黑树了。
所以这里new 一个TreeBin,TreeBin内部有一个root属性,这样TreeBin对象是永远不会变的,给TreeBin对象加锁就没问题了。

内部类 ForwardingNode

ForwardingNode是一个node,他在数组正在被扩容的时候被创建,并放在正在被transfer的位置上,hash值为MOVED即负一,
ForwardingNode的nextTable属性存的是新数组。
这样当有线程putVal 获取到的hash值为MOVED,就知道当前这个数组在扩容,就会helpTransfer帮助扩容
在这里插入图片描述

构造器

putVal()方法

  • 自旋:
    • if 如果数组为空 则初始化数组 initTable()
    • else if 数组不为空则通过hash计算索引,索引位置为空则通过CAS new Node结点
      这里CAS失败的,就又会自旋
    • else if 如果正在扩容,则帮助扩容helpTransfer()
      f.hash==MOVED MOVED是负1 当前索引位置的对象对应的hash值是负1,说明有别的线程正在扩容,然后当前线程就去帮助扩容
    • else 在当前位置上的对象f(链表头结点/红黑树根节点)上用synchronized加锁
      在当前索引处的对象还是加锁的对象时,即头结点没有发生变化时继续:
      • 如果是链表,遍历链表,如果有key相同的则替换其value,否则new Node 通过尾插法插入链表。
      • 是红黑树,通过putTreeVal()向红黑树插入元素
    • if 判断是否需要树化treeifyBin()
  • addCount() 扩容

initTable() 初始化数组

由于可能多个线程同时来初始化,为了保证只有一个线程能够初始化,用了Thread.yield() 和cas ,这里利用SIZECTL这个参数 实现了无锁 并发,保证只有一个能进去初始化,其他运行到都是释放资源(thread.yield)
在这里插入图片描述

  • 通过自旋+ Thread.yield() 初始化失败的线程就Thread.yield() 把运行状态让出来(暂时放弃cpu的执行)
  • 用CAS方式修改SIZECTL为-1,能修改成功的线程进行初始化
    SIZECTL为-1表示正在初始化
    sc=n-(n>>>2) ??是什么意思
  • 初始化完成后,SIZECTL改为table最大存放元素的个数

addCount() 添加元素个数

baseCount是用来统计存的元素的个数的,如果都去竞争baseCount则效率低,因此用一个CounterCell数组,让线程来竞争CounterCell数组某个位置的CounterCell对象,最后对CounterCell数组进行统计

  • 添加元素个数
    • CounterCell为空去通过CAS去修改baseCount
    • CounterCell不为空或上面竞争baseCount失败的线程
      • 数组不为空, 线程生成一个随机数,通过与CounterCell数组的长度进行与操作,来得到在CounterCell中的索引位置,通过CAS方式对当前索引上的value属性+x
      • 数组为空或上一步CAS修改失败 则 fullAddCount(x, uncontended)
  • 统计整个ConcurrentHashMap的元素个数s sumCount()
    就是把baseCount和CounterCell中的值都求和
  • while 进行扩容(扩容完后可能新数组又需要扩容
    当 s>sizeCtrl即扩容阈值 且数组不为空 且 链表长度是小于MAXIMUM_CAPACITY 则扩容
    • sc<0 表示有别的线程正在扩容
    • sc>=0
      • 通过CAS将sc改成一个负数
      • 通过transfer() 迁移

一句话总结:addCount()+fullAddCount() 就是往baseCount或CounterCell 加添加进的元素的个数的,最终就是为了保证一定能加成功,加锁方式用的是CAS,最后统计size就是统计这两部分的和。


fullAddCount()

cellsBusy :用来表示当前CounterCell是否在被其他线程使用,如果是0表示没有被其他线程使用。 只要对CounterCell进行操作,都要是保证cellsBusy为0的时候,用CAS方式将cellsBusy改为1,相当于用CAS的方式加锁。

ThreadLocalRandom.getProbe() 对线程生成一个随机数,且这个随机数是不变的对这个线程是唯一的。 也就是对这个线程来说每次调getProbe()的结果都是一样的
ThreadLocalRandom.advanceProbe(h)生成一个新的值

uncontended:用来控制是否用CAS修改当前cell的对象。因为如果进入fullAddCount()函数之前就已经尝试过CAS修改当前cell的对象值了,失败uncontended就为false。这样这里先判断uncontended为false就不让他执行下面的了。
collide:用来控制是否扩容


一句话总结:fullAddCount()就是保证一定把x加进去,具体做的事情其实就是自旋的方式创建CounterCell数组,对应索引没对象就创建,有就在此基础上加x。一直没加成功的条件下,会扩容。 只是这些操作的前提都是用CAS操作来加锁的,保证线程安全。


  • 计算当前线程的对应随机数
  • 自旋:
    • CounterCell数组不为空
      • if 对应索引处为null 即没有对象
        则创建cellsBusy对象,在修改cellsBusy成功的情况将创好的对象赋值到CounterCell数组
      • else if uncontended为 true的情况 有对象 则通过CAS方式对此对象+x
      • else if collide为true的情况进行对CounterCell扩容
        collide为true是什么情况呢?也就是什么时候或扩容?
        两次hash计算得到的索引对应的位置都有对象,并且通过cas对此对象修改失败
      • 上面都没成功就rehash 重新为线程生成随机数
    • CounterCell数组为空 且cellsBusy为0即其他线程没有使用
      • 通过CAS方式将cellsBusy改为1,也就相当于是给CounterCell数组加锁。
      • 加锁成功才初始化,即new 一个长度为2的CounterCell数组,并在线程算出来的对应索引处new CounterCell(x) 对象
      • 将cellsBusy改为0,即释放锁
    • 以上情况都不满足,则还是用CAS对baseCount去修改

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

transfer() 转移数据


ForwardNode 就是把正在被扩容的数组的node都包装成ForwardNode,hash值为MOVED即负1,这样其他线程对这个数组进行put的时候发现对应位置对应的值为-1就会helpTransfer
advance :advance为true 则一直往前找自己扩容的区域 ,找到后会被修改为false
finishing:为true即当前线程的扩容任务已做完

bound到i为当前线程的扩容范围(i>bound)
transferIndex: 每个线程进来会根据transferIndex来计算自己应该扩容的区域[bound,i],transferIndex相当于[0,transferIndex]的范围都还没被迁移,[transferIndex,n]的范围已经在被别的线程迁移了


  • 准备工作
    • 计算当前线程扩容的步长stride 默认为16
    • 初始化
      nextTab即新数组为null就创建一个长度为原数组2倍的数组,transferIndex为n (即原数组会从后往前进行迁移)
    • 创建 ForwardingNode<K,V> 对象fwd,hash值为MOVED即-1,其属性放着nextTab。
      老数组的一个位置为null或者被迁移到新数组后,就会把fwd放在当前位置。表示此数组正在扩容。别的线程访问到就会帮助扩容。
  • 循环 进行迁移
    • while(advance)得到当前线程需要扩容的区域[bound,i]
      通过cas修改transferIndex来得到当前线程需要扩容的区域[bound,i]
    • 当前位置为null,cas将fwd放在此位置
    • 当前位置为fwd,advance改为true继续前进
    • synchronized 加锁
      • 当前位置是链表,和1.7ConcurrentHashMap链表迁移逻辑一样
        遍历链表像蜘蛛纸牌一样,把最后的新位置一样的连续结点一起拿过去;再遍历链表,算出新的对应位置一个一个移过去。
      • 当前位置是红黑树,和1.8HashMap红黑树迁移逻辑一样
        通过每个TreeNode的prev和next 看作双向链表,然后通过高低指针得到两条链(每条对应新数组的新位置),分别看两条链小于untreeify阈值则untreeify,不小于则还是红黑树。
  • 结束条件
    当前线程已经把所有可以迁移的都迁移完了(i<n)
    • 但其他线程还在迁移(通过SIZECTL和初始值不等判断得到),则直接return
      因为在扩容的时候SIZECTL会被设置为一个很小的负值,每个线程进来扩都会将SIZECTL+1,扩完走的时候会把SIZECTL-1。当前线程走的时候SIZECTL和当初的很小的负值不相等说明还有别的线程在扩容。
    • 整个数组已被迁移完,即当前线程是最后一个迁移的,则将新数组赋值给table

  • 准备工作
    在这里插入图片描述
  • 获取迁移区域 和结束逻辑
    在这里插入图片描述
  • 迁移的位置为null或本来就是fwd
    在这里插入图片描述
  • 迁移的位置是链表,加锁迁移
    在这里插入图片描述
  • 迁移的位置是红黑树,加锁迁移
    在这里插入图片描述

helpTransfer()

CopyOnWriteArray

读写分离 空间换时间

从数据一致性角度讲,保证最终一致性

在这里插入图片描述

setArray后 当前副本就变成正本,原来的正本就要被抛弃
在这里插入图片描述

跳表 ConcurrentSkipListMap

保证在并发场景下 key有序的map,跳表的时间复杂度O(logn)
保证map的key的顺序,底层的数据结构基于链表O(n),又维护了一个索引,根据索引去找就更快了

在这里插入图片描述

Redis的sortset是基于跳表的

TreeMap也是key有序的map,但是不是线程安全的

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值