ConcurrentHashMap胡思乱想
ConcurrentHashMap是1.8提供的一个线程安全的HashMap,其特点是:
- 有效利用自旋+CAS+synchronized的方式为不同粒度的并发操作提供同步保障;
- 使用多线程协作的方式对扩充这个耗时操作提供高效支持。
正是上述两个优化使得相较于前面的版本,有了很大的效率提升。
putVal
对于该方法流程如下:
- 如果table为null,或者table.length为0,则执行initTable初始化;
- 如果对应下标的桶为null,则执行插入,注意,执行插入时采用CAS操作,在并发情况下,可能CAS插入失败,因此,CAS需要搭配自旋使用,所以可以理解putVal方法内部是一个很大的for循环;
- 如果对应下标的同正在执行迁移,则参与到迁移工作中来;
- 否则,在对应下标的桶上执行尾部插入,而该行为使用针对桶的头结点施加synchronized关键字的方式实现;
- 执行addCount修改记录的桶的个数。
到此,产生问题:Q为何插入新的桶节点时采用CAS+自旋的方式,而往桶中进行尾插入时就是用synchronized的方式?
首先,在插入新的桶节点时,由于此时下标index指示的桶节点为null,无法使用synchronized关键字进行加锁;如果想要使用synchronized关键字,则只能定义一个全局锁Lock对该全局锁进行加锁,那么这种方式就会扩大加锁范围,造成对不同位置插入桶的操作之间造成并发问题,违反了设计初衷;
那么,对桶进行尾部节点插入时,为啥不能使用CAS+自旋的方式呢?不可以!!这样会造成错误结果,比如当前桶的链表如下:1->2->3->4->5,而两个线程并发删除3和4,那么进行CAS时进行分别进行如下两个操作CAS(2.next, 3, 3.next),CAS(3.next, 4, 4.next)很显然,由于操控的是不同的节点,这两个CAS操作不会发生冲突,可以并行执行,假如第一个CAS先执行完,那么桶变为了1->2->4->5,此时第二个CAS执行完后桶的状态依然是1->2->4->5。原因在于,虽然不同节点的CAS操作本身没有问题,但是他们之间可能产生依赖性,就像上面的实例所示,只有第二个CAS先于第一个CAS执行是正确的结果;而解决代码之间的依赖性,synchronized就顺理成章的用上了。
方法如下:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 不允许插入空值或空键
// 允许value空值会导致get方法返回null时有两种情况:
// 1. 找不到对应的key2. 找到了但是value为null;
// 当get方法返回null时无法判断是哪种情况,在并发环境下containsKey方法已不再可靠,
// 需要返回null来表示查询不到数据。允许key空值需要额外的逻辑处理,占用了数组空间,且并没有多大的实用价值。
// HashMap支持键和值为null,但基于以上原因,ConcurrentHashMap是不支持空键值。
if (key == null || value == null) throw new NullPointerException();
// 高低位异或扰动hashcode,和HashMap类似
// 但有一点点不同,后面会讲,这里可以简单认为一样的就可以
int hash = spread(key.hashCode());
// bincount表示链表的节点数
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();
// 情况二:目标下标对象为null
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 重点:采用CAS进行插入
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
break;
}
// 情况三:数组正在扩容,帮忙迁移数据到新的数组
// 同时会新数组,下次循环就是插入到新的数组
// 关于扩容的内容后面再讲,这里理解为正在扩容即可
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 情况四:直接对节点进行加锁,插入数据
// 下面代码很多,但逻辑和HashMap插入数据大同小异
// 因为已经上锁,不涉及并发安全设计
else {
V oldVal = null;
// 同步加锁
synchronized (f) {
// 重复检查一下刚刚获取的对象有没有发生变化
if (tabAt(tab, i) == f) {
// 链表处理情况
if (fh >= 0) {
binCount = 1;
// 循环链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 找到相同的则记录旧值
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;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
// 判断是否需要转化为红黑树,和返回旧数值
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 总数+1;这是一个非常硬核的设计
// 这是ConcurrentHashMap设计中的一个重点,后面我们详细说
addCount(1L, binCount);
return null;
}
// 这个方法和HashMap
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
initTable
该方法负责对table进行初始化,而该方法也很清晰明了,就是采用CAS+自旋的方式竞争是对table进行初始化,还是等待;而CAS修改的只是一个int类型的标志,能够成功修改为特定的某个值则说明获取到初始化权限,而其他线程只能让出时间片等待table初始化完成。因此,该方法就是一个大的while循环,循环体针为一个针对CAS结果的if分支:
- 成功CAS则进行table初始化;
- 否则执行Thread.yield让出时间片。
这里对CAS失败的线程为何要让出时间片,而不是return呢?原因为:initTable方法发生在第一次putVal时,在并发场景下,紧接着的、并发的putVal方法都需要依赖于initTable创建出的table数组,因此,CAS竞争失败的线程也不能够直接退出,需要等待table的初始化完成。这又是一个依赖性的问题!!
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 这里的循环是采用自旋的方式而不是上锁来初始化
// 首先会判断数组是否为null或长度为0
// 没有在构造函数中进行初始化,主要是涉及到懒加载的问题
while ((tab = table) == null || tab.length == 0) {
// sizeCtl是一个非常关键的变量;
// 默认为0,-1表示正在初始化,<-1表示有多少个线程正在帮助扩容,>0表示阈值
if ((sc = sizeCtl) < 0)
Thread.yield(); // 让出cpu执行时间
// 通过CAS设置sc为-1,表示获得自选锁
// 其他线程则无法进入初始化,进行自选等待
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 重复检查是否为空
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 设置sc为阈值,n>>>2表示1/4*n,也就相当于0.75n
sc = n - (n >>> 2);
}
} finally {
// 把sc赋值给sizeCtl
sizeCtl = sc;
}
break;
}
}
// 最后返回tab数组
return tab;
}
transfer
该方法负责进行桶节点的迁移,transfer方法实现中大体是两个循环嵌套:
- for循环负责控制筒下标i,每次将i位置的同进行迁移;
- while循环其实是一个CAS自旋操作,其作用有两个:当前线程第一次执行transfer方法时使用CAS搭配while循环来设置线程负责迁移的范围,也即初始化i和bound两个索引变量;之后while循环负责移动i来实现对负责范围内所有筒的遍历。
同样,该方法中修改各个状态等操作、将空节点设置为ForwardingNode等操作都是使用CAS自旋,而迁移整个筒采用的是synchronized对筒节点加锁的方式。这也提一下,对于加了锁的同步代码内部,不需要CAS了。
同样,每一次for循环中除了经历while循环维护筒下标i之外,还要经历如下处理过程:
- 根据i和bound判断本次任务是否完成,如果完成则需要CAS修改ConcurrentHashMap的全局状态记录sizeCtl;
- 如果下标i位置的筒为null,那么直接将其标记为ForwardingNode;
- 如果下标i位置的筒为ForwardingNode,那么advance设置为true,则在下一次for循环中会开启while循环来将i向前移动一个位置;
- 否则,对下标i位置的筒进行迁移操作;这个操作会对筒的头结点使用synchronized关键字加锁,迁移操作同HashMap一样;迁移完毕后会将下标i位置的筒节点设置为ForwardingNode;并将advance设置为true,则在下一次for循环中会开启while循环来将i向前移动一个位置。
正是transfer方法将HashMap中最耗时的迁移操作使用多线程来完成,使得并发访问ConcurrentHashMap中的成员的时候,遇到transfer时不是阻塞住,而是helpTransfer,这种多线程共同完成同一个任务的方案,大大提高了ConcurrentHashMap的效率。
// 这里的两个参数:tab表示旧数组,nextTab表示新数组
// 创建新数组的线程nextTab==null,其他的线程nextTab等于第一个线程创建的数组
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// stride表示每次前进的步幅,最低是16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果新的数组还未创建,则创建新数组
// 只有一个线程能进行创建数组
if (nextTab == null) {
try {
@SuppressWarnings("unchecked")
// 扩展为原数组的两倍
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) {
// 扩容失败出现OOM,直接把阈值改成最大值
sizeCtl = Integer.MAX_VALUE;
return;
}
// 更改concurrentHashMap的内部变量nextTable
nextTable = nextTab;
// 迁移的起始值为数组长度
transferIndex = n;
}
int nextn = nextTab.length;
// 标志节点,每个迁移完成的数组下标都会设置为这个节点
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// advance表示当前线程是否要前进
// finish表示迁移是否结束
// 官方的注释表示在赋值为true之前,必须再重新扫描一次确保迁移完成,后面会讲到
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// i表示当前线程迁移数据的下标,bound表示下限,从后往前迁移
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 这个循环主要是判断是否需要前进,如果需要则CAS更改下个bound和i
while (advance) {
int nextIndex, nextBound;
// 如果还未到达下限或者已经结束了,advance=false
if (--i >= bound || finishing)
advance = false;
// 每一轮循环更新transferIndex的下标
// 如果下一个下标是0,表示已经无需继续前进
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 利用CAS更改bound和i继续前进迁移数据
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// i已经达到边界,说明当前线程的任务已经完成,无需继续前进
// 如果是第一个线程需要更新table引用
// 协助的线程需要将sizeCtl减一再退出
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 如果已经更新完成,则更新table引用
if (finishing) {
nextTable = null;
table = nextTab;
// 同时更新sizeCtl为阈值
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 线程完成自己的迁移任务,将sizeCtl减一
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 这里sc-2不等于校验码,说明此线程不是最后一个线程,还有其他线程正在扩容
// 那么就直接返回,他任务已经完成了
// 最后一个线程需要重新把整个数组再扫描一次,看看有没有遗留的
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// finish设置为true表示已经完成
// 这里把i设置为n,重新把整个数组扫描一次
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果当前节点为null,表示迁移完成,设置为标志节点
else if ((f = tabAt(tab, i)) == null)
// 这里的设置有可能会失败,所以不能直接设置advance为true,需要再循环
advance = casTabAt(tab, i, null, fwd);
// 当前节点是ForwardingNode,表示迁移完成,继续前进
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 给头节点加锁,进行迁移
// 加锁后下面的内容就不涉及并发控制细节了,就是纯粹的数据迁移
// 思路和HashMap差不多,但也有一些不同,多了一个lastRun
// 读者可以阅读一下下面源码,这部分比较容易理解
synchronized (f) {
// 上锁之后再判断一次看该节点是否还是原来那个节点
// 如果不是则重新循环
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// hash值大于等于0表示该节点是普通链表节点
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
// ConcurrentHashMap并不是直接把整个链表分为两个
// 而是先把尾部迁移到相同位置的一段先拿出来
// 例如该节点迁移后的位置可能为 1或5 ,而链表的情况是:
// 1 -> 5 -> 1 -> 5 -> 5 -> 5
// 那么concurrentHashMap会先把最后的三个5拿出来,lastRun指针指向倒数第三个5
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;
// 这个node节点是改造过的
// 相当于使用头插法插入到链表中
// 这里的头插法不须担心链表环,因为已经加锁了
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;
}
// 树节点的处理,和链表思路相同,不过他没有lastRun,直接分为两个链表,采用尾插法
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;
}
}
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;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
addCount
在HashMap中还需要维护成员的个数,而在并发场景下,该值的维护也是使用多线程维护的,核心方法是addCount方法。addCount方法也不是一上来就进行复杂的操作来维护size,在addCount方法中首先会进行一些简单的尝试,然后再调用fullAddCount方法保证count能够被正确维护;在addCount方法中进行的尝试为:
- 尝试使用一次CAS直接修改baseCount值;
- 根据hash生成的index索引获取counterCells中对应位置的对象,尝试使用一次CAS修改该对象的值。
当上面两个方法都没有成功,或者CounterCells为null时,会调用fullAddCount方法保证count能够被正确维护。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// 如果数组不为空 或者 数组为空且直接更新basecount失败
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
// 表示没发生竞争
boolean uncontended = true;
// 这里有以下情况会进入fullAddCount方法:
// 1. 数组为null且直接修改basecount失败
// 2. hash后的数组下标CounterCell对象为null
// 3. CAS修改CounterCell对象失败
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);
return;
}
// 如果长度<=1不需要扩容(说实话我觉得这里有点奇怪)
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
// 扩容相关逻辑,下面再讲
}
}
fullAddCount方法内部使用了复杂的机制来维护CounterCells数组以及baseCount。而fullAddCount方法很长,修改CounterCell本身是一件很容易的事情,因此,采用CAS+自旋的方式完成,因此,该方法整体上就是一个大的for循环,里面根据情况进行操作,发生冲突后就进入下一个循环,直到成功:
- 如果CounterCells数组为null,那么就获取唯一的独占锁,并初始化数组;而在当前数组还没初始化完成的情况下,同putVal一样,其他的任何操作依赖于这个CounterCells数组,因此,并发失败的线程也不会闲着,当然也没有使用Thread.yield让出时间片,而是尝试使用CAS直接修改baseCount,如果真的修改成功了,就直接return了;
- 如果CounterCells数组不为null
- 如果当前线程获取下标索引后指示的CounterCell对象是null,那么就尝试获取唯一独占锁,成功后创建CounterCell对象;
- 否则,尝试通过CAS修改下表索引指示的CounterCell对象的值;
- 都不成功,则需要对CounterCells数组进行扩容。
值得注意的是,在fullAddCount方法中的全局唯一的独占锁,其实就是一个int标志,并发环境下通过CAS对该变量进行操作,操作失败则伴随着for自旋再次竞争。
有个疑问:addCount这种机制虽然也是为不同的线程维护CounterCell来共同维护count,但是这种复杂的方式是否过于追求并发带来的那一点效率的提升?直接大家都使用CAS自旋来修改baseCount不行吗?
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
// 如果当前线程随机数为0,强制初始化一个线程随机数
// 这个随机数的作用就类似于hashcode,不过他不需要被查找
// 下面每次循环都重新获取一个随机数,不会让线程都堵在同一个地方
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
h = ThreadLocalRandom.getProbe();
// wasUncontended表示没有竞争
// 如果为false表示之前CAS修改CounterCell失败,需要重新获取线程随机数
wasUncontended = true;
}
// 直译为碰撞,如果他为true,则表示需要进行扩容
boolean collide = false;
// 下面分为三种大的情况:
// 1. 数组不为null,对应的子情况为CAS更新CounterCell失败或者countCell对象为null
// 2. 数组为null,表示之前CAS更新baseCount失败,需要初始化数组
// 3. 第二步获取不到锁,再次尝试CAS更新baseCount
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
// 第一种情况:数组不为null
if ((as = counterCells) != null && (n = as.length) > 0) {
// 对应下标的CounterCell为null的情况
if ((a = as[(n - 1) & h]) == null) {
// 判断当前锁是否被占用
// cellsBusy是一个自旋锁,0表示没被占用
if (cellsBusy == 0) {
// 创建CounterCell对象
CounterCell r = new CounterCell(x);
// 尝试获取锁来添加一个新的CounterCell对象
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try {
CounterCell[] rs; int m, j;
// recheck一次是否为null
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
// created=true表示创建成功
created = true;
}
} finally {
// 释放锁
cellsBusy = 0;
}
// 创建成功也就是+1成功,直接返回
if (created)
break;
// 拿到锁后发现已经有别的线程插入数据了
// 继续循环,重来一次
continue;
}
}
// 到达这里说明想创建一个对象,但是锁被占用
collide = false;
}
// 之前直接CAS改变CounterCell失败,重新获取线程随机数,再循环一次
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// 尝试对CounterCell进行CAS
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
// 如果发生过扩容或者长度已经达到虚拟机最大可以核心数,直接认为无碰撞
// 因为已经无法再扩容了
// 所以并发线程数的理论最高值就是NCPU
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
// 如果上面都是false,说明发生了冲突,需要进行扩容
else if (!collide)
collide = true;
// 获取自旋锁,并进行扩容
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
// 扩大数组为原来的2倍
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
counterCells = rs;
}
} finally {
// 释放锁
cellsBusy = 0;
}
collide = false;
// 继续循环
continue;
}
// 这一步是重新hash,找下一个CounterCell对象
// 上面每一步失败都会来到这里获取一个新的随机数
h = ThreadLocalRandom.advanceProbe(h);
}
// 第二种情况:数组为null,尝试获取锁来初始化数组
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try {
// recheck判断数组是否为null
if (counterCells == as) {
// 初始化数组
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
// 释放锁
cellsBusy = 0;
}
// 如果初始化完成,直接跳出循环,
// 因为初始化过程中也包括了新建CounterCell对象
if (init)
break;
}
// 第三种情况:数组为null,但是拿不到锁,意味着别的线程在新建数组,尝试直接更新baseCount
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
// 更新成功直接返回
break;
}
}