ConcurrentHashMap

为什么使用ConcurrentHashMap

HashMap是线程不安全的,在多线程执行put操作过程中,有可能会试容量达到阈值,触发扩容操作,HashMap的扩容操作会将数组容量扩大为当前数组长度的两倍,重新遍历HashMap,将每一个链表中的元素进行重新hash,存入新的HashMap中。如果是多线程进行put,会出现链表成环,导致HashMap的get操作无法结束,CPU利用率达到100%;老生常谈,HashMap的死循环

HashTable几乎提供了与HashMap相同的操作,但是HashMap的很多方法都是通过synchronized修饰的,多线程操作会导致线程阻塞,即便是多个只进行查询操作的线程,这样使得效率非常低下。

我们可以使用Collections提供的封装方法,得到线程安全的Map。但是看了下面SynchronizedMap的实现,是使用了一个Object对象作为锁,同样每一个操作方法都被synchronized修饰了,可见效率也不高。如果需要线程安全且比较高效的HashMap,可以使用ConcurrentHashMap。

1
2
3
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}

为什么ConcurrentHashMap是线程安全的

ConcurrentHashMap是由Segment数组和HashEntry数组组成的,Segment是一种可重入锁,HashEntry用于存储键值对数据。一个Segment包含一个HashEntry数组,每个HashEntry是一个链表结构,每个Segment守护一个HashEntry数组的元素,当需要对数组中的元素进行修改时,必须先获得对应Segment的锁。

struct.png

初始化

  • 初始化segments数组,通过conrencyLevel计算数组长度,必须是2的整数幂。
  • 初始化segmentShift和segmentMask,segmentShift用于定位参与散列运算的位数,segmentMask是参与与hash做与运算的掩码,为size-1.
  • 初始化每一个segment,每一个HashEntry长度同样需要是2的整数幂长度。

get操作

get操作是不需要获取锁的,因为每一个HashEntry节点的value已经被volatile修饰了,可以保证读到的值是最新的值。
定位节点分为两步,首先定位目标segment,然后再定位具体的节点

1
2
hash >>> segmentShift) & segmentMask  // 定位Segment所使用的hash算法 
int index = hash & (tab.length - 1); // 定位HashEntry所使用的hash算法

put操作

在进行put操作时,首先定位到具体的segment,会对当前segment进行加锁,然后判断是否需要扩容,如果需要扩容,只对当前segment执行扩容操作,最后再添加元素,这不同于HashMap,HashMap添加节点以后进行扩容,如果以后都不再添加元素,这也许是一次无效扩容。

查询size

每个Segment使用volatile维护了一个表示当前segment内元素数量的count,但是显然对每个segment中count进行求和的操作不是原子性的。ConcurentHashMap还维护了一个变量,modCount,每次对ConcurrentHashMap中元素的修改操作会使得当前变量加1,所以在对count进行求和之前,保留modCount的副本,在求和以后如果modCount没有发生变化,证明求和这段时间没有线程对容器内元素进行操作,对count的求和是可靠的,如果modCount发生了改变,则需要重新求和,连续两次容器的大小都没有成功正确统计到,则对所有的segment加锁然后求和。

文章作者: hohnor
文章链接: http://www.zhulk3.cn/2022/02/10/ConcurrentHashMap/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 甜茶不贵