Java集合-源码浅析个人笔记-{ConcurrentHashMap}

阅读建议

1. 本篇文章仅用于个人笔记,因此有部分简略表达词汇(hm = HashMap, lhm = LinkedHashMap, chm = ConcurrentHashMap, ht = HashTable)

2. 因为写的比较简略,没有对一些源码出现的变量名做说明,建议已经对该集合有基本了解或对照源码做补充阅读,如果笔记有问题也欢迎讨论

3. 佬们有遇见过的相关面试问题也教教我。

源码分析

JDK1.7

  • 存储结构:多个segment的组合体,每个segment中包含一个类似hm的hashEntry数组结构,因此可以在segment内部进行hm扩容,但是segment的个数一旦初始化就不能再改变。默认segment个数为16个,也就是说chm默认支持最多16个线程并发。
    • 通过segment+ReentrantLock实现并发支持。
    • 如果并发度设置过小,会带来严重的锁竞争问题;
    • 如果并发度设置过大,原本位于同一个segment内的访问会扩散大不同的segment中,cpu cache命中率会下降,从而引起程序性能下降。
  • 初始化:
    • 检验并发级别大小:如果超过了1<<16则重置为1<<16。(端口数量最大是这个数,当然0号是定义成无效端口的。不过最大线程数是可以超过端口数的,所以这可能只是一个防止系统异常的兜底策略)
    • 寻找并发级别之上最接近2的幂次方的数ssize,作为初始化容量大小。(每个hm的容量:c = initialCapacity/ssize, 容量cap = c之上最接近的2的幂次)
    • 记录segmentShift偏移量,这个值为【2^N中的N】,默认是32 - sshift = 28。
    • 记录段掩码segmentMask,默认为ssize-1。
    • 初始化segment[0],默认大小是2,由于负载因子为0.75,插入第二个值的时候才会扩容。
  • put方法
    • 计算要put到的key的位置,获取指定位置的segment
      • 这里是通过取hash的高N位(这个N就是SShift)和SMask(15就是1111)做与运算。
    • 如果指定位置的segment为空,就初始化这个segment。(通过反复的确认null来确保每一步对segment状态的改变都不会出现hm中put的线程不安全问题。)
      • 检查是否为null,为null才初始化。
      • 自选判断指定位置的segment是否是null,使用CAS在这个位置赋值初始化segment。
      • 使用创建的HashEntry数组初始化这个segment。
      • 再次检查计算到的指定位置segment是否为null。
      • 使用segment[0]的容量和负载因子创建HashEntry数组。
    • 获取和初始化完segment后内部调用segment的put方法
      • 由于segment继承了ReentrantLock,很容易可以通过加锁来保证线程安全,这里通过自旋获取锁加锁。
      • 计算put到的index位置,然后遍历HashEntry数组尝试put
        • 若entry元素不存在:
          • 1. 如果当前容量大于扩容阈值,小于最大容量,进行扩容
          • 2. 直接头插法插入
        • 若entry元素存在:
          • 1. 遍历判断当前链表元素的key和hash是否符合要求,一致则替换
          • 2. 全都不一致
            • 如果当前容量大于扩容阈值,小于最大容量,进行扩容
            • 链表头插法插入
        • 释放锁
  • 扩容rehash方法
    • 针对HashEntry的扩容方法,可以参考hm的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
    • 用两个for循环收尾
      • 第一个是找到同样进入新位置的一批节点,直接接到新位置去。
      • 第二个是把剩余节点通过头插法插入到指定位置链表。
  • get方法
    • 1. 一次hash定位到对应的segment位置。
    • 2. 再一次hash定位到具体的元素位置。
    • 有链表的话就要遍历链表找对应值了,这也是1.7的chm效率低下的一部分原因,所以它在1.8被优化掉了。
    • 整个get方法没有用到加锁,因为HashEntry中的value属性是被volatile关键字修饰的,可以保证都看到最新的同一份内容,并且不会发生指令重排。
  • 可以完成同一segment的并发读写和不同segment的并发写入

JDK1.8

  • 存储结构:底层是Node数组+链表/红黑树,链表达到一定长度(8)时会转换成红黑树。
  • 通过对Node节点的CAS+Synchronized关键字实现并发支持。
  • 初始化:
    • ConcurrentHashMap的构造函数仅用于设置初始参数,比如初始容量、负载因子和并发级别(不过初始化的并发级别并没有用到,只是说如果初始容量小于并发级别,会将容量初始为并发级别大小,因为是按Node节点数做并发的)。
    • 而整个table的初始化是在向chm中插入第一个元素的时候发生的。比如调用put、computeIfAbsent、compute、merge方法并且检查table == null的时候。
    • 初始化的并发控制主要用过sizeCtl属性完成:
      • -1说明正在初始化,其他线程需要自旋等待。
      • >0 表示 table 扩容的阈值,如果 table 已经初始化。
      • 0 表示 table 初始化大小,如果 table 没有初始化。
      • -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数。
    • 初始化方法主要应用了关键属性sizeCtl 如果这个值<0,表示其他线程正在进行初始化,就放弃这个操作。在这也可以看出ConcurrentHashMap的初始化只能由一个线程完成。如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。初始化数组后,将sizeCtl的值改为0.75*n。
  • put方法
    • 根据 key 计算出 hashcode 。
      • 判断是否需要进行初始化。
      • hash & (n-1) 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
      • 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
      • 如果都不满足,则利用 synchronized 锁写入数据。
      • 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
  • get方法
    • 根据计算出来的hashcode来定位,如果直接在桶上就直接返回桶值
      • 如果桶里面是红黑树就按树的方式拿值
      • 如果是链表就按链表方式

常见问题

  • chm和ht的区别?
    • jdk1.7:ht是用synchronized关键字锁住了整个类,而chm用了分段锁技术,其中的segment继承ReentrantLock,不像ht一样无论是put还是get都要做同步处理。chm支持segment数量级别的并发。
    • jdk1.8:chm用的synchronized+CAS锁住每个Node节点,而不是锁整个类。
  • 8
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值