ConcurrentHashMap小记

为了减少保存的书签数目,整理一下收藏的文章。主要整理的还是思想吧,代码分析看链接文章就好了。

JDK7中的ConcurrentHashMap

设计

在JDK7的实现中,ConcurrentHashMap被分成了许多段,只有在同一个段内才存在竞态关系。实际上ConcurrentHashMap是一个Segment数组,而Segment继承自ReentrantLock static final class Segment<K,V> extends ReentrantLock implements Serializable{...},这样就能保证每个segment是线程安全的了,也就实现了全局的线程安全。由于ConcurrentHashMap的分段的设计,这会导致一些扫描整个map的方法需要一些特殊的实现,如size(),containsValue()方法,它需要进行跨段访问,所以在调用这些方法的时候,需要按顺序锁定所有段,操作完毕后再按顺序释放所有锁,如果不按顺序,极有可能出现死锁。
JDK7中ConcurrentHashMap结构

图片修改自:Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析

并发度

并发度可以理解为程序能够同时更新并不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]数组的长度。ConcurrentHashMap的默认并发度为16,用户也可以在构造函数中设置。当用户设置并发度时,ConcurrentHashMap会使用大于等于该值的最小2幂指数作为实际并发度,例如用户设置并发度17,实际并发度为32。运行时通过将key的高n位(n = 32 – segmentShift)和并发度减1(segmentMask)做位与运算定位到所在的Segmentlong u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;。segmentShift与segmentMask都是在构造过程中根据concurrency level被相应的计算出来。

创建分段锁

与JDK6不同,JDK7中除第一个Segment外,其余的Segments都是采用的延迟初始化的机制,即每次put之前都需要检查key对应Segment是否为null,如果是则调用ensureSegment()确保Segment被创建。ensureSegment可能在并发环境下被调用,但与想象中不同,ensureSegment并未使用锁来控制竞争,而是使用了Unsafe对象的getObjectVolatile()提供的原子读语义结合CAS来确保Segment创建的原子性。

put/putAll/puIfAbsent(remove类似)

在进行写入操作的时候,会调用node = tryLock() ? null : scanAndLockForPut(key, hash, value)。如果tryLock()方法失败,则会进入scanAndLockForPut方法,这个方法会一直tryLock直到超出最大循环次数,如果超出就阻塞等待,直到成功拿到独占锁。
此时若存在A,B两个线程竞争:

  1. 线程A执行tryLock()方法成功获取锁,则把HashEntry对象插入到相应的位置
  2. 线程B获取锁失败,则执行scanAndLockForPut()方法,在scanAndLockForPut方法中,会通过重复执行tryLock()方法尝试获取锁,在多处理器环境下,重复次数为64,单处理器重复次数为1,当执行tryLock()方法的次数超过上限时,则执行lock()方法挂起线程B
  3. 当线程A执行完插入操作时,会通过unlock()方法释放锁,接着唤醒线程B继续执行

get和containsKey

读取操作简单的多了,它不需要任何锁,而是通过Unsafe对象的getObjectVolatile()方法提供的原子读语义,来获得Segment以及对应的链表,然后对链表遍历判断是否存在key相同的节点以及获得该节点的value。

  1. 计算 hash 值,找到 segment 数组中的具体位置
  2. segment中也是一个数组(table),根据 hash 找到数组中具体的位置
  3. 到这里是链表了,顺着链表进行查找即可

size和containsValue

首先不加锁循环执行以下操作:循环所有的Segment(通过Unsafe的getObjectVolatile()以保证原子读语义),获得对应的值以及所有Segment的modcount之和。如果连续两次所有Segment的modcount和相等,则过程中没有发生其他线程修改ConcurrentHashMap的情况,返回获得的值。

当循环次数超过预定义的值时,这时需要对所有的Segment依次进行加锁,获取返回值后再依次解锁。值得注意的是,加锁过程中要强制创建所有的Segment,否则容易出现其他线程创建Segment并进行put,remove等操作。

一般来说,应该避免在多线程环境下使用size和containsValue方法

注1:modcount在put, replace, remove以及clear等方法中都会被修改。
注2:对于containsValue方法来说,如果在循环过程中发现匹配value的HashEntry,则直接返回true。

并发问题分析

上文已经说明了put 过程和 get 过程,我们可以看到 get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作

put操作的线程安全性:

  1. 初始化段,使用了 CAS 来初始化 Segment 中的数组。
  2. 添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
  3. 扩容。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。

remove操作的线程安全性:

  1. get 操作需要遍历链表,但是 remove 操作会”破坏”链表。
  2. 如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
  3. 如果 remove 先破坏了一个节点,分两种情况考虑。 1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。

JDK7ConcurrentHashMap总结

总的来说,ConcurrentHashMap是通过以下3方面来保证高效并发的:

  • 通过分段锁技术保证并发环境下的写操作
  • 通过HashEntry的不变性、volatile变量(table是volatile的,还有HashEntry的value,next成员是volatile)的内存可见性和UNSAFE原子语义
  • 通过不加锁和加锁两种方案控制跨段操作(size,containsValue等)的安全性

JDK8中的ConcurrentHashMap

设计

JDK8中的ConcurrentHashMap的改动,可以参考JDK8相对于JDK7的HashMap的改动,ConcurrentHashMap也引入了红黑树。
JDK8中ConcurrentHashMap结构

图片修改自:Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析

从图上可以显而易见的看出,JDK8中的ConcurrentHashMap取消了分段锁的设计,而是采用了部分加锁和CAS算法来实现多线程安全。

sizeCtl属性

它用来控制table的初始化和扩容操作,private transient volatile int sizeCtl;,可以看到它是volatile修饰的。

  1. -1代表table正在初始化
  2. -N表示有N-1个线程正在进行扩容操作
  3. 正数或者0(默认为0)表示还没有初始化,这个数值表示初始化或下一次扩容的大小,它的值始终是当前

ConcurrentHashMap容量的0.75倍,0.75(n - (n >>> 2)),这与loadfactor(负载因子)是对应的

ForwardingNode

内部类,继承自Node,在扩容的时候会用到,存储的是新的nextTable的引用。

初始化

初始化时调用的是initTable方法,它主要使用了sizeCtl的值,如果sizeCtl<0,那么就表示其他线程正在对它进行操作,由此就会放弃本次的initTable操作。由此也能看出ConcurrentHashMap的初始化只能由一个线程完成。如果sizeCtl=0,那么initTable获得初始化权限,并将sizeCtl赋值为-1,防止其他线程的进入。

transfer扩容

当ConcurrentHashMap容量不足的时候,需要对table进行扩容。它支持多线程进行扩容操作,而并没有加锁。这样做的目的是利用多线程多扩容操作进行“加速”。
整个扩容操作分为两个部分

  1. 第一部分是构建一个nextTable,它的容量是原来的两倍,这个操作是单线程完成的。
  2. 第二个部分就是将原来table中的元素复制到nextTable中,这里允许多线程进行操作。

(这里的内容有点多,我没看完,这个图挺好理解的,先留空)
这里写图片描述

图来自:ConcurrentHashMap总结

put

在多线程情况下,可能出现以下情况:

  1. 如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;
  2. 如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。

size

对于ConcurrentHashMap来说,这个table里到底装了多少东西其实是个不确定的数量,因为不可能在调用size()方法的时候像GC的“stop the world”一样让其他线程都停下来让你去统计,因此只能说这个数量是个估计值。对于这个估计值,ConcurrentHashMap也是大费周章才计算出来的。

常见问题汇总:

HashTable和ConcurrentHashMap为什么不支持key,value为null,而HashMap支持?
因为HashTable和ConcurrentHashMap是支持多线程的,那么以map.get(key)方法取得结果为null的时候,无法确定到底是key对应的value不存在,还是value就是null;而HashMap由于是非并发的,尽管get(key)为null,但你可以使用contains(key)来进行判断。那为啥HashTable和ConcurrentHashMap不能用contains(key)判断呢,因为在判断的时候很有可能其他线程已经对map进行更改了,contains得出的结果是不准确的。ConcurrentMaps不允许key、value为null

参考链接:

猜你喜欢

转载自blog.csdn.net/xtick/article/details/81098122