ConcurrentHashMap提高性能的关键点是多线程并发扩容.hash冲突会使得检索效率下降,所以扩容是一个减少冲突很好的方式.但是如果只有单线程进行迁移工作,其他读写线程都只能等待,效率就会比较低。concurrentHashMap同时存在2个成员变量table,nextTable.table存储当前数据,nextTable则是当发生扩容时搬运数据的目标容器.只要正在操作的slot没有与迁移的slot发生冲突,就可以最大限度提升扩容期间的并发读写。
下面解析一些核心的方法
public V get(Object key) {
Node<K, V>[] tab;
Node<K, V> e, p;
int n, eh;
K ek;
// 生成 hash 值
int h = spread(key.hashCode());
// 定位到目标entry
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 首先确定 hash 要相同 因为同一个slot 中 某些元素的hash可能不同
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
} else if (eh < 0)
// 从e 中寻找节点 负数代表为 TreeBin 或者正在移动中(MOVED) (1)
return (p = e.find(h, key)) != null ? p.val : null;
// hash不匹配 往下遍历链表
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
可以观察到get方法是一个无锁方法,如果此时有其他线程修改链表会发生什么?
从put的实现中可以看到采用的是尾插法
假设新插入的entry是b,会挂载到a上.
此时刚好访问到a
2种情况:
1)read()a -> put()b -> read()a.next 这时能读取到最新的b节点
2)read()a -> read()a.next -> put()b 这时无法读取到最新的b节点
以上两种情况在高并发下都可以接受
可以看到(1)处hash值为负数,当发现entry.hash为负数时代表当前slot正在迁移中,稍后再解释,我们先梳理完不迁移的所有逻辑(树节点也是负数,但是有关树的查找不是本文要研究的点)
public boolean containsValue(Object value) {
if (value == null)
throw new NullPointerException();
Node<K, V>[] t;
if ((t = table) != null) {
Traverser<K, V> it = new Traverser<K, V>(t, t.length, 0, t.length);
for (Node<K, V> p; (p = it.advance()) != null; ) {
V v;
if ((v = p.val) == value || (v != null && value.equals(v)))
return true;
}
}
return false;
}
Traverser该对象是ConcurrentHashMap的迭代器基类 从上面的代码描述大概可以知道使用方式,每当调用advance会返回一个node.
final Node<K, V> advance() {
Node<K, V> e;
// 当前已经找到了一个entry,直接返回next就可以
if ((e = next) != null)
e = e.next;
for (; ; ) {
Node<K, V>[] t;
int i, n; // must use locals in checks
// 找到了e直接返回
if (e != null)
return next = e;
// 此时已经超出了table的扫描范围 也就是遍历完了所有数据
if (baseIndex >= baseLimit || (t = tab) == null ||
(n = t.length) <= (i = index) || i < 0)
return next = null;
// hash < 0 代表此时entry是一个树结构 或者正在迁移中
// tabAt(t, i),i = index 这两步已经根据最新的index定位到了entry
if ((e = tabAt(t, i)) != null && e.hash < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K, V>) e).nextTable;
e = null;
pushState(t, i, n);
continue;
} else if (e instanceof TreeBin)
e = ((TreeBin<K, V>) e).first;
else
e = null;
}
if (stack != null)
recoverState(n);
// slot下标+1
else if ((index = i + baseSize) >= n)
index = ++baseIndex; // visit upper slots if present
}
}
可以看到也是一个无锁方法,与get基本一致,树或者迁移逻辑先放一边.
下面来看一下数据是如何插入的
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 处理hash值 使得更加分散
int hash = spread(key.hashCode());
int binCount = 0;
// 注意这里还没有加锁 利用table的volatile修饰词 可以确保在table扩容完成后可以感知到变化
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
// 如果容器还没有初始化 进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 初始化之后 如果对应slot 为null 直接设置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 尝试使用cas.直接设置成功就不需要后续处理 竞争失败可能是2种情况.刚好这个slot被标记成需要搬运数据,或者其他线程抢先插入了数据
if (casTabAt(tab, i, null,
new Node<K, V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 发现在table上对应的slot正在发生数据迁移 协助一起搬运
else if ((fh = f.hash) == MOVED)
// 搬运的逻辑之后看 这是提升性能的关键
tab = helpTransfer(tab, f);
else {
// 发生冲突 但是没有发生数据迁移 比较简单的情况
V oldVal = null;
// 因为要对链表进行操作,这里锁定头节点 (线程竞争的集中点)
synchronized (f) {
// double check
// 2种情况发生冲突:
// 1.当前节点正好在构建树
// 2.数据迁移完成后更新table 当锁定该节点后,发现table本身已经改变了 这时要尝试重新定位槽,并再次锁定头节点
// (从下面只有2个分支可以推断出 想要迁移某个slot 必然也是要获取头节点的锁 所以下面的处理再考虑hash = MOVED的情况了)
if (tabAt(tab, i) == f) {
// 代表是链表结构
if (fh >= 0) {
// binCount 相当于记录了链表的长度 如果超过8 会转换成红黑树
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;
// hashMap操作就不看了
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) {
// 需要做树化 注意这里在锁外
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 返回旧的值
if (oldVal != null)
return oldVal;
break;
}
}
}
// 这里主要是判断当前table内数据是否足够多,并进行扩容。
addCount(1L, binCount);
return null;
}
链表的树化
private final void treeifyBin(Node<K, V>[] tab, int index) {
Node<K, V> b;
int n, sc;
if (tab != null) {
// 代表尝试树化时首先要保证 数组大小超过了 64 否则 选择扩容替代 树化
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 进行扩容 默认变成2倍大小
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 继续锁定头节点 同样当发现table发生变化就是发生了扩容,选择放弃树化 因为扩容后同一个slot的深度变小了 就没有必要进行树化了
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K, V> hd = null, tl = null;
// 构建树的过程就不看了
for (Node<K, V> e = b; e != null; e = e.next) {
TreeNode<K, V> p =
new TreeNode<K, V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// 将头节点替换成一个特殊的node(TreeBin 代表本entry下是一个树结构) 初始化的同时还会进行树的平衡
setTabAt(tab, index, new TreeBin<K, V>(hd));
}
}
}
}
}
尝试进行扩容
private final void tryPresize(int size) {
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
// 负数代表其他线程正在修改table
while ((sc = sizeCtl) >= 0) {
Node<K, V>[] tab = table;
int n;
// 如果数组还没有初始化 在这里逻辑 跟init 一样
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);
}
} finally {
sizeCtl = sc;
}
}
}
// 已经扩容到足够的大小了
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
// 这里才是正常的扩容逻辑
else if (tab == table) {
// 此时n等同于table的原大小
// 返回的是一个特殊的标记位 低14位代表当前table.size转换成二进制后左边有多少个0
int rs = resizeStamp(n);
// TODO 这个分支怎么进入的 进入while的前提应该是sc >= 0
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);
}
// 将SIZECTL修改成一个负数 同时包含一些特殊信息 比如原大小
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
上面已经介绍了map的插入以及查询,是没有考虑到正在数据迁移的情况的,先看看数据迁移是怎么实现的
/**
* Moves and/or copies the nodes in each bin to new table. See
* above for explanation.
*
* @param tab source容器
* @param nextTab 目标容器 如果传入null 就代表要更新容器
*/
private final void transfer(Node<K, V>[] tab, Node<K, V>[] nextTab) {
int n = tab.length, stride;
// 这里开始分段 线程以段为单位进行数据搬运
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 代表需要更新nextTable容器 默认情况下扩容成2倍大小
// 只有一条线程会以执行nextTable的更新 其余协助搬运的线程传入的nextTable不为空
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
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;
}
int nextn = nextTab.length;
// forwardingNode 就是标记当前slot正在进行数据迁移 这个节点可以在所有slot中共用
ForwardingNode<K, V> fwd = new ForwardingNode<K, V>(nextTab);
// 代表是否需要更新待搬运的slot范围
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 到这里先缓一下 我们来推测一下接下来要做什么 想要完全理解concurrentHashMap快在哪要准确分析所有的线程冲突点
// 旧的table通过检测ForwardingNode 发现slot需要扩容 并参与到扩容工作上,那么现在应该要将ForwardingNode逐步设置到旧table的所有slot中
// 每个线程只负责搬运一部分slot 那么分界线是如何划分的 交界点的并发控制又是怎么做的(如果某个线程搬运完自己的部分后,发现还有剩余的slot肯定会继续搬运的)
for (int i = 0, bound = 0; ; ) {
Node<K, V> f;
int fh;
// 需要更新带搬运的slot范围
while (advance) {
// nextBound 代表本轮该线程搬运的slot对应的下标下限
// nextIndex 代表搬运的slot对应的下标上限 也就是从哪里开始搬运
int nextIndex, nextBound;
// --i >= bound 代表上一个slot搬运完毕 往前走一格
// finishing 代表已经完成数据扩容了 不需要更新slot范围 所以advance为false
if (--i >= bound || finishing)
advance = false;
// 代表已经没有空闲的slot可以分配了
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
// cas修改 transferIndex 就可以为多个线程并发搬运做分割了 每个成功修改的线程负责为对应的slot进行数据迁移
} else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
// 如果此时待搬运的块 超过了每个线程分工的slot数量 减少对应的部分 如果不足一个stride 剩余的都由该线程搬运
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
// 确定好了要搬运的范围 也就是 nextBound~nextIndex
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 此时本次范围内要搬运的所有slot都已经搬运完成
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 本次所有数据搬运完成 扩容结束
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// sc此时携带了参与数据迁移的线程数的信息 这里代表减少一条线程
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 代表还有线程正在进行数据迁移
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 代表这是最后一条迁移数据的线程 此时迁移任务完成
finishing = advance = true;
i = n; // recheck before commit
}
// 这是最简单的情况 当前要搬运的slot下没有任何entry 但是要设置一个占位符 这样其他线程就会协助扩容
// 修改成功返回true 代表本slot不需要再搬运了 更新i
} else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 发现当前slot已经由其他线程负责搬运了 更新i
// 不过老实说 既然已经cas更新transferIndex了 线程间应该不会在搬运过程中发生slot的冲突 但是不影响理解 只要继续搬运下一个slot就可以
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
// 最普遍的情况 该slot下有数据需要搬运
else {
// 这里也锁定了header节点 就与putValue 以及树化产生了锁竞争 顺便再声明一次当某个slot尝试树化时发现已经在搬运数据了 就会停止树化
// 但是当竞争到锁的时候头节点可能已经变成了一个TreeBinNode
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K, V> ln, hn;
// 代表此时还是链表结构
if (fh >= 0) {
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;
// 代表出现了某个节点hash跟上个节点hash不一致的情况
if (b != runBit) {
runBit = b;
// lastRun 就是上个hash不同的节点
lastRun = p;
}
}
// 根据 hash & n 的结果划分不同的情况
// 注意 ln(hn) 后面可能还有一些节点
if (runBit == 0) {
ln = lastRun;
hn = null;
} else {
hn = lastRun;
ln = null;
}
// 只需要遍历到lastRun之前的节点 因为后面的链表还没有打散
for (Node<K, V> p = f; p != lastRun; p = p.next) {
int ph = p.hash;
K pk = p.key;
V pv = p.val;
// 根据hash是否刚好是n的倍数 拆分成2个小链表
if ((ph & n) == 0)
ln = new Node<K, V>(ph, pk, pv, ln);
else
hn = new Node<K, V>(ph, pk, pv, hn);
}
// 将2个小链表插入到新的table中
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 因为整个扩容动作还没有完成 将该slot标记成扩容中 这样新的put线程发现slot对应的标记就会协助扩容
// 这里有一个隐含的好处 就是在扩容中,如果旧的table对应的slot又插入了新的节点,之前针对该slot的移动就白费了.
// 梳理下来是这种情况 当整个table触发扩容时,往某个还未开始转移的slot插入数据是可行的,但是一旦当转移的线程抢占到这个slot后,打上标记,并在整个扩容完成前都不允许再操作这个slot了
// 而hash冲突的线程也没有让它干等着 而是再看到move标记后协助数据迁移
setTabAt(tab, i, fwd);
// 继续转移下个slot
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;
}
}
// 当小于 某个阈值后要退化为链表结构 因为已经在锁内了, 剩下的变形操作不是很重要先不看了
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;
}
}
}
}
}
}
从上面的代码可以总结出,每当有一条线程参与数据迁移,会修改SIZECTL的值, 当所有数据完成迁移后,使用新table替换旧table,因为table使用volatile修饰,所以能观测到变化.其他线程在帮助数据迁移完成后会不断的自旋,等待table更新
来看看其他线程发现MOVE标记时如何处理
final Node<K, V>[] helpTransfer(Node<K, V>[] tab, Node<K, V> f) {
Node<K, V>[] nextTab;
int sc;
// 迁移的头节点中维护了新的数组对象
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K, V>) f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
// 当发生扩容时,sizeCtl是一个特殊的负数,而初始化table时对应-1
(sc = sizeCtl) < 0) {
// 代表此时已经没有slot可分配给该线程了
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 代表此时正在迁移数据的线程数+1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
在完成了最重要的数据迁移逻辑的解析后,其他一些方法的实现大同小异,先通过hash值找到目标slot,如果发现被标记为MOVE,就参与数据迁移.如果是正常节点就锁定头节点.之后就是一些正常的插入,替换,删除操作.
现在回过头来看get()请求在发现hash为负数时怎么处理 入口在entry.find()方法 (发现TreeNode的find方法有一个检查锁的步骤,原因是红黑树在平衡时需要加锁. 这里将通过cas修改volatile int lockState 属性实现并发控制.不展开了)
那么如果此时get对应的slot正好在进行数据迁移该怎么处理
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;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (; ; ) {
int eh;
K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
// 数据迁移是不会发生嵌套的,这种情况不可能发生.
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K, V>) e).nextTable;
continue outer;
// 从树节点中读取
} else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
slot头节点的操作顺序在扩容时的操作顺序是,先锁定头节点,完成数据迁移,修改成MOVE标识.所以当调用get并发现MOVE标记时,代表有关这个slot的数据迁移已经完成,直接去扩容的目标容器查询数据就可以了. 不过这里有一个问题:迁移完成的瞬间nextTable会被置空,这个时候如果刚好触发find不就没办法获取到数据了吗?
下面回到这个方法 看一下当发现某个entry正在迁移时会怎么处理
final Node<K, V> advance() {
Node<K, V> e;
// next != null 代表当前entry是一个链表结构每次只要返回下一个节点就可以
if ((e = next) != null)
e = e.next;
for (; ; ) {
Node<K, V>[] t;
int i, n; // must use locals in checks
// 找到了e直接返回
if (e != null)
return next = e;
// 此时已经超出了table的扫描范围 也就是遍历完了所有数据
if (baseIndex >= baseLimit || (t = tab) == null ||
(n = t.length) <= (i = index) || i < 0)
return next = null;
// hash < 0 代表此时entry是一个树结构 或者正在迁移中
// tabAt(t, i),i = index 这两步已经根据最新的index定位到了entry
if ((e = tabAt(t, i)) != null && e.hash < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K, V>) e).nextTable;
e = null;
// 将之前的table,index,table.length暂时存起来
pushState(t, i, n);
continue;
} else if (e instanceof TreeBin)
e = ((TreeBin<K, V>) e).first;
else
e = null;
}
if (stack != null)
// 利用stack还原之前存储的属性
recoverState(n);
// 正常的逻辑就是每次下标+1
else if ((index = i + baseSize) >= n)
index = ++baseIndex; // visit upper slots if present
}
}
当发现当前entry是一个迁移中的entry,先将此时的table,index,n存储到一个stack结构中.并且因为tab得到了更新,下一轮t发生了变化,就可以读取到index在迁移数组所对应的slot了.此时通过 e=tabAt(t,i)获取到entry后.因为stack被赋值.执行recoveryState还原了table以及相关指针.然后再下一次循环中利用 if (e != null) return next = e; 遍历迁移后的数据.
看看pushState/recoveryState分别做了什么
private void pushState(Node<K, V>[] t, int i, int n) {
// 当pushState 在执行recoveryState前发生冲入时 会更新spare 但是一次get()请求只对应一个Traverser对象应该是不会重入的
TableStack<K, V> s = spare; // reuse if possible
if (s != null)
spare = s.next;
else
s = new TableStack<K, V>();
// 将当前标识信息暂存到这个结构中
s.tab = t;
s.length = n;
s.index = i;
s.next = stack;
stack = s;
}
private void recoverState(int n) {
TableStack<K, V> s;
int len;
while ((s = stack) != null && (index += (len = s.length)) >= n) {
n = len;
// 将stack的数据还原到当前对象
index = s.index;
tab = s.tab;
s.tab = null;
TableStack<K, V> next = s.next;
s.next = spare; // save for reuse
stack = next;
spare = s;
}
// 最终还是进入这个分支,只是将index加1
if (s == null && (index += baseSize) >= n)
index = ++baseIndex;
}
其他遍历方法都是以这个类作为模板.