前言
JDK1.8 的ConcurrentHashMap相较于JDK1.7 的做了比较大的改动,取消了分段锁 的设计。JDK8改成了Node数组+链表/红黑树,Node 是类似于一个 HashEntry 的结构。它的冲突再达到一定大小时会转化成红黑树,在冲突小于一定数量时又退回链表。
同时使用了CAS和synchronized锁来保证线程安全,synchronized锁的粒度为桶中的头节点(链表Node结点或包装红黑树的TreeBin结点)。
(有先了解HashMap源码和JDK1.7 的ConcurrentHashMap分析再来阅读会好些)
基本构造
重要属性
// 将链表转换成红黑树的阈值,链表长度超过阈值就会进行转换
static final int TREEIFY_THRESHOLD = 8;
// 将红黑树退化成链表的阈值,当某个桶中的节点数小于等于阈值就会转换成链表
static final int UNTREEIFY_THRESHOLD = 6;
static final int MOVED = -1; // 表示正在转移
static final int TREEBIN = -2; // 表示是树节点
// 默认没初始化的数组,用来保存元素
transient volatile Node<K,V>[] table;
// 转移数据的时候使用的数组
private transient volatile Node<K,V>[] nextTable;
private transient volatile int sizeCtl;// 状态控制变量
- sizeCtl 是最重要的属性了。它的取值比较多,不同值表示不同的场景。有:
- -1 代表正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- table还没初始化时,则表示初始化的容量
- table初始化化,该值为容量的0.75倍,可以理解为类似HashMap的扩容阈值,超过此阈值就会进行扩容。从另一个角度看,也可以认为加载因子是0.75。
- 元素节点hash值的含义:
hash == MOVED
,MOVED即 -1 ,表示当前正在转移数据(扩容时把旧数据转移到新数组中)hash == TREEBIN
,TREEBIN 即 -2,表示当前节点是树节点,在get方法中会用到hash > 0
,表示节点是个链表节点。
table操作的三个核心方法
这三个方法都是调用Unsafe类直接对内存进行操作的,效率较高。
// 获得在i位置上的Node节点。因为volatile关键字无法保证数组元素的可见性,所以需要调用getObjectVolatile
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);
}
// 利用CAS操作设置i位置上的Node节点,保证只能有一个线程修改成功
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 把Node节放置到i位置,放置完成后对其他线程立即可见。当i位置为空时调用此方法
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
构造函数
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
// 相当于取 (1.5*initialCapacity +1)的最近的2的n次方
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
可以看到,构造函数里对用户传入的初始容量重新做了计算,并取最近的2的n次方。和HashMap一样,ConcurrentHashMap的容量也总保证是2的n次方。计算之后把新容量赋值给 sizeCtl,这就是sizeCtl
的第一个使用场景:table未初始化时,表示初始化容量。
初始化table
这个操作比较简单,就是初始化一个指定大小的数组。如果有多个线程创建时,会使用CAS保证只有一个线程能创建成功。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 说明其他线程正在进行初始化,那么让出当前线程的CPU使用权
if ((sc = sizeCtl) < 0)
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) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 n - n/4 = 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
put方法
put 的主要流程是:
- 计算 key 的hash值
- 判断数组是否为空,为空则进行初始化
- 根据hash值得到key在数组中对应的索引,取得该位置的节点
- 如果该位置为空,则直接CAS放入新节点
- 如果该位置的节点的hash等于MOVED,说明当前正在迁移数据,那么当前线程也先去帮忙,等到迁移完成后,再把数据添加进去。
- 否则就取得该节点的监视器锁,判断该节点如果为链表,则使用尾插法插入,如果是红黑树则调用红黑树的方法插入。插入完成后判断链表长度是否超过树化阈值,超过则将该链表转换成红黑树。
- 最后更新容器的size,并判断是否需要扩容。
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 对key的hashCode重新计算,得到 hash 值
int hash = spread(key.hashCode());
// 用于记录相应链表的长度
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 值对应的数组下标,得到第一个节点 f
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// hash 等于 MOVED时,说明现在在扩容,那么当前线程先去帮忙转移数据,扩容完再把数据添加进来
else if ((fh = f.hash) == MOVED)
// 帮助数据迁移,后面会介绍
tab = helpTransfer(tab, f);
else { // 到这里就是说,f 是该位置的头结点,而且不为空,那么就可以添加数据了
V oldVal = null;
// 获取数组该位置的头结点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头结点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
// 到了链表的最末端,将这个新节点放到链表的最后面(尾插法)
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
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;
}
}
}
}
if (binCount != 0) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 更新容器的size,判断是否需要扩容。(在这里暂不分析,也是大有文章)
addCount(1L, binCount);
return null;
}
扩容流程
接下来我们来看看ConcurrentHashMap里牛逼又比较难理解的部分:扩容。线程在执行put等操作时,如果发现当前容器正在扩容,那么都会先放下自己手里的工作,一起先帮忙迁移数据,实现了并发迁移,等到迁移完毕,再继续操作自己的数据。ConcurrentHashMap也是做翻倍扩容的,每次扩容后长度是原来的2倍。
扩容:tryPresize
接下来我们来看扩容方法tryPresize
,它会尝试把数组扩大到给定的容量。
// treeifyBin调用此函数时,传进来的参数 size 已经是原数组长度的2倍了。
// putAll 传过来的size是要插入的元素数量
private final void tryPresize(int size) {
// c 的取值为(size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方)。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
// sizeCtl>=0 说明此时没有其他线程在初始化或迁移数据,那么我就来做这个工作
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
// 再次检查tab是否为原引用,不是的话则说明已经完成扩容了,不用再操作了
else if (tab == table) {
// resizeStamp(n)的大致意思是记录扩容时的信息,在transfer方法可以用来判断迁移操作的状态
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;
// 2. 用 CAS 将 sizeCtl 加 1,表明当前线程加入了迁移数据的工作,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 此时得到的结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null,用于初始化迁移数组
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
接下来就是扩容的重头戏:transfer方法。
数据迁移:transfer
这个方法的功能就是把原数组的数据迁移到新数组 nextTable 中去,迁移完成后再把 nextTable 赋值给table。
transfer会在多个地方被调用,可能会被多个线程同时调用,但它是线程安全的。其他方法在调用transfer时,会保证第一个发起数据迁移的线程,nextTable 参数传入为null,使transfer可以先对nextTable初始化。之后其他线程再调用时,nextTable不会为空。
首先我们要了解 transfer 是如何工作的。假设原数组长度为n,那么也就是有n个迁移任务。如果让每个线程每次只分配一个迁移任务是最简单的,但这样的话分配次数就多了,比较消耗资源。所以我们可以让每个线程每次搬运一定数量的任务,也就是说把所有迁移任务分成一个一个任务包,每个包里有特定数量的任务,每个线程负责搬运一个包,搬完了再看看还有没有需要帮忙继续搬运的。这样就能提高搬运效率。
作者使用了stride
这个概念,叫做步长,表示分配给一个线程一次搬运的任务数量。步长会根据CPU核数计算得出一个较优数值。除此之外,我们还需要一个全局调度者(总指挥)来指明每个线程要从哪开始搬运任务包,transferIndex
就相当于总指挥。
第一个发起数据迁移的线程会把 transferIndex 指向数组中最后的一个位置n
,从后往前的stride
个任务就是属于该线程的,然后将 transferIndex 指向新的位置n-stride
,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,因为一个线程在迁移完自己的任务后,还会继续检查是否有剩下的任务没迁移,如果有的话自己再进行下一次迁移。
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,相当于一个线程分配到 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果 nextTab 为 null,先进行一次初始化
// 前面我们说了,外围会保证第一个发起迁移的线程调用此方法时,参数 nextTab 为 null
// 之后参与迁移的线程调用此方法时,nextTab 不会为 null
if (nextTab == null) {
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 是 ConcurrentHashMap 中volatile修饰的属性
nextTable = nextTab;
// transferIndex 也是 ConcurrentHashMap 中volatile修饰的属性,用于控制迁移的位置
transferIndex = n;
}
int nextn = nextTab.length;
// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,
// 就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance 指的是做完了一个位置的迁移工作,可以准备做下一个位置的了
boolean advance = true;
// 所有迁移任务是否完成的标志,这里是指数组中全部的位置是否都被迁移完成
boolean finishing = false; // to ensure sweep before committing nextTab
/*
* 下面这个 for 循环,最难理解的在前面,而要看懂它们,应该先看懂后面的,然后再倒回来看
*
*/
// i 是位置索引,bound 是边界,注意是从后往前
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// advance 为 true 表示可以进行下一个位置的迁移了
// 简单理解:i 指向了 transferIndex,bound 指向了 transferIndex-stride
while (advance) {
int nextIndex, nextBound;
// 如果 i 还没超过迁移边界,则跳出,继续下面的迁移
// 如果已迁移完成,直接跳出
// 当 --i < bound 时说明此次的任务包已经搬运好了,如果 finishing 还是false,那么会继续搬运下一个任务包
if (--i >= bound || finishing)
advance = false;
// 将 transferIndex 值赋给 nextIndex
// 这里 transferIndex 一旦小于等于 0,说明原数组的所有位置都有相应的线程去处理了,也就是说不用再分配任务了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 分配当前线程此次迁移任务的边界 bound ~ i
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 看括号中的代码,nextBound 是这次迁移任务的边界,注意,是从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 当 i 等于在下列取值范围时,说明原数组中所有位置都迁移好了,但还需要再次检查,防止有的没迁移
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 所有的迁移操作已经完成
if (finishing) {
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 重新计算 sizeCtl:n 是原数组长度,所以 sizeCtl 得出的值将是新数组长度的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 在tryPresize说过,sizeCtl 在第一次发起迁移前会设置为 (rs << RESIZE_STAMP_SHIFT) + 2
// 然后,每有一个线程参与迁移就会将 sizeCtl 加 1,
// 这里使用 CAS 操作对 sizeCtl 进行减 1,代表做完了属于自己的任务
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 当前线程的任务结束,方法退出。(当前正在迁移的线程数量大于1个)
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 到这里,说明 (sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT,
// 此时说明只剩下一个迁移的线程,那么该线程会重新检查一遍数组
// 因为在迁移过程中,可能会有其他在null的位置插入了节点,所以要把新插的节点也迁移过去
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果位置 i 处是空的,没有任何节点,那么放入刚刚初始化的 ForwardingNode ”空节点“
// 注意这里CAS可能失败,失败后 advance 为false,会重新检查该位置
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 该位置处是一个 ForwardingNode,代表该位置已经迁移过了
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 对数组该位置处的结点加锁,开始处理数组该位置处的迁移工作
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 头结点的 hash 大于 0,说明是链表的 Node 节点
if (fh >= 0) {
// 下面这一块和 Java7 中的 ConcurrentHashMap 迁移是差不多的,
// 需要将链表一分为二,
// 找到原链表中的 lastRun,然后 lastRun 及其之后的节点是一起进行迁移的
// lastRun 之前的节点需要进行克隆,然后分到两个链表中
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;
}
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);
}
// 其中的一个链表放在新数组的位置 i
setTabAt(nextTab, i, ln);
// 另一个链表放在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕,继续迁移下一个
advance = true;
}
else if (f instanceof TreeBin) {
// 红黑树的迁移
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<K,V> p = new TreeNode<K,V>
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
// 如果一分为二后,节点数少于 8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<K,V>(hi) : t;
// 将 ln 放置在新数组的位置 i
setTabAt(nextTab, i, ln);
// 将 hn 放置在新数组的位置 i+n
setTabAt(nextTab, i + n, hn);
// 将原数组该位置处设置为 fwd,代表该位置已经处理完毕,
// 其他线程一旦看到该位置的 hash 值为 MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
// advance 设置为 true,代表该位置已经迁移完毕
advance = true;
}
}
}
}
}
}
transfer分析完了,总结一下主要步骤:
- 如果传入参数 nextTab 为null,那么会先初始化 nextTable 数组,长度为原长的2倍。
- 接着根据CPU核数计算线程的步长
stride
,即每个线程一次需要迁移的任务数量。更新transferIndex
到下一个迁移位置。 - 开始依次迁移每个位置的数据。
3.1 如果该位置为空,则放入ForwardingNode
节点
3.2 如果该位置不为空,当节点的hash等于MOVED,说明该位置已经迁移完毕,则跳过,进行下一个位置的迁移
3.3 否则说明该位置可迁移,使用synchronized
锁住头节点,如果头节点是链表节点,那么将该链表分成两部分,一部分迁移到新数组中的 原位置,另一部分迁移到 原位置+原数组长度 的位置。类似JDK7的ConcurrentHashMap迁移。如果是红黑树,那么会把红黑树一分为二,如果切开后的长度小于8,那么转换成链表再插入到新数组里。 - 该位置迁移完成后,在该位置放置
ForwardingNode
节点,用于说明该位置已经迁移完毕。 - 回到循环判断是否还需要迁移下一个任务包。
- 如果当前线程是最后一个在迁移的线程,那么该线程会从后往前重新扫描一遍原数组,看还有没有节点没迁移的。
get方法
get就灰常简单了。过程如下:
- 计算key的hash值
- 通过hash值得到key在数组中的位置
- 判断该位置是否为空,为空则直接返回
- 不为空的话判断头节点的hash值,大于0则说明是链表,那么会遍历链表寻找是否有key和hash都相同的节点,找到了就返回value。
- 如果头节点hash值小于0说明正在扩容或者该位置是红黑树,调用
e.find(h, key)
查找。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 判断头结点是否就是我们需要的节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果头结点的 hash 小于 0,说明 正在扩容,或者该位置是红黑树
else if (eh < 0)
// 参考 ForwardingNode.find(int h, Object k) 和 TreeBin.find(int h, Object k)
return (p = e.find(h, key)) != null ? p.val : null;
// 遍历链表
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
可以看到get操作是没有加锁的(除了红黑树操作),为什么可以这么做呢?因为链表节点Node的value和next属性都是用volatile修饰的,保证了可见性。而红黑树就需要加锁,因为树旋转时可能会改变根结点或者其链接。
计数
借鉴了分段锁的相关思想:将原本所有线程对一个变量进行的线程安全的更新操作,扩展为不同线程对多个不同的计数单元的线程安全的操作,以减少更新时的冲突。
实现
使用一个long型名为baseCount
变量和一个CounterCell
数组类型的名为counterCells的变量一起来记录size。counterCells是计数单元的数组,其大小始终为2的n次方倍,目的是使取余运算更加高效。每个桶中的位置存储的是CounterCell类型变量,记录了在桶中的线程需要增加的size的值。每个线程首先会CAS更新baseCount
,如果更新失败,再将值更新到对应的计数单元上。
每个线程都能生成一个随机数,然后用这个随机数当这个线程的哈希码,通过这个哈希码,就能把这个线程对应到counterCells数组中的一个位置,再把要更新的值更新到这个位置上的CounterCell的value值上面。最后计数总数时,再在baseCount基础上累加上CounterCell数组里的所有值就可以了。
cellsBusy
是一个用volatile修饰的变量,通过CAS原子更新的方式,充当了自旋锁的作用,当该值为0时,表示该锁可以使用,当该值为1时,表示某个线程获取了锁。
大致流程:
- 判断数组是否为空,为空的话 则尝试获取
CELLSBUSY
CAS锁来初始化数组。初始化成功则将值叠加到对应的计数单元中,完成之后返回;当初始化失败时,通过CAS尝试将值叠加到基础计数变量baseCount中,CAS失败则继续循环 - 如果数组存在,通过探针 hash 定位桶中的位置,如果桶中为空,通过
CELLSBUSY
CAS 锁新建节点并插入数组,如果成功,结束循环,如果失败(其他线程可能在 初始化 或者 扩容 或者 也在新建节点)转到第 5 步 - 如果定位到桶中有值,通过 CAS 修改,如果成功,结束,如果失败向下走
- 如果数组大小小于 CPU 核数,尝试获取
CELLSBUSY
锁来扩容数组,每次数组扩容时,其大小为先前大小的2倍,同时将旧计数单元数组的每个元素直接复制到新表中。如果获取锁失败,转第 5 步。 - 重新计算探针 hash。探针通过ThreadLocalRandom.getProbe()生成,它可以给线程生成一个随机数作为线程的唯一标志,并且这个方法对于同一个线程每次生成的值是一样的。使用了 ThreadLocalRandom,相当于每个线程都有一个 Random,都有自己的种子,这样就不会存在多线程竞争修改种子,提高并发时的效率。
ConcurrentHashMap为何采用该方式来实现size()方法?我能想到的一个原因是ConcurrentHashMap并不需要精确的节点数目的值,由于ConcurrentHashMap该数据结构是为并发而生的,为此,获取精确的节点数目的值本身意义并不大。当你消耗了性能,获取了此时此刻的节点数目的精确值,随后还是可能会被其他线程修改,导致上一刻的值无法使用,为此获取一个“大概”值便是一个较好的选择
总结
学习大佬Doug Lea写的源码真的能学到很多东西,也有很多让人大呼牛逼的部分。有点小时候拆解玩具观察内部构造的乐趣。
其他问题
- 在扩容的时候,可不可以对数组进行读写操作呢?
是可以的。当在进行数组扩容的时候,如果put要操作的位置为空,那么会CAS放入新节点,但此时可能会失败。如果当前位置还没被迁移,那么get操作可以直接读取该位置的值,如果当前位置已迁移完成的话,会调用e.find
方法查询。