JUC-ConcurrentHashMap 源码分析
前言
ConcurrentHashMap 是 J.U.C 包里面提供的一个线程安全并且高效的 HashMap,所ConcurrentHashMap 多用在并发编程的场景中保证安全性。
JDK1.7 和 JDK1.8 变化
ConcurrentHashMap 和 HashMap 的实现原理是差不多的,在此基础上支持并发操作。
在 JDK1.7 的实现上, ConrruentHashMap由多个 Segment 组成, 通过ReentrantLock 来进行加锁,每次锁住一个 segment来保证每个 segment内的操作的线程安全性从而实现全局线程安全。默认情况下,理论上可以同时支持 16 个线程的并发写入。
JDK1.7 CHM结构图如下
JDK1.8CHM 作出了改进
-
取消了 segment 分段设计,使用 Node 数组来保存数据,并且采用 Node 数组元素作为锁来实现每一行数据进行加锁来进一步减少并发冲突的概率;
-
将原本数组+单向链表的数据结构变更为了数组+单向链表+红黑树的结构;
为什么要引入红黑树呢?
在正常情况下,key值hash之后如果能够很均匀的分散在数组中,那么 table 数组中的每个队列的长度主要为 0 或者 1.但是实际情况下,还是会存在一些队列长度过长的情况。如果还采用单向列表方式,那么查询某个节点的时间复杂度就变为 O(n); 因此对于队列长度超过 8 的列表,JDK1.8 采用了红黑树的结构,那么查询的时间复杂度就会降低到O(logN),可以提升查找的性能。
chm结构和 JDK1.8 版本中的 Hashmap 的实现结构基本一致。但为了保证线程安全性,chm的实现会稍微复杂。
put()和get()是如何保证线程安全的
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) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());// 计算 hash 值
int binCount = 0; // 用来记录链表的长度
for (Node<K,V>[] tab = table;;) {// 自旋操作,当出现线程竞争时不断自旋
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)// 如果数组为空,则数组初始化
tab = initTable();//初始化
//通过 hash 值对应的数组下标得到第一个节点;
//以 getObjectVolatile() 读的方式来读取 table 数组中的元素,保证每次拿到的数据都是最新的
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该下标返回的节点为空,则直接通过 cas 将新的值封装成 node 插入即可;
//如果 cas 失败,说明存在竞争,则进入下一次循环
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
...
}
假如在上面这段代码中存在两个线程,在不加锁的情况下:线程 A 成功执行 casTabAt 操作后,随后的线程 B 可以通过 tabAt 方法立刻看到 table[i]的改变。原因如下:线程 A 的casTabAt 操作,具有 volatile 读写相同的内存语义,根据 volatile 的 happens-before 规则:线程 A 的 casTabAt 操作,一定对线程 B 的 tabAt 操作可见。
initTable()
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)//被其他线程抢占了初始化的操作,则直接让出自己的 CPU时间片
Thread.yield(); // lost initialization race; just spin
//通过 cas 操作,将 sizeCtl 替换为-1,标识当前线程抢占到了初始化资格
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;//默认初始容量为16
@SuppressWarnings("unchecked")
//初始化数组,长度为 16,或者初始化在构造 ConcurrentHashMap 的时候传入的值
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;//将这个数组赋值给 table
sc = n - (n >>> 2);//使用了右移来计算下次扩容的大小,当前容量的0.75
}
} finally {
sizeCtl = sc;//设置 sizeCtl 为 sc, 如果默认是 16 的话,那么这个时候sc=16*0.75=12
}
break;
}
}
return tab;
}
数组初始化方法,初始化一个合适大小的数组。sizeCtl这个标志是在 Node 数组初始化或者扩容的时候的一个控制位标识,负数代表正在进行初始化或者扩容操作。
-1 代表正在初始化;
-N 代表有 N-1 个线程正在进行扩容操作;
tabAt()
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
该方法获取对象中offset偏移地址对应的对象field的值。实际上这段代码的含义等价于tab[i],但是为什么不直接使用 tab[i]来计算呢?getObjectVolatile(),一旦看到 volatile 关键字,就表示可见性。因为对 volatile 写操作 happen-before 于 volatile 读操作,因此其他线程对 table 的修改均对 get 读取可见;虽然 table 数组本身是增加了 volatile 属性,但是“volatile 的数组只针对数组的引用具有volatile 的语义,而不是它的元素”。 所以如果有其他线程对这个数组的元素进行写操作,那么当前线程来读的时候不一定能读到最新的值。出于性能考虑,Doug Lea 直接通过 Unsafe 类来对 table 进行操作。
Unsafe 类
Unsafe 类是在 sun.misc 包下,不属于 Java 标准。但是很多 Java 的基础类库,包
括一些被广泛使用的高性能开发库都是基于 Unsafe 类开发的,比如 Netty、Hadoop、Kafka 等;
Unsafe 可认为是 Java 中留下的后门,提供了一些低层次操作,如直接内存访问、线程的挂起和恢复、CAS、线程同步、内存屏障,而 CAS 就是 Unsafe 类中提供的一个原子操作,第一个参数为需要改变的对象,第二个为偏移量(即之前求出来的 headOffset 的值),第三个参数为期待的值,第四个为更新后的值整个方法的作用是如果当前时刻的值等于预期值 var4 相等,则更新为新的期望值 var5,如果更新成功,则返回 true,否则返回 false;
put()方法第二阶段
在putVal方法执行完成以后,会通过addCount来增加ConcurrentHashMap中的元素个数,并且还会可能触发扩容操作。这里会有两个非常经典的设计:
-
高并发下的扩容
-
如何保证 addCount 的数据安全性以及性能
final V putVal(K key, V value, boolean onlyIfAbsent) { ... //将当前 ConcurrentHashMap 的元素数量加 1,有可能触发 transfer 操作(扩容) addCount(1L, binCount); return null; }
addCount()
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
/*
判断 counterCells 是否为空,
1. 如果为空,就通过 cas 操作尝试修改 baseCount 变量,对这个变量进行原子累加操
作(做这个操作的意义是:如果在没有竞争的情况下,仍然采用 baseCount 来记录元素个
数)
2. 如果 cas 失败说明存在竞争,这个时候不能再采用 baseCount 来累加,而是通过
CounterCell 来记录
*/
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;//是否冲突标识,默认为没有冲突
/*
这里有几个判断
1. 计数表为空则直接调用 fullAddCount
2. 从计数表中随机取出一个数组的位置为空,直接调用 fullAddCount
3. 通过 CAS 修改 CounterCell 随机位置的值,如果修改失败说明出现并发情况(这里又
用到了一种巧妙的方法),调用 fullAndCount
Random 在线程并发的时候会有性能问题以及可能会产生相同的随机
数 ,ThreadLocalRandom.getProbe 可以解决这个问题,并且性能要比 Random 高
*/
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
fullAddCount(x, uncontended);//执行 fullAddCount 方法
return;
}
if (check <= 1)//链表长度小于等于 1,不需要考虑扩容
return;
s = sumCount();//统计 ConcurrentHashMap 元素个数
}
...
}
在 putVal() 最后调用 addCount() 的时候,传递了两个参数,分别是 1 和 binCount(链表长度),
addCount 方法里面做了什么操作?
x 表示这次需要在表中增加的元素个数,check 参数表示是否需要进行扩容检查,大于等于 0
都需要进行检查
// TODO