一 简介
ConcurrentHashMap是HashMap的同步容器,是线程安全的,通过CAS和Synchronized来实现线程安全!
JDK 1.8 将之前的Segment+ReentrantLock改为Node+Synchronized的原因:
因为ConcurrentHashMap已经将锁细化到了一个槽中,冲突不会太大。
Synchronized在重量锁的时候有自旋锁的行为,不会一下就挂起,而ReentrantLock的话,当前线程只要不是排在第二个等待节点就会被挂起,消耗资源。线程的挂起和唤醒会导致线程之间的切换,会频繁的导致内核态和用户态之间的切换,十分耗费资源。
综合而言,Synchronized的性能会比较好,并且占用内存小。
二 数据结构
ConcurrentHashMap1.8和HashMap一样都是采用拉链法处理hash冲突,且为了防止链表长度过长影响查询效率,所以当链表长度超过阈值,就会将其转成红黑树。采用数组+链表+红黑树的结构
node结构
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val; //采用volatile关键字修饰
this.next = next; //采用volatile关键字修饰
}
常用属性
//数组最大容量
private static final int MAXIMUM_CAPACITY = 1 << 30;
//默认初始化容量,必须是2的幂次方
private static final int DEFAULT_CAPACITY = 16;
//最大可能数组大小
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//负载因子
private static final float LOAD_FACTOR = 0.75f;
//链表转红黑树阈值
static final int TREEIFY_THRESHOLD = 8;
//树转链表阈值
static final int UNTREEIFY_THRESHOLD = 6;
//存放元素的数组容器
transient volatile Node<K,V>[] table;
//扩容时生成的数组,容量为之前的2倍
private transient volatile Node<K,V>[] nextTable;
//多个线程的共享变量,操作的控制标识符:
// -1代表正在初始化
// -(1+nThreads),表示有n个线程正在一起扩容
// 0代表rehash还没有被初始化,默认值
// 大于0表示下一次进行扩容的容量大小
private transient volatile int sizeCtl;
// putVal时,无锁竞争统计的value个数
private transient volatile long baseCount;
//putVal时,有锁竞争统计的value个数(数组形式)
private transient volatile CounterCell[] counterCells;
三 源码详解
1、get()方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 扰动函数计算key的hash值,取到其所在的数组下标位置
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) { // 读取首节点的Node元素
// 如果当前节点的hash值和首节点的Hash值相同,并且value也相同,直接返回。
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// hash为负值,表示正在扩容。这时需要用到ForwardingNode的find()方法定位到nextTable。
// 1) eh=-1,说明该节点是一个ForwardingNode,正在扩容迁移,此时调用ForwardingNode的find()方法去nextTable里找
// 2) eh=-2,说明该节点是一个TreeBin,此时调用TreeBin的find()方法遍历红黑树,注意:由于红黑树可能正在旋转变色,所以find()方法里会加一个读写锁。
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// eh >=0 说明该节点是一个链表节点,直接遍历链表即可。
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
流程:
1:使用扰动函数计算key的hash值,取到其所在的数组下标位置。
2:如果节点是首节点,直接返回。
3:如果节点的hash值eh是负值,说明ConCurrentHashMap正在进行扩容,或该节点是一个树节点TreeBin。
如果eh=-1表示正在扩容,该节点是一个 ForwardingNode(树头节点),直接调用ForwardingNode的find方法去nextTable里找;
如果eh=-2,该节点是一个TreeBin,调用TreeBin的find()方法遍历红黑树,由于红黑树有可能在变色旋转,所以find()里会有读写锁。
4:最后如果eh>0说明节点是链表,遍历链接即可。
TreeBin.find()方法:
该方法多了一个基于CAS实现的读写锁,通过TreeBin查找某个节点时,如果当前写锁被占用或者有等待获取写锁的线程,表示红黑树处于正在调整的过程中,则遍历链表查找;
如果写锁没有被占用且没有等待的线程,则抢占读锁,遍历红黑树的节点来查找,读锁释放时会判断是否所有的读锁都释放了,如果都释放了且当前有等待获取写锁的线程,则唤醒正在等待中的线程。
final Node<K,V> find(int h, Object k) {
if (k != null) {
for (Node<K,V> e = first; e != null; ) {
int s; K ek;
// 如果当前读写锁的状态是WAITER或者WRITER,则通过链表遍历查找,此时红黑树正在调整的过程中。
if (((s = lockState) & (WAITER|WRITER)) != 0) {
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
e = e.next;
}
// //如果不是上述两种状态,则将状态设置为READER,每次都会加上READER,表示正在遍历红黑树,此时就不能调整红黑树了
else if (U.compareAndSwapInt(this, LOCKSTATE, s,
s + READER)) {
TreeNode<K,V> r, p;
try {
// 如果根节点为null,则返回null;否则通过根节点查找匹配的节点。注意:进入此方法根节点不可能为null
p = ((r = root) == null ? null :
r.findTreeNode(h, k, null));
} finally {
Thread w;
if (U.getAndAddInt(this, LOCKSTATE, -READER) == //所有的读锁都释放了
(READER|WAITER) && (w = waiter) != null) //且有等待获取写锁的线程
// 唤醒该线程
LockSupport.unpark(w);
}
return p;
}
}
}
return null;
}
总结
1、java8中ConcurrentHashMap的get()正常情况是不需要加锁的,不加锁的原因:
1:get()方法访问的大多数变量是volatile关键字修饰的,比如:Node.val、Node.next、count,volatile保证了其值的修改对其他线程的可见性。
即:在多线程环境下线程A修改Node节点的val或者新增节点对线程B是可见的。
2:像引用类型:数组table用volatile修饰,在数组扩容的时候就保证了其引用的改变对其他线程的可见性
3:final域确保变量初始化的安全性,初始化安全性让不可变对象不需要同步就可以被自由的访问和共享。
这也是它比其他并发集合,如hashtable、用Collections.synchronizedMap()包装的hashmap;效率高的原因之一。
2、但是当节点是红黑树的时候,如果树正在变色旋转并且要查询的值不是红黑树的头节点,会加一个读写锁。
3、另外其迭代器iterator是弱一致性的,因为在迭代过程中可以向map中添加元素;而HashMap是强一致性的。
2、put()方法
首先我们先看下源码:
public V put(K key, V value) {
return putVal(key, value, false);
}
/**
* Implementation for put and putIfAbsent
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key和value都不允许为空,因为如果为空的话,线程不安全,会出现数据错乱。
if (key == null || value == null) throw new NullPointerException();
// 使用扰动函数计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
// phase1:对数组做无限循环,其中包含四种情况的判断
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
/**
* part1, table数组需要被创建,即table数组为null 或 长度为0,则懒汉式初始化数组
*/
if (tab == null || (n = tab.length) == 0)
// todo 如何保证初始化数组是线程安全的?
tab = initTable();
/**
* part2,table数组已经被创建,并且寻址后的数组位置没有被占用。
* 因为table数组是线程间可见的,但数组元素不是。所以采用volatile读的方式来读取数组中的元素,保证每次拿到的数据都是最新的。
* tabAt(tab, i = (n - 1) & hash)) 相当于 table[i]
*/
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 直接基于主内存地址,创建Node节点 并 通过CAS将其插入到当前位置;CAS失败则进入下一次table数组循环
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// part3: 寻址到的位置正在进行扩容/迁移操作,即:数组位置的hash值 = MOVED = -1,
else if ((fh = f.hash) == MOVED)
// 当前线程加入到扩容/迁移大军,帮助数组进行扩容/迁移。
tab = helpTransfer(tab, f);
// part4:其他情况,即:hash冲突的场景
else {
V oldVal = null;
// 只针对单个数组元素采用synchronized锁(而不是对整个数组加锁),确保线程安全的将节点插入到链表 或 红黑树中
synchronized (f) {
if (tabAt(tab, i) == f) {
// 当前节点的hash值大于0,表明该节点是正常节点,不是被扩容/迁移中的节点。
if (fh >= 0) {
binCount = 1;
// phase1: 遍历链表,将节点插入到链表中
for (Node<K, V> e = f; ; ++binCount) {
K ek;
// 待插入的key与已有链表节点f的key相同
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
// 如果onlyIfAbsent为false对value值进行替换
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K, V> pred = e;
// 如果链表中没有key和待插入的key相同,封装新节点插入到链表的尾部。
if ((e = e.next) == null) {
pred.next = new Node<K, V>(hash, key,
value, null);
break;
}
}
}
/**
* 红黑树的操作
* phase2: 如果节点类型是红黑树节点,将节点插入到红黑树中,如果找到相同key的节点,则根据onlyIfAbsent判断是否替换value值
*/
else if (f instanceof TreeBin) {
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;
}
}
}
}
/**
* phase3: 转换元素存储类型,即:链表转红黑树的操作
*/
if (binCount != 0) {
// 如果链表的个数大于等于8,则将链表转为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果存在旧的value值,则将旧的value值返回
if (oldVal != null)
return oldVal;
break;
}
}
}
// phase2:将ConcurrentHashMap中存储的KV键值对总数+1
addCount(1L, binCount);
return null;
}
引用别人的一张流程图:
操作步骤:
ConCurrentHashMap的put流程可以分为校验和两个阶段。
首先校验key和value不能为null,否者会抛出空指针异常。检验通过之后会通过使用异或和与操作的扰动函数计算key的hash值。
其次,两个阶段分别为:
- 第一阶段对table数组做无限循环进行插入KV键值对操作,其中包含四种情况;
-
第二阶段将ConcurrentHashMap中存储的KV键值对总数+1。
第一阶段可以分为四个步骤来看:
1、table数组需要被创建时,即table数组为null 或 长度为0时,则懒汉式初始化数组(第一次put时才初始化)。由于可能存在多个线程并发的初始化数组,所以首先初始化数组要保证线程安全:
- initTable()中while死循环table数组为空的场景:
-
如果sizeCtl < 0 表示有其他线程正在初始化数组,此时使用 Thread.yield() 将当前线程由运行态进入到就绪态,让出CPU资源;即当前线程在外面自旋,直到table初始化完毕才能跳出while循环。
-
否则,如果通过CAS成功将sizeCtl设置为-1,则当前线程进行数组的初始化,并将sizeCtl设置为扩容阈值(默认为12)。
-
CAS竞争失败的线程;同样在外面自旋,直到table数组初始化完毕才能跳出While循环。
-
总的来说initTable()初始化数组的线程安全性是通过一个volatile修饰的变量sizeCtl 结合 CAS来实现的。
2、table数组已经被创建,并且寻址后的数组位置没有被占用。
- 因为table数组是线程间可见的,但数组元素不是。所以采用volatile读的方式来读取数组中的元素,保证每次拿到的数据都是最新的。
-
然后直接基于主内存地址,通过CAS将创建的Node节点插入到当前位置;CAS失败则进入下一次table数组循环。
3、寻址到的位置正在进行扩容/迁移操作,即:数组位置的hash值 = MOVED = -1;
将当前线程加入到扩容/迁移大军,通过执行helpTransfer()方法来协助其他线程进行扩容/迁移操作。
4、出现hash冲突的场景;
首先只针对单个数组元素加synchronized锁(而不是对整个数组加锁),确保线程安全的将节点插入到链表 或 红黑树中;
这种情况大致分为三个部分:
- 遍历链表,将节点插入到链表中;如果链表中存在相同key的节点,则根据onlyIfAbsent判断是否替换value值;否者将新Node放入到链表尾部。
-
如果节点类型是红黑树节点,将节点插入到红黑树中;如果找到相同key的节点,则根据onlyIfAbsent判断是否替换value值。
-
如果链表长度大于8,则转换元素存储类型,即链表转红黑树。
总结:
1、当不存在hash冲突时,采用基于主内存的volatile读来读取数组元素、采用CAS确保存储数据的线程安全性;
2、当存在hash冲突时,针对单个数组位置加synchronized锁;链表转红黑树时也是对单个数组位置加synchronized锁;
3、帮助其他线程进行数据迁移/扩容时,通过对volatile修饰的变量nextTable、sizeCtl进行volatile读确保线程安全性。
第二阶段调用addCount()方法,将ConcurrentHashMap中存储的KV键值对总数+1。
addCount()方法,我们先只看前半部,后半部分的扩容下篇文章讲接:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x) 每次竟来都baseCount都加1因为x=1
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {//1
CounterCell a; long v; int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
//多线程CAS发生失败的时候执行
fullAddCount(x, uncontended);//2
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//当条件满足开始扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
if (sc < 0) {//如果小于0说明已经有线程在进行扩容操作了
//一下的情况说明已经有在扩容或者多线程进行了扩容,其他线程直接break不要进入扩容操作
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))//如果相等说明扩容已经完成,可以继续扩容
transfer(tab, nt);
}
//这个时候sizeCtl已经等于(rs << RESIZE_STAMP_SHIFT) + 2等于一个大的负数,这边加上2很巧妙,因为transfer后面对sizeCtl--操作的时候,最多只能减两次就结束
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
通过对volatile修饰的变量baseCount、sizeCtl进行CAS,对CounterCell进行CAS确保对数量修改的线程安全性。在并发量很高时,如果存在两个线程同时执行CAS修改baseCount值,失败的线程会继续执行fullAddCount(x, uncontended)方法,这个方法其实就是初始化counterCells,并将x的值插入到counterCell类中,而x值一般也就是1。
3、size()方法
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
当 counterCells 不是 null,就遍历元素,并和 baseCount 累加。(变量上边文章有介绍)
counterCell结构
// 一种用于分配计数的填充单元。改编自LongAdder和Striped64。请查看他们的内部文档进行解释。
@sun.misc.Contended
static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
其中的@sun.misc.Contended,是Java8新增的一个注解,对某个字段加上该注解 则表示该字段会单独占用一个缓存行(Cache Line);
-
缓存行是指CPU缓存(L1、L2、L3)的存储单元,常见的缓存行大小为64字节;
-
JVM添加-XX:-RestrictContended参数后@sun.misc.Contended注解才有效。
单独使用一个缓存行可以避免伪共享:
为了提高读取速度,每个CPU都有自己的缓存,CPU读取数据后会存到自己的缓存里;并且为了节省空间,一个缓存行可能存储着多个变量,即伪共享。但是伪共享对于共享变量,会造成性能问题,比如:
当一个CPU要修改共享变量A时会先锁定自己缓存里 A所在的缓存行,并且把其他CPU缓存上相关的缓存行设置为无效。但如果被锁定或失效的缓存行里,还存储了其他不相干的变量B,其他线程此时就访问不了B。
或者由于缓存行失效需要重新从内存中读取加载到缓存里,也就造成了开销。
所以让共享变量A单独使用一个缓存行就不会影响到其他线程对其他共享变量的访问。
适用场景
主要适用于会被频繁写的共享数据上。如果不是被频繁写的数据,那么CPU缓存行被锁的几率就不大,所以没必要使用;否则不仅占空间还会浪费CPU访问/操作数据的时间。