并发容器之ConcurrentHashMap
前言
鉴于HashMap在多线程下的安全问题,所以JDK在1.5版本引入了线程安全的HashMap,即是本文将要说的ConcurrentHashMap,他在1.8采用了降低锁的粒度的思想,引入段Segment的概念,然而在1.8后又取消了这个概念
Before JDK 1.8
数据结构
Segment数组+链表,每个Segment包含一个与HashMap数据结构差不多的链表数组,key,hash 和 next 域都被声明为 final 型,value 域被声明为 volatile 型
static final class HashEntry<K,V> {
final int hash; //确保唯一性
final K key; //确保唯一性
volatile V value;
final HashEntry<K,V> next;
//其他省略
}
确定Segment位置
ssize表示Segment的大小,一定是大于或等于concurrentLevel的最小的2的次幂,2的sshift次方等于ssize,取Segment桶的位置的时候则会保留sshift位高位(通过segmentShift=32-sshift,右移)来与Segment长度减一(segmentMask:段掩码)相与获得桶的位置
Segment内确定具体桶的位置
和原HashMap的思想一致
同步方式
Segment继承了ReentrantLock,具备锁的能力,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑
扩容机制
rehash方法中会定位第一个后续所有节点在扩容后index都保持不变的节点,然后将这个节点之前的所有节点重排即可
求size
为更好支持并发操作,ConcurrentHashMap会在不上锁的前提逐个Segment计算3次size,如果某相邻两次计算获取的所有Segment的更新次数(每个Segment都与HashMap一样通过modCount跟踪自己的修改次数,Segment每修改一次其modCount加一)相等,说明这两次计算过程中无更新操作,则这两次计算出的总size相等,可直接作为最终结果返回。如果这三次计算过程中Map有更新,则对所有Segment加锁重新计算Size。
After JDK 1.8
数据结构
放弃了分段锁的方案,而是直接使用一个大的数组
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //它的Key值和hash值都由final修饰,不可变更
final K key;
volatile V val; //Value及对下一个元素的引用由volatile修饰,可见性也有保障
volatile Node<K,V> next;
}
//当链表长度过长的时候,会转换为TreeNode
//由TreeBin完成对红黑树的包装,替了TreeNode的根节点
重要属性
- sizeCtl:在不同的地方有不同用途,而且它的取值不同,也代表不同的含义
- 负数代表正在进行初始化或扩容操作
- -1代表正在初始化(初始化只能由一个线程完成)
- -N 表示有N-1个线程正在进行扩容操作
- 正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小
- ForwardingNode:一个特殊的Node节点,hash值为-1,其中存储nextTable的引用,只有table发生扩容的时候,ForwardingNode才会发挥作用,作为一个占位符放在table中表示当前节点为null或则已经被移动
同步方式
其中大量用到了CAS,对于put操作的话,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素(也即链表表头或者树的根元素)使用synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。
如果f的hash值为-1,说明当前f是ForwardingNode节点,意味有其它线程正在扩容,则一起进行扩容操作
扩容机制
- 单线程内扩容:
- 构建一个nextTable,它的容量是原来的两倍
- 把table的数据复制到nextTable中
- 首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素f,初始化一个forwardNode实例fwd
- 如果f == null,则在table中的i位置放入fwd
- 如果f是链表的头节点,就构造一个反序链表,把他们分别放在nextTable的i和i+n的位置上
- 如果f是TreeBin节点,也做一个反序处理,并判断是否需要untreeify,把处理的结果分别放在nextTable的i和i+n的位置上
- 多线程扩容
如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容。
多线程遍历节点,处理了一个节点,就把对应点的值set为forward,另一个线程看到forward,就向后遍历。这样交叉就完成了复制工作。
求size
通过baseCount来持有这个size,因为进行一次put操作后都会进行更新这个值,求个数的方法会在mappingCount或size方法返回,两个方法都没有直接返回basecount 而是统计一次这个值,而这个值其实也是一个大概的数值,因此可能在统计的时候有其他线程正在执行插入或删除操作
与HashMap 的不同
- ConcurrentHashMap线程安全,而HashMap非线程安全。
- HashMap允许Key和Value为null,而ConcurrentHashMap不允许。
因为concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。