什么是ConcurrentHashMap
在并发环境下,直接使用 HashMap会导致数据不一致甚至死循环。而使用 Collections.synchronizedMap()包装的Map,虽然线程安全,但它是通过在整个对象上使用 粗粒度锁(synchronized) 来实现的,性能极差(所有操作串行化)。
因此,需要一种支持高并发读写、且保证线程安全的 Map。这就是 ConcurrentHashMap的使命。
ConcurrentHashMap的演变
在JDK 1.7以及之前,ConcurrentHashMap由一组Segment(段)组成,每个Segment本质上是一个独立的ReentrantLock和一个小型的HashEntry数组。
在JDK 1.8以及之后,采用CAS加synchronized精细化锁。
ConcurrentHashMap的底层数据结构
节点类
普通节点
java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // volatile保证可见性
volatile Node<K,V> next;
}
ConcurrentHashMap和HashMap中的Node有点不同,就在于val和next属性使用了volatile进行修饰。
红黑树节点
java
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
}
Forwading节点
java
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
}
参数
sizeCtl
负数:表示初始化或扩容状态
-
-1 : 表示哈希表
table正在初始化中。这个状态由某个线程通过 CAS 操作独占。 -
-(1 + n) : 表示哈希表
table正在扩容中。
高 16 位: 存放一个扩容标识戳 (resizeStamp),由当前 table 的长度 n计算得来,用于标记本次扩容是基于哪个长度的 table 进行的。
低 16 位: 表示正在进行扩容的线程数 + 1 。例如,-(1 + 2)表示有 2 个线程正在协助扩容。
零或正数:
- 0 : 创建 ConcurrentHashMap 时未指定初始容量,使用默认初始容量(16)。
- 正数 :
- 在初始化之前 : 表示用户指定的初始容量(会调整为 2 的幂次)。
- 在初始化或扩容完成之后 : 表示下一次触发扩容的阈值 (
capacity * loadFactor)。
put操作流程
java
public V put(K key, V value) {
return putVal(key, value, false);
}
实际的工作委托给putVal函数,第三个参数false表示如果键已存在,则替换。
在putVal的内部,整体是一个巨大的自旋循环
java
for (Node<K,V>[] tab = table;;) {
// 无限循环直到插入成功
}
ConcurrentHashMap是懒加载的,因此如果底层的数组是空的,则先进行数组初始化
java
if (tab == null || (n = tab.length) == 0)
tab = initTable();
- 如果底层数组不为空,但是当前插入的桶为空,使用
CAS插入数据,如果成功则直接插入,如果失败则循环重试。
java
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break; // no lock when adding to empty bin
}
- 如果当前插入的桶的头是一个
ForwardingNode,说明正在扩容,因此线程帮助扩容
java
if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
- 如果不是第2种和第3种情况,将会使用
synchronized锁住该桶头,在该桶内,查找对应的键,如果找到则更新值,如果没有找到则插入尾部。
java
else {
V oldVal = null;
synchronized (f) { // 锁住链表头节点
// 双重检查
if (tabAt(tab, i) == f) {
// 插入逻辑
}
}
}
- 插入后,如果达到阈值,则转成红黑树
- 更新元素个数,采用类似
LongAdder的分段计数的思想。
初始化
在ConcurrentHashMap中初始化底层数组时,依旧使用自旋+CAS的方法处理
java
while ((tab = table) == null || tab.length == 0){
...
}
- 如果当前有其他的线程正在初始化,那么将放弃
CPU
java
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
- 如果没有其他线程在初始化,那么
cas设置当前的状态标签为-1
java
else if (U.compareAndSetInt(this, SIZECTL, sc, -1))
- 竞争成功后,进行双重检查,然后初始化数组,最后恢复状态标签
java
try {
if ((tab = table) == null || tab.length == 0) { // 双重检查
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 确定初始容量
Node<K,V>[] nt = new Node<?,?>[n]; // 创建数组
table = tab = nt; // volatile 写入
sc = n - (n >>> 2); // 计算扩容阈值:n*0.75
}
} finally {
sizeCtl = sc; // 恢复为正的阈值
}
get流程
get操作是ConcurrentHashMap高性能读的关键。不需要加锁 ,因为节点的val和next都是 volatile的,保证了可见性 。 根据哈希定位到桶,然后遍历链表或搜索红黑树即可。如果遇到ForwardingNode,说明正在扩容,会调用其 find方法到新数组中去查找。