ConcurrentHashmap部分总结

549 阅读8分钟

ConcurrentHashmap总结

重点方法主是put rehash size/containsValue 。JDK8将rehash重写为多线程实现。

1.1 ConcurrentHashMap 
设计思路:
ConcurrentHashMap 在线程安全的基础上,提供了更好地并发能力,同时降低了读一致性要求。大量利用了volatile,final,CAS等技术减少锁竞争。
ConcurrentHashMap采用了分段锁设计,不同分段间无锁竞争,大大提高了并发能力。但同时导致全表扫描的方法,发size(),containsValue()需要特殊的实现。
ConcurrentHashMap放弃一对一致性的要求,是弱一致性的。
ConcurrentHashMap由分段锁构成,Segment继承自ReentrantLock,内部拥有一个Entry数组。
ConcurrentHashMap中的KV对HashEntry中的,key不作volatile修饰,而value和next都被volatile修饰。保持对多线程可见性。
2.1 并发度
并发度可以理解为能够同时更新ConcurrentHashMap且不产生锁竞争的最大线程数,也就是分段锁长度,即Segment[]数组长度,默认值16,用户设置时自动增加为2的幂指数。这样做的好处是可以用移位运算快速定位到Segment。
并发度设置的过小会导致锁竞争问题,过大会导致CPU缓存命中率下降。
定位segment算法:
int sshift = 0;
        int ssize = 1;
        while (ssize < DEFAULT_CONCURRENCY_LEVEL) {
            ++sshift;
            ssize <<= 1;
        }
        int segmentShift = 32 - sshift;
        int segmentMask = ssize - 1;
通过将key的高n位(n=32-segmentShift)和并发度减1(segmentMask)做位与运算,定位到所在segment。

创建分段锁:
除第一个Segment外,剩余的Segment采用延迟初始化机制。每次put之前检查key对应Segment是否为null,为空则调用ensureSegment()确保Segment被创建。
ensureSegment支持在并发环境下调用。其并未采用锁控制竞争,而是使用Unsage对象的getObjectVolatile()提供的原子读语义,结合CAS来确保Segment创建的原子性。

第二次修补内容:
内容:segment含义,定位方法,延迟初始化机制,并发执行原理。
补充一句:检查为空使用原子读语义。初始化对象并未进行任何修饰,是单线程执行的。初始化的指针赋值是调用>了CAS操作
3.1 put/putIfAbsent/putAll
ConcurrentHashMap的put方法被代理到各segment。
JDK7的做了进一步优化,在获取锁之前先用tryLock()尝试获得锁。
在尝试获取锁的过程中,对相同hashcode的链表遍历,若找不到相同key的HashEntry节点,就为后续put操作创建一个HashEntry。
当tryLock一定次数后仍无法获得锁,则通过lock申请锁。(自旋原理)

在并发环境下,其它线程的put,rehash,remove操作都会导致链表头节点变化。因此在过程中需要重新检查,若头节点发生变化,变对链表重新遍历。
这样即使其它线程操作,删除了此段链表的节点,即使操作是非原子的(删除节点后链接后续节点调用了非原子语义的Unsafe.putOrderedObject()方法),可能导致当前线程无法观察到,也不影响遍历的正确性。
在获取锁的过程中遍历整个链表的原因:希望被遍历的链表被CPU缓存,为后续put操作的链表遍历提升性能。
新建节点时,如果节点总数超过了threthold,就需要调用rehash()方法进行扩容。
put方法的链接新节点的后继HashEntry.setNext(),以及将链表写入数组setEntryAt(),都是用Unsafe.putOrderedObject()方法现。这个方法不具有原子语义,并未使用具有原子语义的putObjectVolatile()的原因是:JVM会保证从获得锁到释放锁之间的所有对象,都会在锁释放后更新到主存,保证为其它线程可见(锁同样具有volatile同步可见性语义)。

补充:
主要内容:
先用tryLock获取锁(自旋),遍历冲突链表提高缓存命中率,没找到为put提前新建好节点,tryLock不到再用lock。
遍历冲突链表时,头节点变化重新遍历。
put达到threthold要扩容。
put的补链和挂链不需要原子语义,原因是锁释放的语义,会保证其可见性。
4.1 remove
remove和put类似,在获得锁之前,会遍历链表提高缓存命中率。
get与containsKey方法同理,都不用锁,而是用Unsafe.getObjectVolatile()提供的原子读语义,来获得segment及对应的链表。
然后遍历链表判断key是否存在。
由于此过程其它线程可能修改了链表,因而ConcurrentHashMap只保证弱一致性,类似于不可重复读。
强一致性使用Collections.synchronizedMap();
size和containsValue方法。
在不加锁的情况下循环所有segment(通过Unsafe.getObjectVolatile()保证原子性)。连续两次获取各segment的size,没有改变说明没有被其它线程修改。两次获得的值不一样,加锁禁止其它线程put,remove,再进行获取。
注意事项:
避免在多线程环境下使用size containsValue。
与HashMap不同的是,ConcurrentHashMap不允许key或value为null。这代表HashEntry没有映射完成就被其它线程可见,需要特殊处理。一些防御性判断的阻止未映射完成的HashEntry使用。

以下是JDK8中的ConcurrentHashMap实现。

4.1 JDK8 中的sizeCtl
JDK8摒弃了Segment的概念,采用全新的CAS算法实现(以节点为单位进行并发)。底层仍由数组、链表、红黑树的思想。为实现并发,又加入输助类TreeBin,Traverser对象内部类。

sizeCtl初始化或标识控制位(volatile):
负数表示正在初始化或扩容操作。
-1表示正在初始化。
-N表示有N-1个线程正在进行扩容操作。
正数或0表示还没有被初始化,数值大小表示下一次扩容的大小(threthold),默认0.75*capacity。
Node:
Node即封装了KV对,又是并发的基本单位。它同样对value和next设置了volatile同步锁,但不允许用setValue()方法直接改变Node的value域。
TreeNode:
当链表长度超过8,转为TreeNode(带有next指针)。并把节点包装成TreeNode并放在TreeBin对象中,由TreeBin完成对红黑树的包装。
TreeBin:
负责包装TreeNode节点,代替的TreeNode根节点,数组中存放的就是TreeBin对象(这与Hashap不同)。并且TreeBin还带有读写锁,是并发的粒度单位。
ForwadingNode:
用于两个table的节点类,包含一个nextTable指针,指向下一张表。其key value next全部为null,hash值为-1。
5.1 Unsafe CAS
ConcurrentHashMap大量使用了Unsafe.compareAndswap方法,利用CAS无锁改值操,避免锁开销。CAS操作,不断地比较内存值与compare值是否相等,相等则改为swap值。如果当前线程中的值与预期不符,进行修改可能覆盖掉其它线程的修改。但这无法避免ABA问题。
3个原子操作,保障线程安全:
U.getObjectVolatile()
U.putObjectVolatile
U.compareAndSwapObject()
分别实现了tabAt() setTabAt() casTabAt()

initTable
ConcurrentHashMap的初始化仅仅是设置了一些参数,而table初始化发生在put操作(每次put都会检查table==null)。
为获取初始化权限,用CAS方法将sizeCtl设置为-1,防止其它线程进入。ConcurrentHashMap初始化仅能由一个线程完成。
初始化先检查sizeCtl,小于0说明其它线程正在执行初始化,就放弃这个操作(Thread.yidld()让出时间片)。
初始化后sizeCtl值0.75*capacity。
6.1 扩容方法
JDK8实现的ConcurrentHashMap支持并发扩容,并且没有加锁。减少了扩容带来的时间影响,数组拷贴心还是很耗时的。
简单步骤:
nextTable扩容为原来的两倍,由单线程完成。
原来的表元素复制到新nextTable中去,允许线程操作。
单线程步骤:
遍历,如果第i位置为空,就在原table位置i放入fowardNode节点,用来触发扩容。
如果是Node节点/TreeBin节点,作反序处理(这个要加synchronized上锁),分别放在nextTable的i和i+n位置上。
遍历结束完成复制,nextTable指向新数组,更新sizeCtl为0.75倍新容量。
多线程改进:
多线程遍历节点,处理一个节点,就把节点值设为foward。另一个线程看到foward,就向后遍历。这样就多线程共同完成了复制工作,并且线程安全。

推荐使用mappingCount()方法代替size()方法统计数量。
7.1 put方法
根据哈希值算出位置i,空位直接放入,树节点插表末,树节点,按树的方式插入。ConcurrentHashMap不允许key或value为空。JDK8的ConcurrentHashMap实现只锁住Node,锁粒度更细。并且只对改冲突链加锁,之前的操作都是无锁且线程安全的。
多线程实现:
如果检测到其它线程正在为其扩容(检测到被插入的位置被forward节点占有),当前put方法的线程也要参与到扩容中去。
检测到节点位置为空,直接放入,且不用加锁。
检测到节点非空且不是foward节点,加锁重构链表或树。加入链表后节点长度大于8,要转为红黑树。(扩容后也可能再降回链表)
JDK8实现的ConcurrentHashMap总结:
JDK7的实现用Segment减小锁粒度,分段。put时仅锁住Segment。get时不加锁,仅用volatile保证可见性。统计size用两次尝试的办法,不一致再加锁。主要问题是冲突链表的增删改查耗时长。
JDK8的设计优化:
直接锁住Node,减小了锁粒度。
设计了MOVED状态,使其它put线程协助扩容。
3个CAS操作保证线程安全,用更轻量的方式替代了锁。
sizeCtl扩容控制符。
足够信作synchronized,不再使用ReentrantLock。