重新认识ConcurrentHashMap

怼烎@ 2023-01-07 07:06 260阅读 0赞

一、ConcurrentHashMap数据结构

ConcurrentHashMap 底层是基于 数组 + 链表 组成的,不过在 jdk1.7 和 1.8 中具体实现稍有不同。
在1.7中数据结构
在这里插入图片描述
是由 Segment 数组、HashEntry 链表组成。它和HashMap 一样,仍然是数组加链表。
Segment 是 ConcurrentHashMap 的一个内部类,主要的组成如下:

  1. static final class Segment<K,V> extends ReentrantLock implements
  2. Serializable {
  3. private static final long serialVersionUID = 2249069246763182397L;
  4. // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
  5. transient volatile HashEntry<K,V>[] table;
  6. transient int count;
  7. // 记得快速失败(fail—fast)么?
  8. transient int modCount;
  9. // 大小
  10. transient int threshold;
  11. // 负载因子
  12. final float loadFactor;
  13. }

HashEntry跟HashMap差不多的,但是不同点是,他使用volatile去修饰了他的数据Value还有下一个节点next。
volatile的特性:
(1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其它线程来说是立即可见的。(实现可见性)
(2)禁止进行指令重排序。(实现有序性)
(3)volatile 只能保证对单次读/写的原子性。i++ 这种操作不能保证原子性。

二、那你能说说他并发度高的原因么?

原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承于 ReentrantLock。

分段锁首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

不会像 HashTable 那样不管是 put 还是 get 操作都需要做同步处理,理论上 ConcurrentHashMap支持CurrencyLevel (Segment 数组数量)的线程并发
每当每个线程占用锁访问一个 Segment 时,不会影响到其他Segment。
就是说如果容量大小是16他的并发度就是16,可以同时允许16个线程操作16个Segment而且还是线程安全的。

  1. public V put(K key, V value) {
  2. Segment<K,V> s;
  3. if (value == null)
  4. throw new NullPointerException();//这就是为啥他不可以put null值的原因
  5. int hash = hash(key);
  6. int j = (hash >>> segmentShift) & segmentMask;
  7. if ((s = (Segment<K,V>)UNSAFE.getObject
  8. (segments, (j << SSHIFT) + SBASE)) == null)
  9. s = ensureSegment(j);
  10. return s.put(key, hash, value, false);
  11. }

他先定位到Segment,然后再进行put操作。
我们看看他的put源代码,你就知道他是怎么做到线程安全的了,关键句子我注释了。

  1. final V put(K key, int hash, V value, boolean onlyIfAbsent) {
  2. //将当前Segment中的table通过key的hashcode定位到HashEntry
  3. HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
  4. V oldValue;
  5. try {
  6. HashEntry<K,V>[] tab = table;
  7. int index = (tab.length - 1) & hash;
  8. HashEntry<K,V> first = entryAt(tab, index);
  9. for (HashEntry<K,V> e = first;;) {
  10. if (e != null) {
  11. K k;
  12. //遍历该HashEntry,如果不为空则判断传入的key和当前遍历的key是否相等,相等则覆盖旧的value。
  13. if ((k = e.key) == key ||
  14. (e.hash == hash && key.equals(k))) {
  15. oldValue = e.value;
  16. if (!onlyIfAbsent) {
  17. e.value = value;
  18. ++modCount;
  19. }
  20. break;
  21. }
  22. e = e.next;
  23. }
  24. else {
  25. // 不为空则需要新建一个HashEntry并加入到Segment中,同时会先判断是否需要扩容。
  26. if (node != null)
  27. node.setNext(first);
  28. else
  29. node = new HashEntry<K,V>(hash, key, value, first);
  30. int c = count + 1;
  31. if (c > threshold && tab.length < MAXIMUM_CAPACITY)
  32. rehash(node);
  33. else
  34. setEntryAt(tab, index, node);
  35. ++modCount;
  36. count = c;
  37. oldValue = null;
  38. break;
  39. }
  40. }
  41. } finally {
  42. //释放锁
  43. unlock();
  44. }
  45. return oldValue;
  46. }

首先第一步的时候会尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用scanAndLockForPut() 自旋获取锁。

  1. 尝试自旋获取锁
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功。

三、ConcurrentHashMap 的get的逻辑呢?

get 逻辑比较简单,只需要将 Key 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体的元素上。

由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。

四、你有没有发现1.7虽然可以支持每个Segment并发访问,但是还是存在一些问题?

ConcurrentHashMap 的 get 方法是非常高效的,因为整个过程都不需要加锁。
因为基本上还是数组加链表的方式,我们去查询的时候,还得遍历链表,会导致效率很低,这个跟jdk1.7的HashMap是存在的一样问题,所以他在jdk1.8完全优化了。

五、那你再跟我聊聊jdk1.8他的数据结构是怎么样子的呢?

其中抛弃了原有的 Segment 分段锁,而采用了 CAS + synchronized 来保证并发安全性。

跟HashMap很像,也把之前的HashEntry改成了Node,但是作用不变,把值和next采用了volatile去修饰,保证了可见性,并且也引入了红黑树,在链表大于一定值的时候会转换(默认是8)。

六、同样的,你能跟我聊一下他值的存取操作么?以及是怎么保证线程安全的?

ConcurrentHashMap在进行put操作的还是比较复杂的,大致可以分为以下步骤:
(1)根据 key 计算出 hashcode 。
(2)判断是否需要进行初始化。为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功
(3)如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
(4)如果都不满足,则利用 synchronized 锁写入数据。
(5)如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树
在这里插入图片描述

七、你在上面提到CAS是什么?自旋又是什么?

CAS 是乐观锁的一种实现方式,是一种轻量级锁,JUC 中很多工具类的实现就是基于 CAS 的。CAS原理机制分析
CAS 操作的流程如下图所示,线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。
在这里插入图片描述
这是一种乐观策略,认为并发操作并不总会发生
还是不明白?那我再说明下,乐观锁在实际开发场景中非常常见,大家还是要去理解。

就比如我现在要修改数据库的一条数据,修改之前我先拿到他原来的值,然后在SQL里面还会加个判断,原来的值和我手上拿到的他的原来的值是否一样,一样我们就可以去修改了,不一样就证明被别的线程修改了你就return错误就好了。
SQL伪代码大概如下:

  1. update a set value = newValue where value = #{oldValue}

oldValue就是我们执行前查询出来的值

八、CAS就一定能保证数据没被别的线程修改过么?什么是ABA?那怎么解决ABA问题?

并不是的,比如很经典的ABA问题,CAS就无法判断了。
就是说来了一个线程把值改回了B,又来了一个线程把值又改回了A,对于这个时候判断的线程,就发现他的值还是A,所以他就不知道这个值到底有没有被人改过,其实很多场景如果只追求最后结果正确,这是没关系的。

但是实际过程中还是需要记录修改过程的,比如资金修改什么的,你每次修改的都应该有记录,方便回溯。

版本号去保证就好了,就比如说,我在修改前去查询他原来的值的时候再带一个版本号,每次判断就连值和版本号一起判断,判断成功就给版本号加1。

  1. update a set value = newValue vision = vision + 1 where value = #{oldValue} and vision = #{vision}

判断原来的值和版本号是否匹配,中间有别的线程修改,值可能相等,但是版本号100%不一样。除了版本号还有别的方法保证么?

其实有很多方式,比如时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值的时候一起修改更新时间,这样也能保证,方法很多但是跟版本号都是异曲同工之妙,看场景大家想怎么设计吧。

CAS性能很高,但是我知道synchronized性能可不咋地,为啥jdk1.8升级之后反而多了synchronized?

synchronized之前一直都是重量级的锁,但是后来java官方是对他进行过升级的,他现在采用的是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。

九、ConcurrentHashMap的get操作又是怎么样子的呢?

根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果是红黑树那就按照树的方式获取值。
就不满足那就按照链表的方式遍历获取值。

小结:1.8 在 1.7 的数据结构上做了大的改动,采用红黑树之后可以保证查询效率(O(logn)),甚至取消了 ReentrantLock 改为了 synchronized,这样可以看出在新版的 JDK 中对 synchronized 优化是很到位的。

原文链接
https://blog.csdn.net/qq\_35190492/article/details/103589011

发表评论

表情:
评论列表 (有 0 条评论,260人围观)

还没有评论,来说两句吧...

相关阅读

    相关 重构之重新认识

    重构?每次听到这个词,头脑里面闪现的就是“推倒重做,代码重写”,那到底重构是什么玩意?所谓“外事不决问谷歌,内事不决问百度,房事不决问天涯”,百度百科上面的解释是:重构(Ref

    相关 重新认识C语言

    重新认识Linux C语言 1.函数的隐形声明(implicit Declaration):main函数使用没有先声明的函数的时候,编译器就会认为在使用此个函数的      

    相关 重新认识秦始皇

    由于本人 特别喜欢 曹操 秦始皇 刘邦 韩信 最近又找到一些资源,想分享给大家 让大家重新认识下 秦始皇 [视频资源地址][Link 1] [Link 1]: ht

    相关 重新认识面向对象

    对象是什么? – 从概念层面讲,对象是某种拥有责任的抽象。 – 从规格层面讲,对象是一系列可以被其他对象使用的公共接口。 – 从语言实现层面来看,对象封装了代码和数