ConcurrentHashMap
请先掌握HashMap,本文ConcurrentHashMap源码基于1.8
ConcurrentHashMap是一个线程安全的HashMap,通过CAS + synchronized 来保证并发安全,数据结构仍然是数组+链表+红黑树。读操作get是不会加锁的。
核心字段
// table数组
transient volatile Node<K,V>[] table;
// rehash时用到的数组
private transient volatile Node<K,V>[] nextTable;
// sizeCtl
private transient volatile int sizeCtl;
// rehash时用到
private transient volatile int transferIndex;
// 下面这三个看名字就很眼熟,甚至Striped64有个一模一样的字段名,cellsBusy
// 这是统计元素个数用的,显然这是把LongAdder思想借用过来了,我们后面再细说
private transient volatile long baseCount;
private transient volatile int cellsBusy;
private transient volatile CounterCell[] counterCells;
sizeCtl:控制逻辑
sizeCtl是一个用于控制Map行为的关键字段,Ctl是Control的缩写
// 源码注解如下
/**
* Table initialization and resizing control. When negative, the
* table is being initialized or resized: -1 for initialization,
* else -(1 + the number of active resizing threads). Otherwise,
* when table is null, holds the initial table size to use upon
* creation, or 0 for default. After initialization, holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
sizeCtl用于控制「初始化」和「扩容」。
简单翻译一下大概是下面这个意思:
-
负数代表:正在被初始化或者是扩容
- -1:代表正在初始化
- -X ( X > 1 ):代表正在扩容,扩容的线程数为 X-1(真的是这样么)
-
正数代表:数组初始化的大小或是触发扩容的阈值
- 未进行初始化,代表初始化时数组的大小,默认为0
- 已经初始化完毕,代表触发扩容的阈值
看一个构造方法佐证一下:
public ConcurrentHashMap(int initialCapacity) { int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }设置的是sizeCtl的值,而非直接初始化数组,实际上大多数集合类都有这样的懒加载的细节,可以参考:
核心方法
put(k,v)详细流程
final V putVal(K key, V value, boolean onlyIfAbsent) {
// hash值
int hash = spread(key.hashCode());
// 链表长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// 1. 数组为空 初始化数组
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 2. 数组的位置为null,CAS的方式直接放上去
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 若CAS失败再来一次循环 成功就直接退出 put成功
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
// 3. 发现是fwd 帮助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 4. 一般逻辑 下面再分析
else {
// ...
}
}
// 计数
addCount(1L, binCount);
return null;
}
再来看看一般逻辑:
// 就是 数组的那个位置有节点,首先锁住头节点(可能是链表头,也可能是红黑树的根)
V oldVal = null;
synchronized (f) {
// 1. 链表
if (fh >= 0) {
// 遍历链表 判断equals,如果key都不同,就尾插
}
// 2, 红黑树
else if (f instanceof TreeBin) {
// 插入红黑树
}
}
// 如果尾插链表后达到阈值8
// 如果数组小于64,扩容;否则转化为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
initTable方法如何初始化数组?
initTable会尝试CAS的方式将sizeCtl设置为-1,发现其他线程正在初始化,会yield(尽量等到别的线程初始化数组完毕,该线程再继续执行,并不是陷入阻塞状态,而是进入可运行状态)
if ((sc = sizeCtl) < 0)
Thread.yield();
最后返回tab。
其实就是用CAS保证只有一个线程会做初始化
大致流程已经理清了,现在还有几个黑盒我们后面解决:
- treeifyBin内的tryPresize如何扩容
- addCount(1L, binCount)方法干了啥
get(k)详细流程
get方法是没有加锁的。
public V get(Object key) {
// hash
int h = spread(key.hashCode());
// 这下面一大堆判断 如果数组节点为空 就返回空 有就继续
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 1. 头节点就是目标节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 2. 红黑树 或是 正在扩容
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 3. 链表遍历
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
如何统计元素个数
在HashMap中,我们用一个transient int size;统计容器内的元素个数,但ConcurrentHashMap不能这么做
put方法锁的是hash对应的链表头节点/红黑树根节点,但容器内的元素个数是数组所有元素共享的,那这样维护元素个数就需要锁住整个Map,性能非常低。在上篇文章CAS与锁的应用之:原子类、LongAdder、阻塞队列详解学习了LongAdder以后,这不就是将LongAdder应用到实战的一个很好的机会吗?实际上,ConcurrentHashMap也确实是以类似的方式做的:
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;
cell眼熟吧,longAdder的数组不也是cells数组吗
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}
同样用Contended注解解决伪共享问题
再看size方法
public int size() {
long n = sumCount();
return n; // 一般情况就return n 省略特殊情况的判断
}
final long sumCount() {
long sum = baseCount;
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
return sum;
}
发现和LongAdder的思想几乎完全一样。
addCount:修改元素个数
最后来看一下addCount方法。虽然名字叫add,实际上在删除节点是也是调用这个方法来维护count的
// x代表对count + x
// check = -1 代表删除操作
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 如果数组为空,对BASECOUNT做CAS
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// 数组不为空 或者CAS失败 进入这个if分支
CounterCell a; long v; int m;
boolean uncontended = true;
// 根据ThreadLocalRandom再去CAS 这个和LongAdder的行为非常像
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 这个方法上面的注释是:
// See LongAdder version for explanation
// 是的,这个方法和LongAdder的longAccumulate几乎一样
// 详解请参考:https://juejin.cn/post/7280747221510930486
// 总之通过自旋CAS,一定能保证成功加上这个x
fullAddCount(x, uncontended);
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) {
// resizeStamp RESIZE_STAMP_SHIFT
int rs = resizeStamp(n);
if (sc < 0) {
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);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
s = sumCount();
}
}
}
resize_stamp:rs
这个东西初看会让人摸不着头脑,我们举个例子来分析:
private static int RESIZE_STAMP_BITS = 16;
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;//16
static final int resizeStamp(int n) {// n是原数组table的长度
// n的前导0的个数 | (1<<15)
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
假设n为8,则resizeStamp的返回值,即rs的值,为:
28 | (0000 0000 0000 0000 1000 0000 0000 0000)
rs = (0000 0000 0000 0000 1000 0000 0001 1100)
在第一次触发扩容时,会将sizeCtl修改为:
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
sizeCtl = 1000 0000 0001 1100 0000 0000 0000 0010
因此,在扩容期间,sizeCtl一定是负数,且不是-1,可以用于扩容期间的标识;前16位的sizeCtl存储了扩容的原数组的n的信息;后16位存储了当前在扩容的线程的数量+1,也就是源码注解提到的意思。
那这个rs有啥用呢?我们就看addCount方法:
if ((sc >>> RESIZE_STAMP_SHIFT) != rs)
可以利用rs来判断此次扩容是否是当前容量n触发的,避免两次触发扩容导致的冲突
扩容机制🚩
其实ConcurrentHashMap最复杂的最难理解的点就是扩容,换个角度思考:如果让你来设计ConcurrentHashMap,你会怎么做呢?最简单的方法就是:所有方法加个synchronized,但复杂度太高了;由于每次get/put都只会对数组的某个位置进行操作,因此我们希望细化锁粒度,只锁数组上的这个节点,这也好办。但困难之处就在于:如何扩容?扩容时,get/put的线程该怎么做?
addCount方法内通过transfer来扩容,但treeifyBin(转红黑树的逻辑)内又用tryPresize方法,这名字不是一看就是扩容吗?那到底哪个是扩容的方法?我们发现tryPresize内调用了transfer,那可以先猜测:它们都能扩容。我们之后再分析源码来验证看看这个猜想是否正确。先看看它们的调用时机。
扩容时机
- transfer有三个方法调用,分别是helpTransfer,addCount和tryPresize
- tryPresize有两处调用,分别是treeifyBin和putAll
因此,扩容时机为:
- put最后的addCount方法会检查是否需要扩容,sizeCtl超过3/4了
- 链表元素超过8个,要转换成红黑树前,会判断数组大小是否大于等于64,小于64会扩容
- 一下子放很多元素,可能导致预先的扩容
再加上helpTransfer,扩容期间对桶做修改的线程会加入参与扩容
因为tryPresize内调用了transfer,我们当然先看transfer
真正的扩容:transfer
这个方法非常长,正式开始前,我们需要先了解一下字段中transferIndex的意思。
transferIndex的意义
transferIndex在触发扩容时,会被设置为旧数组的长度,在线程参与到transfer方法进行扩容时,会被分配一部分任务(需要迁移的桶),那怎么来分配呢?就是利用transferIndex,在执行任务前,会不断CAS将transferIndex - stride(每个线程负责迁移的桶的数量),于是这部分任务就交给了这个线程
一个线程不是只会被分配一次任务的,如果一次任务执行完,仍未finishing,advance仍为true,还会被再分配一次任务,直到迁移工作不需要该线程了,才会从transfer返回
接下来开始transfer源码的漫漫长路:
1.计算每个线程负责迁移多少,最小为16
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 16
2.如果目标数组为空,初始化一下(第一次触发扩容时nextTab为空)
if (nextTab == null) { // initiating
try {
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
3.准备工作,下面要进入一个非常长的循环
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true; // 是否继续分配任务
boolean finishing = false; // 是否所有table的桶都已经迁移完毕
4.开始循环,比较长,还是拆开来讲
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// ....
}
4.1、分配任务,这就是前文提到的transferIndex起到的作用
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
4.2、任务都已经分配完了,那不需要我继续扩容了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 任务全部完成 退出
if (finishing) {
nextTable = null;
table = nextTab;
// sizeCtl仍然是 3/4 cap
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 任务虽然全部被分派,但还没执行完成,维护一下sizeCtl的值
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 如果我不是最后一个线程了 直接退出就好了
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 如果是参与扩容的最后一个线程 还需要recheck一遍
// 会再遍历一遍所有节点看是否有所遗漏
finishing = advance = true;
i = n; // recheck before commit
}
}
4.3、如果这个位置为空,直接CAS改成fwd即可
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
4.4、如果已经是fwd了,已经被处理完了,或者是正在被别的线程处理,不用管,跳过即可
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
4.5、这个桶真的需要我去迁移了:
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
// 准备好两个链表,迁移时因为hashcode只多一位
// 所以一个旧桶的元素,最多只会被分配到两个新桶,先自己串成两个链表
Node<K,V> ln, hn;
// 如果是链表
if (fh >= 0) {
// 这里是找到最后一个新一位hashcode的不一样的节点
// 从lastRun到链表尾,这些元素都可以保留,不必new Node
// 算是个小优化 不影响理解流程
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 遍历整个链表 根据新一位的Hashcode决定插入哪个链表
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 真正修改
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树就省略了
}
}
}
transfer方法小结
真正需要我去扩容时,我会锁住原table的头节点,然后生成两个链表,插入到新table后,再设置为fwd节点释放锁。transfer的过程是持有锁的。
至此,transfer源码就分析完毕了,再看tryPresize
private final void tryPresize(int size) {
// 保证2的幂
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 数组为空,初始化数组
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
// CAS 修改sizeCtl为-1,表示table数组正在初始化
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
// 设置sizeCtl为 3/4的cap
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
// 不需要继续扩容的情况
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
int rs = resizeStamp(n);
// 正在被初始化或者扩容
if (sc < 0) {
Node<K,V>[] nt;
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);
}
// CAS尝试触发扩容
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
在扩容时读写操作如何进行
其实get/put方法我们已经介绍过,但介绍了扩容后,我们才能补充与扩容有关的细节
put
在put方法中,有两处会影响扩容:
- hashcode找到数组下标,不是fwd,至少这个位置还没被扩容,正常put
- 发现数组对应的下标的节点是个fwd,就helpTransfer帮助扩容,等待扩容完毕再继续put
- addCount,treeifyBin可能会主动触发扩容,这个前文已经详细介绍过了
get
在get方法中,看这个之前被我们忽视的2分支
// 2. 红黑树 或是 正在扩容
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
ForwardingNode重写了find方法,我们来看看这个方法
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
// nexttab没节点
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
// 新table的首节点就是
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
// 又被扩容 或是 红黑树
if (eh < 0) {
// 1 扩容 重来一次
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
// 2 红黑树的find
else
return e.find(h, k);
}
// 遍历链表
if ((e = e.next) == null)
return null;
}
}
}
小结
因为迁移过程中,迁移完毕后才会将节点设置为fwd,因此:
- 如果put时,该处的桶正在扩容,put也需要获取锁,因此会阻塞
- 如果put时,该处的桶已经扩容完,即是个fwd节点,会帮助扩容
- 如果get时,该处的桶正在扩容,没关系,直接在原table上去get就行
- 如果get时,该处的桶已经扩容完,即是个fwd节点,那就去新table上去get
所以扩容几乎不会影响get的性能,这是concurrentHashMap设计的非常巧妙的一点。
get不会帮助扩容,put是会的
最后非常推荐ConcurrentHashMap底层详解(图解扩容)(JDK1.8)的流程图,比直接啃源码会更清晰一些
常见面试题
能不能放入null
HashMap允许key和value为null,但ConcurrentHashMap不允许,会直接报错。
HashMap如果key为null,hash值0
key/value放入null值本身就是一件有二义性的事情,即get时拿到值为null,我们无法确定这到底是不存在该键值对,还是value就是null。
如果非要用null,可以用一个空Object代替null
public static final Object NULL = new Object();
为啥HashMap可以?忘了从哪听来的说法了,HashMap在设计时关于该不该允许放入null争论了很久。一般情况下也不会放null,非要放建议用上面的方案。
与HashMap的不同
利用CAS+synchronized保证线程安全,由于锁粒度细化,在扩容和计数上都有区别。计数是LongAdder的思想,而扩容非常复杂。以及put/get的详细流程,加锁与CAS的使用细节。
简述扩容机制
时机:元素数量超过3/4,链表元素个数超过8个且数组<64,addAll放很多元素会触发扩容
行为:transfer方法,分配任务,如何迁移(两链表),加锁与fwd的细节
别的get/put线程的行为:put可能帮忙?get直接返回?四种情况讨论
细节:rs有啥用?transferindex?sizeCtl的含义?这些都说出来面试官就能get到你是看过源码的
由于concurrentHashMap源码比较复杂,难免有错误,请在评论区指正,感激不尽