部分内容来自以下博客:
https://blog.csdn.net/bill_xiang_/article/details/81122044
https://www.cnblogs.com/zhaojj/p/8942647.html
注意:本文基于JDK1.8进行记录。
1 分类
参照之前在学习集合时候的分类,可以将JUC下有关Map相关的类进行分类。
ConcurrentHashMap:继承于AbstractMap类,相当于线程安全的HashMap,是线程安全的哈希表。使用数组加链表加红黑树结构和CAS操作实现。
ConcurrentSkipListMap:继承于AbstractMap类,相当于线程安全的TreeMap,是线程安全的有序的哈希表。通过跳表实现的。
2 ConcurrentHashMap
2.1 JDK1.7的分段锁机制
Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁。也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。
因此,在JDK1.5到1.7版本,Java使用了分段锁机制实现ConcurrentHashMap。
简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段。而每个Segment节点,即每个分段则类似于一个Hashtable。在执行put操作时首先根据hash算法定位到节点属于哪个Segment,然后使用ReentrantLock对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可是实现多线程put操作。
Segment类是ConcurrentHashMap中的内部类,继承于ReentrantLock类。ConcurrentHashMap与Segment是组合关系,一个ConcurrentHashMap对象包含若干个Segment对象,ConcurrentHashMap类中存在Segment数组成员。
HashEntry也是ConcurrentHashMap的内部类,是单向链表节点,存储着key-value键值对。Segment与HashEntry是组合关系,Segment类中存在HashEntry数组成员,HashEntry数组中的每个HashEntry就是一个单向链表。
2.2 JDK1.8的改进
在JDK1.7的版本,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组加链表加红黑树的方式实现,而加锁则采用CAS自旋锁、volatile关键字、synchronized可重入锁、热点分段锁实现。
JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。
JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了。
JDK1.8版本的扩容操作支持多线程并发。在之前的版本中如果Segment正在进行扩容操作,其他写线程都会被阻塞,JDK1.8改为一个写线程触发了扩容操作,其他写线程进行写入操作时,可以帮助它来完成扩容这个耗时的操作。
JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表。
2.3 重要属性
2.3.1 sizeCtl属性
标志控制符。这个参数非常重要,出现在ConcurrentHashMap的各个阶段,不同的值也表示不同情况和不同功能:
1)负数,表示正在进行初始化或扩容操作。-1表示正在进行初始化操作。-N表示正在进行扩容操作,高16位是扩容标识,与数组长度有关,最高位固定为1,低16为表示扩容线程数加1。
2)0,表示数组还未初始化。
3)正数,表示下一次进行扩容的大小,类似于扩容阈值。它的值始终是当前容量的0.75倍,如果数组节点个数大于等于sizeCtl,则进行扩容。
2.3.2 内部类Node中的hash属性
1)负数,-1表示该节点为转发节点,-2表示该节点为红黑树节点。
2)正数,表示根据key计算得到的hash值。
2.4 构造方法
需要说明的是,在构造方法里并没有对集合进行初始化操作,而是等到了添加节点的时候才进行初始化,属于懒汉式的加载方式。
另外,loadFactor参数在JDK1.8中不再有加载因子的意义,仅为了兼容以前的版本,加载因子默认为0.75并通过移位运算计算,不支持修改。
同样,concurrencyLevel参数在JDK1.8中不再有多线程运行的并发度的意义,仅为了兼容以前的版本。
// 空参构造器。
public ConcurrentHashMap() {
}
// 指定初始容量的构造器。
public ConcurrentHashMap(int initialCapacity) {
// 参数有效性判断。
if (initialCapacity < 0)
throw new IllegalArgumentException();
// 提供多余空间,避免初始化后马上扩容,防止初始容量为0,保证最小容量为2。
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
// 设置标志控制符。
this.sizeCtl = cap;
}
// 指定初始容量,加载因子的构造器。
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
// 指定初始容量,加载因子,并发度的构造器。
public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) {
// 参数有效性判断。
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 比较初始容量和并发度的大小,取最大值作为初始容量。
if (initialCapacity < concurrencyLevel)
initialCapacity = concurrencyLevel;
// 计算最大容量。
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
// 设置标志控制符。
this.sizeCtl = cap;
}
// 包含指定Map集合的构造器。
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
// 设置标志控制符。
this.sizeCtl = DEFAULT_CAPACITY;
// 放置指定的集合。
putAll(m);
}
2.5 初始化方法
集合并不会在构造方法里进行初始化,而是在用到集合的时候才进行初始化,在初始化的同时会设置集合的阈值sizeCtl。
在初始化的过程中为了保证线程安全,总共使用了两步操作:
1)通过CAS原子更新方法将sizeCtl设置为-1,保证只有一个线程执行初始化。
2)线程获取初始化权限后内部进行二次判断,保证只有在未初始化的情况下才能执行初始化。
// 初始化集合数组,使用CAS原子更新保证线程安全,使用volatile保证顺序和可见性。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 死循环以完成初始化。
while ((tab = table) == null || tab.length == 0) {
// 如果sizeCtl小于0则表示正在初始化,当前线程让出CPU。
if ((sc = sizeCtl) < 0)
Thread.yield();
// 如果需要初始化,并且使用CAS原子更新成功。
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 第一个线程初始化之后,第二个线程还会进来所以需要重新判断。类似于线程同步的二次判断。
if ((tab = table) == null || tab.length == 0) {
// 如果没有指定容量则使用默认容量16。
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化一个指定容量的节点数组。
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将节点数组指向集合数组。
table = tab = nt;
// 扩容阀值,获取容量的0.75倍的值,写法更高端比直接乘高效。
sc = n - (n >>> 2);
}
} finally {
// 将sizeCtl的值设为阈值。
sizeCtl = sc;
}
break;
}
}
return tab;
}
2.6 添加方法
1)校验数据,判断传入一个key和value是否为空,如果为空就直接报错。ConcurrentHashMap是不可为空的(HashMap是可以为空的)。
2)初始化数组,判断数组是否为空,如果为空就执行初始化方法。
3)插入或更新节点,如果数组插入位置的节点为空就通过CAS操作插入节点,如果数组正在扩容就执行协助扩容方法,如果产生哈希碰撞就找到节点并更新节点或者插入节点。
4)插入节点后,链表节点判断是否要转为红黑树节点,并且需要增加容量并判断是否需要扩容。
// 添加节点。
public V put(K key, V value) {
retur