看过Java并发编程艺术的绝对对ConcurrentHashMap不会感到陌生;但是由于书籍的出版,尤其经典书籍的出版都经历了漫长的岁月。。。因而很多东西都无法跟上时代(互联网行业)的快速发展:比如Hashtable(看到一个说法,Hashtbale被淘汰上因为没有遵循驼峰法)
吐槽结束。现在回到CMAP。本文乃作者心血,自己认真研读了源码一周,并结合源码参考了世面上对ConcurrentHashMap优秀的文章解读,即使文章写得不行,拉到底阅读参考文献一定让你对ConcurrentHashMap有一个深入的理解(配合源码实用最佳);写到put方法,回来先提醒一下看官,本文,不涉及红黑树分析。学习红黑树请点赞出门右转;
经历了JDK1.8,CMAP可谓鸟枪换炮。仅从代码量上就能看到,从1600行膨胀到6300行;整体逻辑大变换;1.7的底层实现采用的上Segments分段锁机制,,可以理解为两层结构,具体不再分析,可以参考《Java并发编程的艺术》;
而到了JDK1.8版本,Doug Lea 又将ConcurrentHashMap改回了一层结构,不再使用分段锁,改为使用CAS+synchronized的结构,近一步细分的锁的粒度;
我们知道,1.7版本的CMAP锁的数量与并发等级concurrencyLevel有关,初始化后就确定了;而1.8版本后,锁的数量即为桶的数量;随着你的扩容会导致桶的数量翻倍,扩容后支持的最大并发访问数也同时翻倍;
同时,1.8版本的HashMap都引进了红黑树,当发生严重的Hash冲突时(同一个桶下的链表长度超过8个),此时会自动将链表结构转化为红黑树,从而时间复杂度从O(N)降低到了O(logN),而当桶下的结点数少于6个时,又会将红黑树转为链表结构;
那既然红黑树这么好,为什么用链表(单押*2):这是因为采用红黑树,需要左旋右旋,增加数据的时间损耗较大;而链表的增加删除都非常便捷;因此选用了8和6作为临界;既兼顾性能,又避免频繁的结构转换;
JDK1.8版本的 ConcurrentHashMap有几个非常重要的函数:putVal(),addCount(),transfer()等;下面一一开始介绍:
目录
构造方法
Put方法
initTable
addCount
helpTransfer
transfer
get
remove
//创建一个空表,默认初始大小为16
public ConcurrentHashMap()
//创建一个空表,指定了默认大小
public ConcurrentHashMap(int initialCapacity)
//创建了一个表,并将复制参数中的所有表元素
public ConcurrentHashMap(Map extends K, ? extends V> m)
//创建一个表,指定了初始大小以及加载因子
public ConcurrentHashMap(int initialCapacity, float loadFactor)
//创建一个表,指定了初始大小,加载因子和并发等级(默认为1);
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel)
首先是构造方法,不指定的话则为创建一个空表,经过初始化后默认大小为16,加载因子为0.75,并发等级为1;
为什么说经过初始化后默认大小为16呢,因为无参的构造方法是一个空实现,当我们put元素之后才会进行初始化工作,构造一个表;
put方法非常重要。需要彻底理解,下面根据源码及注释一步步理解;
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
//不允许NULL的键和值,因为并发情况下无法分辨是不存在Key还是没有找到,
//以及Value本身就为空,所以不允许NULL的存在;HashMap可以判断,因为containsKey
//方法存在。而在多线程中,contains后去get,可能会发生修改或者删除,无法判断;
if (key == null || value == null) throw new NullPointerException();
//计算Hash值
int hash = spread(key.hashCode());
int binCount = 0;
//死循环, 可以CAS不断竞争,或者协助扩容后出来继续干活等等;
for (Node[] tab = table;;) {
Node f; int n, i, fh;
//如果未进行初始化,则初始化;接下来分析;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//如果目标桶位为空,则通过CAS插入;
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//当第一个元素的Hash值为MOVED(-1),代表为ForWardingNode,正在扩容
else if ((fh = f.hash) == MOVED)
//则该线程协助扩容;
tab = helpTransfer(tab, f);
else {
//无事发生,我们继续synchronized加锁后慢慢进行传统添加Node
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//fh>=0,代表我是链表
if (fh >= 0) {
binCount = 1;
for (Node 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 pred = e;
if ((e = e.next) == null) {
pred.next = new Node(hash, key,
value, null);
break;
}
}
}
//红黑树,本文不讲
else if (f instanceof TreeBin) {
Node p;
binCount = 2;
if ((p = ((TreeBin)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;
}
}
}
addCount(1L, binCount);
return null;
}
总体来说,put方法还是比较易于理解的。下面简单的梳理一下流程
经过总结,是不是put方法就很简单明了了呢。
当然,接下来我们就要深入探究,这些操作的具体原理,为什么能够帮助扩容?
But,我们还是先看看初始化吧
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node[] initTable() {
Node[] tab; int sc;
//当前表为空,我们尝试当老大,把表初始化(单押)
while ((tab = table) == null || tab.length == 0) {
//小于0,我们在竞争中失败了,睡一觉等着别人初始化,初始化时sizeCtl为-1;
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//好像还没人过来,我们赶紧通过CAS将sizeCtl置-1,慢慢初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//老子是第一个,开始初始化,完成后将sizeCtl设置为扩容阈值;
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node[] nt = (Node[])new Node,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
这个方法可真简单,就是多线程如果都想初始化,那就CAS竞争,谁将sizeCtl改为-1,就慢慢初始化,并将sizeCtl改为阈值(以待扩容)。失败的都休息,等扩容完成;
初始化完成,那我们接下来按照put流程,介绍一下addCount方法
addCount主要等作用有两个:
1、检测是否需要扩容;2、更新结点数量;
//添加计数,如果表太小而且尚未调整大小,则启动扩容。 如果已经调整大小,则在工作可用时帮助
//执行扩容。 在扩容后重新检查占用情况,看是否需要继续扩容。
//从 putVal 传入的参数是 1, binCount,binCount 是链表的长度/红黑树的结点数
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
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;
}
if (check <= 1)
return;
s = sumCount();
}
// 检查是否需要扩容
if (check >= 0) {
Node[] tab, nt; int n, sc;
//表的长度大于sizeCtl(阈值),且表不为空,表的长度小于最大值,则开始扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
int rs = resizeStamp(n);
//正在扩容
if (sc < 0) {
// sizeCtl 变化了
//扩容结束;第一个线程设置 sc ==rs 左移 16 位 + 2,当线程结束扩容了,就会将 sc 减一。这个时候,sc 就等于 rs + 1
//达到最大扩容线程数
//扩容结束,则nextTable为空
//任务已被全部分配
//这么多情况下,我不需要帮助扩容
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//如果可以帮助扩容,那么将 sc 加 1. 表示多了一个线程在帮助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//没有在扩容,由我开启扩容状态,标识符左移 16 位 + 2. 也就是变成一个负数。高 16 位是标识符,低 16 位初始是 2.
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
//统计元素数量
s = sumCount();
}
}
}
put,remove等情况下遇到扩容,如果当前当前线程遇到Forwarding结点,发现正在扩容,就会帮助扩容;如果没有发现扩容,那么仍然可以继续操作;
final Node[] helpTransfer(Node[] tab, Node f) {
Node[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
//类似addCount 不再赘述;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
//通过CAS操作获取扩容名额
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
讲解扩容前,必须先介绍一下sizeCtl属性,他有多种可能出现的值:
扩容方法,非常重要
private final void transfer(Node[] tab, Node[] nextTab) {
int n = tab.length, stride;
//算每条线程处理的桶个数,每条线程处理的桶数量一样;
//如果CPU为单核,则使用一条线程处理所有桶;毕竟可能出现帮助扩容,大家不能越界
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//还未指定下一个表,则新建目标表的大小;
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node[] nt = (Node[])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,他的Hash值为-1(MOVED)
//作用是告诉大家这个表正在扩容,快来帮忙;
//以及,查询的时候看到我,指向了下一个表,你去那里看看;
ForwardingNode fwd = new ForwardingNode(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//循环处理一个stride长度的任务,i后面会被赋值为该 stride 内最大的下标,而
//bound 后面会被赋值为该 stride 内左边界;通过循环不断减小i的值,从右往
//左依次迁移桶上面的数据,直到i小于bound时结束该次长度为 stride 的迁移任务
//结束这次的任务后会通过外层 addCount、helpTransfer、tryPresize 方法的
// while 循环达到继续领取其他任务或者没有未分配的任务区间就休息;
for (int i = 0, bound = 0;;) {
Node f; int fh;
while (advance) {
int nextIndex, nextBound;
//处理一个桶就i减1,进行
if (--i >= bound || finishing)
advance = false;
//transferIndex<=0证明任务分配完毕,i置-1,advance为false,后续根据这个退出扩容
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//首次进入for循环会进入该函数,设置任务区间
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//扩容结束,nextTable只有扩容时才不为null;将table指向新表,重新设置sizeCtl
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//每当一条线程扩容结束就会更新一次 sizeCtl 的值,进行减1操作,扩容中,sizeCtl表示有多少个线程
//正在扩容;
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//不是最晚一个干完活的,不用关灯
// 第一个扩容时候设置了U.compareAndSwapInt(this, SIZECTL, sc,
//(rs << RESIZE_STAMP_SHIFT) + 2)
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//最晚离开的,将i设置为n,再重新检查是不是所有的结点都完成转移了
finishing = advance = true;
i = n; // recheck before commit
}
}
//空桶,放fwd标识扩容状态;
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//已经放置了fwd,扩容了,检查下一个
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
// 加锁进行迁移;
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
Node ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node lastRun = f;
//解释一下lastRun,就是最后的连续N个相同的Node,我们需要将当前桶的元素根据
//前一位Hash值分到第i个桶和第i+n个桶上;那么lastRun代表的就是,最后连续的
//多个相同目标桶的的Node链表的第一个Node
for (Node p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//根据runBit,确定是放到第i个桶还是第i+n个;
//LastRun结点后直接迁移,是修改指针,lastRun作为头结点
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//除了LastRun结点,其他结点采用复制,倒序插入
//(倒序的原因是后插入的结点被访问的可能性更大)
for (Node p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
//next指向原来的ln,并ln引用指向自己,实现倒序;
ln = new Node(ph, pk, pv, ln);
else
hn = new Node(ph, pk, pv, hn);
}
//setTabAt方法调用的是 Unsafe 类的 putObjectVolatile 方法,将ln和hn挂到nextTable上;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
//给原table设置fwd,标识迁移完成;
setTabAt(tab, i, fwd);
advance = true;
}
//同上应该差不多,立个flag,等我学会了红黑树我就回来写
else if (f instanceof TreeBin) {
TreeBin t = (TreeBin)f;
TreeNode lo = null, loTail = null;
TreeNode hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode p = new TreeNode
(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(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
盗用参考文献3的一张图帮助大家理解LastRun:
扩容时注意一点,根据treeifyBin()方法:
如果Table长度小于64而某个桶内结点超过8个,此时不会进行红黑树转化,而是直接进行扩容(即使表内结点总数没有超过sizeCtl),直到结点数量少于8个或者Table长度大于64(如果仍大于8,则转为树,不再继续扩容)
//读无须加锁;不帮助扩容
public V get(Object key) {
Node[] tab; Node e, p; int n, eh; K ek;
//获取hash
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为-2,如果正在扩容,fwd结点,hash为-1;
else if (eh < 0)
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;
}
一个重点:读不需要加锁;同时,结合上述的扩容操作。我们知道扩容从后往前进行。如果目标桶已经扩容完成,桶位会设置一个fwd,指向新的table;这时候可以通过fwd去新表上查找;如果还未扩容到当前桶,我们继续查找不受影响;这一个思想可以同时扩容和查找;如果正在扩容,LastRun采用直接转移,LastRun之前的采用复制,原table不受影响。因此也可以同时查找;
public V remove(Object key) {
return replaceNode(key, null, null);
}
/**
* Implementation for the four public remove/replace methods:
* Replaces node value with v, conditional upon match of cv if
* non-null. If resulting value is null, delete.
*/
//当value为空,则删除,否则根据cv进行替换
final V replaceNode(Object key, V value, Object cv) {
int hash = spread(key.hashCode());
for (Node[] tab = table;;) {
Node f; int n, i, fh;
//表不存在 ,或者对应桶为空
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
//先帮助扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//加锁 删除
else {
V oldVal = null;
//判断是否被找
boolean validated = false;
synchronized (f) {
if (tabAt(tab, i) == f) {
//链表
if (fh >= 0) {
validated = true;
for (Node e = f, pred = null;;) {
K ek;
//找到目标结点
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
//cv为空,或者cv等于e.val,满足匹配条件
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
//value不为空,则替换
if (value != null)
e.val = value;
//value为空,删除,跳过当前结点
else if (pred != null)
pred.next = e.next;
else
//目标位桶的头结点,直接将桶指向下一元素结点
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
//红黑树
else if (f instanceof TreeBin) {
validated = true;
TreeBin t = (TreeBin)f;
TreeNode r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
//找到了对应的值,则删除,并addCount记录元素数量变化
if (validated) {
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
return null;
}
remove和repace都是基于replaceNode(Object key, V value, Object cv) 执行的;所以在其中进行了判断,替换可以选择旧值满足条件才进行替换;
扩容后倒序,LastRun及后续结点除外
LastRun结点迁移采用修改引用方法,其他结点重新创建一个结点(深拷贝)
扩容时,对桶对迁移也是从后往前进行迁移;未迁移到的桶可以正常get,put等操作;迁移时原表不受 影响,可以正常get,put,remove需要锁,不能同时进行;
SizeCtl很重要,根据不同情况下可以作不同标识,ForwardingNode表示已经迁移完毕,并为get指向新表,为put,remove作扩容标识协助扩容;
扩容完成后,最后一个线程需要重新检查是否有遗漏的桶未进行扩容;在扩容的时候每个线程都有处理的步长,和CPU核心有关,最少为16,在这个步长范围内的数组节点只有自己一个线程来处理
引入红黑树后,HashMap均采用尾插法
参考文献(排名不分先后):
1、并发编程——ConcurrentHashMap#addCount() 分析:https://www.jianshu.com/p/749d1b8db066
2、并发容器之ConcurrentHashMap(JDK 1.8版本):https://juejin.im/post/5aeeaba8f265da0b9d781d16
3、ConcurrentHashMap1.8 - 扩容详解:https://blog.csdn.net/ZOKEKAI/article/details/90051567
4、ConcurrentHashMap源码分析(1.8):https://www.cnblogs.com/zerotomax/p/8687425.html
如果您对本文有质疑,希望能够指出,欢迎友好探讨~