【源码】ConcurrentHashMap源码分析-1.7版本

67 阅读4分钟

1、底层原理

C.H.M 底层使用了分段锁来实现,主要结构是一个segment数组,每个segment是一个HashEntry数组,而一个HashEntry包含(k,v)、hash、next。因为它底层也是数组+链表的实现,因此这里的HashEntry实际上就是一个链表节点。

image.png

2、初始化

初始化的时候会先把segment数组的大小调整到2的n次幂(记为ssize),主要是方便后面移位运算。接着通过capacity / ssize 得到segment第一个位置的HashEntry数组的大小。同时这里会记录下 log(ssize) 记为 segmentShift,后面是用来计算元素位置的。

image.png

3、put方法

元素插入要分为两个阶段,一个是确定在segments的位置,一个是确定在HashEntry数组的位置。

1)segment元素的put

首先会计算key的哈希值,之后取哈希值的高 segmentShift 位得到该key在segment 数组的位置索引,之后判断该位置的数组是否已经初始化,如果未初始化则需要先初始化,初始化中是根据索引0的数组的结构去创建的(相同的负载因子,相同的大小),注意在初始化阶段会多次使用 getObjectVolatile 方法,这个方法可以实现类似读volatile的作用,因为此时可能有其他线程在做修改,它可以保证数据从主内存读到线程的工作内存,确保线程间的可见性。

image.png

确定了在segments中的位置后,就需要真正执行插入了。

2)确定在HashEntry数组的位置并插入

在这个阶段就需要对segment加锁了,首先是尝试获取锁,如果获取不到就需要进入 scanAndLockForPut 方法中。

首先是在自旋获取锁的过程中找到当前key需要插入的位置,并为key创建节点,当然这里并不保证一定拿到创建好的node,也有可能获取到锁的时候node还没创建好,可以说这里的创建node节点只是顺便做的,不是一定要完成。如果自旋到阈值还获取不到锁,就需要排队加锁了。

接着回到 put 方法,这里会先获取当前key在HashEntry数组的插入位置,用的是key的哈希值的低n位(n指的是log(HashEntry大小)),这样可以确保找到的位置永远不会越界。

image.png

image.png

接着会从头结点开始遍历链表,如果找到key对应的节点就直接修改value之后退出;如果找不到就一直移动,移动到链表尾还没找到就说明链表中没有冲突节点,接着就判断node是否为null,上面讲到 scanAndLockForPut 方法实际上并不保证node不为空,因此这里需要再次判断,如果不为空就将node的next指向链表头结点,从这里也可以发现是采用头插法插入,如果node为空则需要创建一个。需要注意的是,这里并不是直接替换segment的数组内容,而是需要先判断是否超出阈值,如果没超出则执行调用 putOrderedObject 延迟插入。

image.png

执行调用 putOrderedObject 延迟插入,为的是防止遍历操作受到影响,而且 JMM 会保证获得锁到释放锁之间所有对象的状态更新都会在锁被释放之后更新到主存,从而保证这些变更对其他线程是可见的。

image.png

上面讲到如果超出阈值需要扩容,那么下面就看看扩容的逻辑。

4、扩容

每次扩容都会扩到原来的两倍,确定大小之后就创建一个新的数组,之后依次迁移各个位置上的链表,迁移的过程是从下到上的,并且迁移的时候需要重新生成对象,迁移完成之后,还需要将put的新节点插入进去,因为本质上是put新节点超出阈值导致的扩容。这一块比较简单,但需要注意里面并没有显示加锁的地方,因为在外面put的时候已经加上锁了。

image.png

5、get方法

首先会根据哈希值去定位到该key对应的在segments数组的位置(计算方法依旧是取哈希值的高位),也就是定位到对应的HashEntry数组,之后计算该key在HashEntry数组的位置(计算方法依旧是取哈希值的低位),之后遍历链表找有没有这个key即可。

6、size方法

在并发情况下如果只是遍历获取segment中的count属性得到的值肯定是不准确的,那么最差的情况就是需要给所有的segment加锁了,除此之外 C.H.M. 还尝试了另一种操作,两次无锁操作前后结果不一致就直接加锁获取size。这里需要用到segment的两个变量,一个是count,另一个是modCount,count用于统计多个segment的和值,而modCount用于记录这期间的修改操作。