1.前言
Java JDK升级到1.8后有些集合类的实现有了变化,
其中ConcurrentHashMap就有进行结构上的大调整。
jdk1.6、1.7实现的我不会想了解去百度吧
2. 重要概念
想要了解ConcurrentHashMap 你至少要对HashMap有一定的了解吧?
HashMap(1.8)和ConcurrentHashMap(1.8) 底层都是hash表+链表+红黑树
如果对红黑树不了解的可以查看https://blog.youkuaiyun.com/A980719/article/details/120264812
当你对hashMap有一定的了解后要想读懂ConcurrentHashMap还要知道以下重要概念
2.1 table
所有数据都存在table中,table的容量会根据实际情况进行扩容,
table[i]存放的数据类型有以下3种:
- Node 普通结点类型,
- 表示链表头结点,
- 红黑树根节点。
2.2 TreeBin 用于包装红黑树结构的结点类型
这个类并不负责包装用户的key、value信息,而是包装的很多TreeNode节点。它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。
2.3 TreeNode:
树节点类,另外一个核心的数据结构。当链表长度过长的时候,会转换为TreeNode。但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNode在ConcurrentHashMap集成自Node类,而并非HashMap中的集成自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问
2.4、ForwardingNode:
一个用于连接两个table的节点类。它包含一个nextTable指针,用于指向下一张表。而且这个节点的key value next指针全部为null,它的hash值为-1(当hash为-1表示正在扩容). 这里面定义的find的方法是从nextTable里进行查询节点,而不是以自身为头节点进行查找。
3.5 nextTable
扩容时用于存放数据的变量,扩容完成后会置为null。ForwardingNode的nextTable指针在扩容时指向它
3.6 sizeCtl
以volatile修饰的sizeCtl用于数组初始化与扩容控制,它有以下几个值:
当前未初始化:
= 0 //未指定初始容量
> 0 //由指定的初始容量计算而来,再找最近的2的幂次方。
//比如传入6,计算公式为6+6/2+1=10,最近的2的幂次方为16,所以sizeCtl就为16。
初始化中:
= -1 //table正在初始化
= -N //N是int类型,分为两部分,高15位是指定容量标识,低16位表示
//并行扩容线程数+1,具体在resizeStamp函数介绍。
初始化完成:
=table.length * 0.75 //扩容阈值调为table容量大小的0.75倍
一个思考:这个sizeCtl是volatile的,那么他是线程可见的,它是所有修改都在CAS中进行,
但是sizeCtl为什么不设计成LongAdder(jdk8出现的)类型呢?
或者设计成AtomicLong(在高并发的情况下比LongAdder低效),这样就能减少自己操作CAS了。
3.相关操纵(创建和删除)
3.1 ConcurrentHashMap构造方法
3.1.1 ConcurrentHashMap()
public ConcurrentHashMap() {
}
说明:该构造函数用于创建一个带有默认初始容量 (16)、加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射。
3.1.2 ConcurrentHashMap(int)
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
说明:该构造函数用于创建一个带有指定初始容量、默认加载因子 (0.75) 和 concurrencyLevel (16) 的新的空映射。
3.1.3 ConcurrentHashMap(int,float)
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
说明:该构造函数用于创建一个带有指定初始容量、加载因子和默认 concurrencyLevel (1) 的新的空映射。
3.1.4 ConcurrentHashMap(int, float, int)
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0) // 合法性判断
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
说明:该构造函数用于创建一个带有指定初始容量、加载因子和并发级别的新的空映射。
传入的加载因子只对创建表时有影响 并不会改变程序里的加载因子
对于构造函数而言,会根据输入的initialCapacity的大小来确定一个最小的且大于等于initialCapacity大小的2的n次幂,如initialCapacity为15,则sizeCtl为16,若initialCapacity为16,则sizeCtl为16。若initialCapacity大小超过了允许的最大值,则sizeCtl为最大值。值得注意的是,构造函数中的concurrencyLevel参数已经在JDK1.8中的意义发生了很大的变化,其并不代表所允许的并发数,其只是用来确定sizeCtl大小,在JDK1.8中的并发控制都是针对具体的桶而言,即有多少个桶就可以允许多少个并发数。
3.2 put()方法
ConcurrentHashMap最常用的就是put和get两个方法。现在来介绍put方法,这个put方法依然沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。ConcurrentHashMap中依然沿用这个思想,有一个最重要的不同点就是ConcurrentHashMap不允许key或value为null值。另外由于涉及到多线程,put方法就要复杂一点。在多线程中可能有以下两个情况。
- 如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点(hash值为-1),如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;
- 如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。
整体流程就是首先定义不允许key或value为null的情况放入,对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置。
-
如果这个位置是空的,那么直接放入,而且不需要加锁操作。
-
如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。 如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。
final V putVal(K key, V value, boolean onlyIfAbsent) {
//这里也就定了key和Value不能为空
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;// I处结点数量,主要用于每次加入结点后查看是否要由链表转为红黑树
for (Node<K, V>[] tab = table; ; ) {
//CAS经典写法,不成功无限重试,让再次进行循环进行相应操作。
Node<K, V> f;
int n, i, fh;
// f 当前hash的头节点 n 表的长度 i 当前位置 fh 当前hash的头节点 的hash值
if (tab == null || (n = tab.length) == 0)
tab = initTable();//只进行初始化和hashMap的初始化不同
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null))) //如果为null创建Node对象做为链表首结点
break; // no lock when adding to empty bin
} else if ((fh = f.hash) == MOVED)//MOVED==-1 如果添加时hash表正在扩容 反正闲着也是闲着 帮忙去扩容吧
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
//说明当前节点已经有元素 上锁在当前节点的末尾插入新节点
if (tabAt(tab, i) == f) {
//双重检查i处结点未变化 猜测 上面的操作时当前结点的同节点被删除 导致不相同
if (fh >= 0) {
//表明是链表结点类型,hash值是大于0的,即spread()方法计算而来
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;//临时保存当前节点的key 留着判断
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
//放存在相同的key
oldVal = e.val;
if (!onlyIfAbsent)//判断是否需要修改值
e.val = value;
break;
}
Node<K, V> pred = e;
if ((e = e.next) == null) {
//当不存在相同的key 时在结尾插入
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
} else if (f instanceof TreeBin) {
//f.hash== -2
Node<K, V> p;
binCount = 2;
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key, //查找或者添加节点
value)) != null) {
//如果添加节点已经存在
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);//转换成红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);//判断是否扩容
return null;
}
3.3 spread()
jdk1.8的hash策略,与以往版本一样都是为了减少hash冲突:
和hashMap的hash()方法基本一样只不过key不能为空;