ConcurrentHashMap源码&底层数据结构分析

ConcurrentHashMap:线程安全的HashMap

1.7

  JDK 1.7 中 的 ConcurrentHashMap 采用了分段锁的设计,只有在同一个分段内才存在竞态关系,不同的分段锁之间没有锁竞争。相比于对整个 Map 加锁,分段锁大大提高了高并发环境下的处理能力。

  ConcurrentHashMap 底层是由一个 Segment 数组组成的,每个 Segment 元素包含一个 HashEntry 数组,而每个 HashEntry 元素都是一个链表结构的节点。

  HashEntry 和 HashMap 非常类似,唯一的区别就是其中的核心数据 value 以及 next 都被 volatile 修饰,以此保证了多线程读写过程中对应变量的可见性。

put 的流程如下:
    通过 key 的 hashcode 定位到 Segment 中对应的 HashEntry;
    遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value 值。
    为空则需要新建一个 HashEntry 并加入到 Segment 中,在此之前,会先判断是否需要扩容。
    最后解除获取当前 Segment 的锁。

get 方法的流程如下:
    根据 key 的 hash 值通过位运算以及基础偏移量的相加获取具体的 key 偏移量 u;
    通过偏移量 u 去 Segment 数组中获取 u 位置处的 Segment 对象;
    在有对象的情况下,通过 for 循环链表的方式获取 Segment 对象的 HashEntry 值进行比较;
    如果存在则返回对应 value 值,否则返回 null 值表示不存在;

 1.8

  JDK 1.8 的 ConcurrentHashMap 取消了 Segment 分段锁,采取 CAS 和 synchronized 来保证并发的安全性。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发问题。

将 JDK 1.7 中存放数据的 HashEntry 改为了 Node. Node 是最核心的内部类,它包装了 key-value 键值对,所有插入 ConcurrentHashMap 的数据都包装在这里面。它与 HashMap 中的定义相似,差别在于,它对 value 和 next 属性设置了volatile 同步锁(与 JDK 1.7 的 Segment 相同),不允许调用 setValue 方法直接改变 Node 的value 域,还增加了 find 方法辅助 map.get() 方法的实现。

put 方法的流程如下:
    判断对应的 key 和 value 是否为空,为空则直接抛出异常;
    判断 table 数组是否为空,空则进行初始化操作;
    当 table 不为空时,判断在下标 i 的位置是否存在值,不存在则通过 CAS 方式直接在对应位置进行更新,更新成功则直接退出;
    如果下标 i 的位置不为空,且正在准备扩容,则调用 helpTransfer() 方法帮忙 table 进行扩容;
    如果未处于扩容状态,则进行 synchronized 加锁操作给头节点加锁,同时判断当前偏移处的值是否是前面判断时的值;
    判断头节点是否为链表,需要通过链表的方式循环判断是否有与当前 key 相同的值,有则在允许覆盖的情况下进行覆盖,没有则新建一个 Node 值放在链表最后;
    如果当前的 Node 节点为树节点,则进行树节点的相关操作;
    当节点个数 binCount 长度超过8时,就对当前 Node 节点链表进行红黑树的转换;
    最后根据 binCount 值,通过 addCount() 方法增加元素个数,同时检测是否需要进一步扩容;

get 方法的流程如下:
    对 key 进行 hash 取值,然后判断在 table 中的 hash 值计算偏移量后的位置是否有值;
    有值则首先判断当前偏移处的 hash 值是否与传入的 key 的 hash 值相同;
    相同则判断当前 key 是否就是传入的 key,如果是则直接取值返回;
    否则判断当前的偏移处的 hash 值是否小于 0,如果 eh < 0,代表是红黑树,按照红黑树的方式 find 返回;
    如果大于0,则通过链表的方式循环当前偏移处的 Node 对象,直到获取到有相同的 key 值或者链表结束为止;
    获取到值则直接返回,否则返回 null 表示不存在;

 

ConcurrentHashMap 线程安全的具体实现方式/底层具体实现

JDK1.8 之前:

  首先将数据分为一段一段(Segment)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
  ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成
  Segment 继承了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。

  Java 8 几乎完全重写了 ConcurrentHashMap,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。

  ConcurrentHashMap 取消了 Segment 分段锁,采用 Node + CAS + synchronized 来保证并发安全。数据结构跟 HashMap 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。

  Java 8 中,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。

JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?

  • 线程安全实现方式:JDK 1.7 采用 Segment 分段锁来保证安全, Segment 是继承自 ReentrantLock。JDK1.8 放弃了 Segment 分段锁的设计,采用 Node + CAS + synchronized 保证线程安全,锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点。
  • Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
  • 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。

 

ConcurrentHashMap 和 Hashtable 的区别

ConcurrentHashMapHashtable 的区别主要体现在实现线程安全的方式上不同。

  • 底层数据结构:
    • JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
    • Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
    • 在 JDK1.7 的时候,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
    • 到了 JDK1.8 的时候,ConcurrentHashMap 已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。(JDK1.6 以后 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
    • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

下面,我们再来看看底层数据结构的对比图。

 

ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。

JDK1.7 Segment 数组中的每个元素包含一个 HashEntry 数组,每个 HashEntry 数组属于链表结构

JDK1.8 的 ConcurrentHashMap 不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。当冲突链表达到一定长度时,链表会转换成红黑树。TreeNode是存储红黑树节点,被TreeBin包装。TreeBin通过root属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMapTreeBin通过waiter属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。

 

ConcurrentHashMap 能保证复合操作的原子性吗?

  ConcurrentHashMap 是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap 多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的。

  复合操作是指由多个基本操作(如putgetremovecontainsKey等)组成的操作,例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。

如何保证 ConcurrentHashMap 复合操作的原子性呢?

  ConcurrentHashMap 提供了一些原子性的复合操作,如 putIfAbsentcomputecomputeIfAbsentcomputeIfPresentmerge等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。不建议使用加锁的同步机制,违背了使用 ConcurrentHashMap 的初衷。在使用 ConcurrentHashMap 的时候,尽量使用这些原子性的复合操作方法来保证原子性。

ConcurrentHashMap 的 key 和 value 不能为 null

主要是为了避免模棱两可歧义性

就拿#get(key)方法获取value值说说歧义性:返回null有两种情况:

① map中没有这个key-value键值对,返回null

② map中有这个key-value键值对,但是value值为null,此时也返回null

 

posted @ 2023-04-19 15:58  壹索007  阅读(37)  评论(0编辑  收藏  举报