JDK7 和 JDK8 的 ConcurrentHashMap

一、Segment

JDK7 的 HashMap在高并发下会出现链表环,从而导致程序出现死循环。可以使用 HashTable、Collections.syncronizedMap 替代,但是二者性能都很差。因为在执行读写操作时都是将整个集合加锁,导致多个线程无法同时读写集合。因此,高并发下的 HashMap 出现的问题就需要 ConcurrentHashMap 来解决了。

【JDK7 的 ConcurrentHashMap】中有一个 Segment 的概念。Segment 本身就相当于一个 HashMap 对象。同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。单一的 Segment 结构如下:

ConcurrentHashMap 中有 2 的 n 次方个 Segment 对象,共同保存在一个名为 segments 的数组当中。因此整个 ConcurrentHashMap 的结构如下:

可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。这样的二级结构,和数据库的水平拆分有些相似。

1️⃣ConcurrentHashMap 的优势
采取了锁分段技术,每一个 segment 就好比一个自治区,读写操作高度自治,segment 之间互不影响。

Case1:不同 segment 的并发写入【可以并发执行】

Case2:同一 segment 的一写一读【可以并发执行】

Case3:同一 segment 的并发写入【需要上锁】

由此可见,ConcurrentHashMap 中每个 segment 各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。

2️⃣Concurrent 的读写过程

Get 方法:

  1. 为输入的 Key 做 Hash 运算,得到 hash 值。(为了实现 segment 均匀分布,进行了两次 Hash)
  2. 通过 hash 值,定位到对应的 segment 对象。
  3. 再次通过 hash 值,定位到 segment 当中数组的具体位置。

Put 方法:

  1. 为输入的 Key 做 Hash 运算,得到 hash 值。
  2. 通过 hash 值,定位到对应的 segment 对象。
  3. 获取可重入锁
  4. 再次通过 hash 值,定位到 segment 当中数组的具体位置。
  5. 插入或覆盖 HashEntry 对象。
  6. 释放锁。

从步骤可以看出,ConcurrentHashMap 在读写时均需要二次定位。首先定位到 segment,之后定位到 segment 内的具体数组下标。

二、每个 segment 都各自加锁,在调用 size() 的时候,怎么解决一致性的问题

1️⃣调用 size() 是统计 ConcurrentHashMap 的总元素数量,需要把各个 segment 内部的元素数量汇总起来。但是,如果在统计 segment 元素数量的过程中,已统计过的 segment 瞬间插入新的元素,此时该如何?

2️⃣ConcurrentHashMap 的 size() 是一个嵌套循环,大体逻辑如下:

  1. 遍历所有的 segment。
  2. 把 segment 的元素数量累加起来。
  3. 把 segment 的修改次数累加起来。
  4. 判断所有 segment 的总修改次数是否大于上次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数 +1;如果不是说明没有修改,统计结束。
  5. 如果尝试次数超过阈值,则对每一个 segment 加锁,再重新统计。
  6. 再次判断所有 segment 的总修改次数是否大于上次的总修改次数。由于已经加锁,次数一定和上次相等。
  7. 释放锁,统计结束。

官方源代码如下:

为什么这样设计呢?这种思想和乐观锁的思想如出一辙。为了尽量不锁住所有的 segment,首先乐观地假设 size 过程中不会有修改。当尝试一定次数,才转为悲观锁,锁住所有 segment 保证强一致性。

三、ConcurrentHashMap(线程安全

  • 底层采用分段的数组+链表实现
  • 通过把整个 map 分为 n 个 segment,保证线程安全,效率提升 n 倍,默认提升 16 倍。(读操作不加锁,由于 HashEntry 的 value 变量是volatile的,也能保证读取到最新的值。)
  • Hashtable 的synchronized是针对整张 Hash 表的,即每次锁住整张表让线程独占,ConcurrentHashMap 允许多个修改操作并发进行,其关键在于使用了锁分离技术。
  • 有些方法需要跨段,比如 size() 和 containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。
  • 扩容:段内扩容(段内元素超过该段对应 HashEntry 数组长度的 75% 触发扩容,不会对整个 map 进行扩容),插入前检测是否需要扩容,避免无效扩容。

从类图可看出在存储结构中 ConcurrentHashMap 比 HashMap 多出了一个类 Segment,而 Segment 是一个可重入锁。ConcurrentHashMap 是使用了锁分段技术来保证线程安全的。

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

ConcurrentHashMap 提供了与 Hashtable 和 SynchronizedMap 不同的锁机制。Hashtable 中采用的锁机制是一次锁住整个 hash 表,从而在同一时刻只能由一个线程对其进行操作;而 ConcurrentHashMap 中则是一次锁住一个桶。

ConcurrentHashMap 默认将 hash 表分为 16 个桶,诸如 get、put、remove 等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在同时能有 16 个写线程执行,并发性能的提升是显而易见的。

四、ConcurrentHashMap 的 JDK7/JDK8 区别

1️⃣整体结构

JDK7:Segment + HashEntry + Unsafe
JDK8:移除 Segment,使锁的粒度更小,Synchronized + CAS + Node + Unsafe

2️⃣put()

JDK7:先定位 Segment,再定位桶,put 全程加锁,没有获取锁的线程提前找桶的位置,并最多自旋 64 次获取锁,超过则挂起。
JDK8:由于移除了 Segment,类似 HashMap,可以直接定位到桶,拿到 first 节点后进行判断:①为空则 CAS 插入;②为 -1 则说明在扩容,则跟着一起扩容;③ else 则加锁 put(类似 JDK7)

3️⃣get()

基本类似,由于 value 声明为 volatile,保证了修改的可见性,因此不需要加锁。

4️⃣resize()

JDK7:跟 HashMap 步骤一样,只不过是搬到单线程中执行,避免了 HashMap 在 JDK7 中扩容时死循环的问题,保证线程安全。
JDK8:支持并发扩容,HashMap 扩容在 JDK8 中由头插改为尾插(为了避免死循环问题),ConcurrentHashmap 也是,迁移也是从尾部开始,扩容前在桶的头部放置一个 hash 值为 -1 的节点,这样别的线程访问时就能判断是否该桶已经被其他线程处理过了。

5️⃣size()

JDK7:很经典的思路:计算两次,如果不变则返回计算结果,若不一致,则锁住所有的 Segment 求和。
JDK8:用 baseCount 来存储当前的节点个数,这就涉及到 baseCount 并发环境下修改的问题。

五、问题

如何在很短的时间内将大量数据插入到 ConcurrentHashMap,换句话说,就是提高 ConcurrentHashMap 的插入效率。-----尽量散列均匀和避免加锁。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JFS_Study

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值