众所周知 ConcurrentHashMap 是 HashMap 的多线程版别,HashMap 在并发操作时会有各种问题,比方死循环问题、数据覆盖等问题。而这些问题,只需运用 ConcurrentHashMap 就可以完美解决了,那问题来了,ConcurrentHashMap 是怎么确保线程安全的?它的底层又是怎么完成的?
ConcurrentHashMap 线程安全完成简述
ConcurrentHashMap 在 JDK 1.7 时,运用的是分段锁也便是 Segment 来完成线程安全的。
但是它在 JDK 1.8 之后,运用的是 CAS + synchronized 或 CAS + volatile 来完成线程安全的。
JDK 1.7 底层结构
ConcurrentHashMap 在不同的 JDK 版别中完成是不同的,在 JDK 1.7 中它运用的是数组加链表的形式完成的,而数组又分为:大数组 Segment 和小数组 HashEntry。 大数组 Segment 可以理解为 MySQL 中的数据库,而每个数据库(Segment)中又有很多张表 HashEntry,每个 HashEntry 中又有多条数据,这些数据是用链表衔接的,如下图所示:
JDK 1.7 线程安全完成
了解了 ConcurrentHashMap 的底层完成,再看它的线程安全完成就比较简单了。
接下来,咱们通过增加元素 put 方法,来看 JDK 1.7 中 ConcurrentHashMap 是怎么确保线程安全的,详细完成源码如下:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// 在往该 Segment 写入前,先确保获取到锁
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try {
// Segment 内部数组
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有值...
}
else {
// 放置 HashEntry 到特定方位,假如超过阈值则进行 rehash
// 忽略其他代码...
}
}
} finally {
// 开释锁
unlock();
}
return oldValue;
}
从上述源码咱们可以看出,Segment 自身是基于 ReentrantLock 完成的加锁和开释锁的操作,这样就能确保多个线程同时拜访 ConcurrentHashMap 时,同一时间只要一个线程能操作相应的节点,这样就确保了 ConcurrentHashMap 的线程安全了。
也便是说 ConcurrentHashMap 的线程安全是建立在 Segment 加锁的基础上的,所以咱们把它称之为分段锁或片段锁,如下图所示:
JDK 1.8 底层结构
在 JDK 1.7 中,ConcurrentHashMap 虽然是线程安全的,但由于它的底层完成是数组 + 链表的形式,所以在数据比较多的情况下拜访是很慢的,由于要遍历整个链表,而 JDK 1.8 则运用了数组 + 链表/红黑树的方式优化了 ConcurrentHashMap 的完成,详细完成结构如下: 链表晋级为红黑树的规则:当链表长度大于 8,而且数组的长度大于 64 时,链表就会晋级为红黑树的结构。
PS:ConcurrentHashMap 在 JDK 1.8 虽然保留了 Segment 的定义,但这只是是为了确保序列化时的兼容性,不再有任何结构上的用处了。
JDK 1.8 线程安全完成
在 JDK 1.8 中 ConcurrentHashMap 运用的是 CAS + volatile 或 synchronized 的方式来确保线程安全的,它的核心完成源码如下:
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 节点为空
// 运用 CAS 去进行无锁线程安全操作,假如 bin 是空的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// 假如超过阈值,晋级为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
从上述源码可以看出,在 JDK 1.8 中,增加元素时首先会判断容器是否为空,假如为空则运用 volatile 加 CAS 来初始化。假如容器不为空则根据存储的元素核算该方位是否为空,假如为空则运用 CAS 设置该节点;假如不为空则运用 synchronize 加锁,遍历桶中的数据,替换或新增节点到桶中,最后再判断是否需要转为红黑树,这样就能确保并发拜访时的线程安全了。
咱们把上述流程简化一下,咱们可以简单的以为在 JDK 1.8 中,ConcurrentHashMap 是在头节点加锁来确保线程安全的,锁的粒度相比 Segment 来说更小了,发生冲突和加锁的频率降低了,并发操作的功能就提高了。而且 JDK 1.8 运用的是红黑树优化了之前的固定链表,那么当数据量比较大的时候,查询功能也得到了很大的提高,从之前的 O(n) 优化到了 O(logn) 的时间复杂度,详细加锁示意图如下:
小结
ConcurrentHashMap 在 JDK 1.7 时运用的是数据加链表的形式完成的,其间数组分为两类:大数组 Segment 和小数组 HashEntry,而加锁是通过给 Segment 增加 ReentrantLock 锁来完成线程安全的。而 JDK 1.8 中 ConcurrentHashMap 运用的是数组+链表/红黑树的方式完成的,它是通过 CAS 或 synchronized 来完成线程安全的,而且它的锁粒度更小,查询功能也更高。