Java - ConcurrentHashMap

本文详细解析了JDK1.7中ConcurrentHashMap的锁分段机制、Segment和HashEntry结构,以及并发put操作和扩容策略。对比了JDK1.7和1.8版本在锁粒度、哈希冲突处理和并发操作上的改进。
摘要由CSDN通过智能技术生成

JDK1.7#ConcurrentHashMap

JDK1.7 的 ConcurrentHashMap 采用锁分段技术解决了 HashMap 线程不安全和 HashTable 并发场景下低效的问题,默认并发度为 16,与 HashTable 相比写入效率大大提高,并且读取可以以无锁的方式进行。

ConcurrentHashMap 维护着一个 Segment 数组,Segment 继承自可重入锁。Segment 内部维护着一个 HashEntry 数组,HashEntry 是一个链表节点,同一个桶中出现冲突时节点会串成一个链表。

如何确定Segment数组和HashEntry数组的长度?

Segment 数组的大小取决于 concurencyLevel,默认为 16,手动设置后会取不小于该值的最小的 2 次幂数值,最大值为 65536。之所以这样设计,是为了能够使用按位与的散列算法来计算 segments 数组的索引。hash&(length-1) 可以快速计算出 [0,length-1] 范围内的下标。

如何定位Segment和HashEntry?

将补充哈希函数应用于给定的 hashCode,避免劣质哈希函数的影响。通过这种再散列能让数字的每一位都参加到散列运算当中,从而减少散列冲突。

通过 (h>>> segmentShift) & segmentMask) 定位 Segment,通过 ((tab.length - 1) & h) 定位元素在 HashEntry 数组中的下标。

初始化 segmentShift 和 segmentMask。这两个全局变量在定位 segment 时的哈希算法里需要使用,sshift 等于 ssize 从 1 向左移位的次数,在默认情况下 concurrencyLevel 等于 16,1 需要向左移位移动 4 次,所以 sshift 等于 4。

segmentShift 用于定位参与 hash 运算的位数,segmentShift 等于 32 减 sshift,所以等于 28,这里之所以用 32 是因为 ConcurrentHashMap 里的 hash () 方法输出的最大数是 32 位的,后面的测试中我们可以看到这点。

segmentMask 是哈希运算的掩码,等于 ssize 减 1,即 15,掩码的二进制各个位的值都是 1。因为 ssize 的最大长度是 65536,所以 segmentShift 最大值是 16,segmentMask 最大值是 65535,对应的二进制是 16 位,每个位都是 1。

并发put

假如有线程 A、B 在同一 Segment 上执行 put 操作,线程 A 执行 tryLock 获取锁,然后把 HashEntry 插入到相应位置。线程 B 获取锁失败,会使用 scanAndLockForPut 方法通过重复执行获取锁。 多处理器环境下会尝试 64 次 tryLock,单处理器下会尝试 1 次 tryLock,之后如果还是没有获取到锁会调用 lock 方法挂起当前线程。当线程 A 执行完插入操作时,会调用 unlock 释放锁,唤醒线程 B 继续执行。

如何扩容?

扩容为原来的两倍,因为是以 2 的幂扩容,元素要么仍然处于原来的位置 i,要么就会移动到原始 cap+i 的位置。在移动元素时,JDK1.7 的 rehash () 方法会找到链表中的某一个节点 lastRun,确保从 lastRun 节点开始,之后的所有节点都和 lastRun 落在同一个桶中。那我们只需要对该节点之前的元素进行逐个重排,lastRun 节点及其之后的节点移动一次即可。

如何获取总的元素数量?

不加锁遍历 Segment 统计 size,比较第一次和第二次统计的结果。若两次结果不同则第三次会直接对每个 Segment 加锁,再统计 size。

避免热点域

每一个 Segment 都有一个 count 对象用来记录本 Segment 中包含的 HashEntry 的记录数,这样可以避免更新数量时锁定整个 ConcurrentHashMap。

ConcurrentHashMap 支持高并发的原理

ConcurrentHashMap 的读操作是完全不需要加锁的,原因是 ConcurrentHashMap 不会在链表的中间插入节点,默认使用头插法,加上使用 volatile 写入,put 时其他线程要么看到的是插入之前的链表,要么看到的是插入之后的链表。
对于 remove 操作来说,虽然可能会从链表中间移除节点,但是由于使用的也是 volatile 写入,其他线程只会看到删除前和删除后两种完整的状态 (要么包括 node 要么不包括 node),不会看到中间状态。
对于 clear 操作来说,就是遍历 segment 逐个加锁,加锁后遍历数组,逐个使用 volatile 写入的方式把数组置为 null,由于不会修改链表的结构,对正在遍历链表的线程也不会造成影响,所以也是线程安全的。

JDK1.8#ConcurrentHashMap

与JDK1.7相比,有以下区别:

  • 锁粒度更细,并发能力更强
  • 使用红黑树解决哈希冲突问题,极端情况下性能依然稳定 O(logn)
  • 简化了哈希计算函数(红黑树可以兜底)

JDK1.8 放弃了分段锁 Segment 的设计,通过对头节点进行加锁,进一步降低了锁的粒度,底层采用的是 Node+CAS+synchronized 的方式。链表和数组的操作利用 cas 来保证原子性,利用 volatile 来保证可见性。

并发put

执行 put 方法插入数据时,使用 key 的 hash 值找到数据的插入位置,如果当前位置的 Node 还没有初始化,则通过 CAS 初始化数据。
如果当前位置的 Node 不为空,使用 synchronized 对 Node 加锁,如果当前 Node 已经升级为红黑树 TreeBin,就调用 putTreeVal 插入元素。
如果当前链表的节点数达到 8,就会调用 treeifyBin 把链表升级为红黑树。

如何扩容?

如果链表元素达到 8,会把链表转为红黑树,但是如果数组长度小于 64,会先尝试扩容。为了让并发扩容的效率更高,ConcurrentHashMap 会让发生竞争的线程帮助最早开始扩容的线程从原始数组往新数组移动数据。

其他线程如何帮助扩容?

当 put 线程发现 Node 节点为 ForwardingNode 时,会尝试帮助扩容,参与扩容之前会根据 sizeCtl 进行判断是否达到最大线程数,是否已经扩容结束,若仍在扩容且未达到最大线程数,则利用 cas 把 sizeCtl 加 1,再调用 transfer 方法帮助扩容。

transfer方法里面干啥了?

说白了就是迁移节点,每个线程分配一定数量的桶,迁移之前先加锁,迁移时分成高低两条链表,迁移后 cas 设置 fwd 节点。stride 内的节点没迁移完成则继续迁移,都迁移完成后若 tab 尚未迁移完成则继续帮助迁移。

sizeCtl的作用是什么?

  • 初始化时设置为-1表示正在初始化,其他线程发现后会让出cpu。
  • 初始化完成后用于记录集合的负载容量值,即触发集合扩容的极限值。
  • 扩容过程用于协调多线程扩容。过程中会初始化一个小于0的基数,每加入一个线程加一,退出一个线程减一。

如何获取总的元素数量?

不存在竞争的情况下直接拿 baseCount,若存在竞争额外遍历一遍 CounterCells 数组累加 baseCount 得到总的数量。

关于fail-fast和fail-safe

采用 fail-fast 机制的类在遍历过程中一旦集合被修改就会抛出 ConcurrentModificationException 快速失败,例如 HashMap、ArrayList、HashTable 等。

采用 fail-safe 机制的类允许在遍历过程中修改集合,永远不会抛出 ConcurrentModificationException。 但是底层实现机制略有不同,CopyOnWriteArrayList 在修改时会复制一份数据,因此不会影响其他线程的读取。 ConcurrentHashMap 修改时不会复制数据,而是采用了弱一致性协议,通过牺牲部分一致性来保证 fail-safe 和性能。

  • 46
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值