原文有参考:
zhuanlan.zhihu.com/p/157109840
秋招面试的时候,面试官很喜欢问"你来讲讲 concurrentHashMap
" 吧,我每次回答这个问题的时候,都有点含糊,因为都懂一点,但是没深入了解,加上没有整理,所以就回答的个人认为不是很好,所以我想借此来梳理一下这个问题的回答,保证下次面试官问到的时候我可以说上十分钟。(注:不会面面俱到,只会写我想在面试官展示的内容,原理可以自行百度。)
说到 concurrentHashMap
,个人觉得必须要回答到四个模块的内容:put()
流程、get()
流程、扩容流程、以及并发思想。(该片文章是我参考上面三个大佬写的文章然后进行总结的,当然我也是参考了 JDK1.8 的源码,我不希望你一字一句看我的文章,而是希望你有这样的思考,面对 concurrentHashMap
这个并发集合的时候该如何回答,你可以参考我的各个标题以及总结那段文字即可,其他可以不看,建议看上面原文。)
put() 流程
总结流程如下:
- 集合还未初始化:第一个抢到
CPU
资源的线程执行initTable()
方法,使用CAS
操作将sizeCtl
标记位成功设置为 -1的线程就是进行初始化哈希表数组的线程。这里的操作并不是很麻烦,总的来说,就是将被volatile
修饰的table
进行赋值给 内部的tab
变量即可; - 定位到的目标位置在数组上,并且该位置的值为
null
:为了避免线程安全问题,使用CAS
方式将元素直接设置在该数组位置上; - 定位到的目标位置在数组上,并且该位置的值为
FWD
节点:说明此时集合在扩容中,并且当前定位到的节点的hash
桶已经迁移完毕,此时执行put
操作的线程会优先加入到扩容操作,加速扩容的速度,待扩容完成之后再继续循环插入新元素; - 定位到的目标位置在数组上,并且该位置已经有其他值:先锁住位于数组位上的头节点,如果节点类型是普通节点,使用尾插法在末尾拼接新的节点;如果节点类型是
TreeBin
节点,调用TreeBin
的putTreeVal
方法。putTreeVal
方法具体做法为,如果目标位置为null
,则直接添加进去元素,如果目标位置已经有值,则返回旧值,根据onlyIfAbsent
属性决定是否覆盖该红黑树上面的旧值 。
get() 流程
源码如下,并做了一些注释。
总体流程就是如下:
- 根据传入的
key
计算其hash
值,并且判断哈希桶是否已经被初始化(下面的流程都是哈希桶初始化后的) - 如果要找的元素就是在桶中,那么就直接返回
key
对应的value
即可 - 如果
eh
(算是一种标志位) 小于 0,两种情况:哈希表在扩容或者是此节点已经是红黑树节点,有其专门的遍历节点的方法find()
- 否则,直接按照链表节点进行遍历即可
java
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
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 的情况有两种:
// (1)该节点是 TreeBin 节点(红黑树代理节点,hash值固定为-2)
// (2)该集合正在扩容,并且该 hash 桶已迁移完毕,该位置被放置了FWD节点
// 1. 如果是 TreeBin 节点,调用 TreeBin 类的 find 方法,具体是以链表方式遍历还是被红黑树遍历的方式看情况而定;
// 2. 如果是正在扩容,则跳转到扩容后的新数组上查找,TreeBin 和 Node 节点都有对应的 find 方法
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 如果前面两种情况都不满足,说明该hash桶上面连接的是普通链表结构,使用while循环进行遍历即可
while ((e = e.next) != null) {
if (e.hash == h &&((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
}
扩容流程
什么时候会触发扩容?
- 新增元素 addCount() 方法后,执行 putVal 方法之后,增加了check入参,用于指定是否可能会出现扩容的情况
- 扩容状态下其他线程对集合进行插入、修改、删除、合并、compute等操作时遇到 FWD 节点,其他线程会帮助扩容
- putAll 批量插入或者插入节点后发现链表长度达到8个或以上,但数组长度为64以下时触发的扩容
java
public class Map {
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 计算每条线程处理的桶个数:每条线程至少处理16个桶,如果计算出来的结果少于16,则一条线程处理16个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 初始化新数组,长度为原数组的2倍(n << 1)
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 指向最右边的桶,也就是数组索引下标最大的位置
transferIndex = n;
}
int nextn = nextTab.length;
// 新建一个占位对象,该占位对象的 hash 值为 -1 该占位对象存在时表示集合正在扩容状态,key、value、next 属性均为 null ,nextTable 属性指向扩容后的数组
// 占位对象两个作用:1. 占位作用,用于标识数组该位置的桶已经迁移完毕,处于扩容状态;2. 作为一个转发的作用,扩容期间如果遇到查询操作,遇到转发节点,会把该查询操作转发到新的数组上去,不会阻塞查询操作
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 该标识用于控制是否继续处理下一个桶,为 true 则表示已经处理完当前桶,可以继续迁移下一个桶的数据
boolean advance = true;
// 用于控制扩容何时结束,该标识还有一个用途是最后一个扩容线程会负责重新检查一遍数组查看是否有遗漏的桶
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 用于处理一个 stride 长度的任务,i 后面会被赋值为该 stride 内最大的下标,而 bound 后面会被赋值为该 stride 内最小的下标,通过不断缩小i的值,从右往左依次迁移桶上面的数据,直到i小于bound时结束该次长度为 stride 的迁移任务
//结束这次的任务后会通过外层 addCount、helpTransfer、tryPresize 方法的 while 循环达到继续领取其他任务的效果
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;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
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
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
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;
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);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
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;
}
}
}
}
}
}
}
并发思想
为什么 1.7 和 1.8 的控制并发的手段不一致?
1.7 采用的是 数组+segment+
分段锁 的思想;1.8 采用的是 数组+CAS
+synchronized
的思想。
所以说:
1.7 的实现采用了分段锁的技术,只不过多了个 segment
,一个 segment
里对应一个小 HashMap
,其中 segment
继承了 ReentrantLock
,充当了锁的角色,一把锁 锁住了一个小 HashMap
(相当于多个Node
),而1.8 的实现中锁的粒度从多个 Node
的级别又减小到了一个 Node
级别,再度减少锁的粒度,减小程序同步的部分。
get() 方法如何线程安全地获取 key、value 值?
我们知道,在获取链表节点的时候,我们貌似没有任何加锁的操作,在 get()
方法中,只是用到了一个 table
代表哈希桶的变量,且该变量是被 volatile
修饰的;所以 get() 方法没有线程安全问题,只有线程之间的数据可见性问题。
但是有一个地方还是需要注意的,就是 eh < 0
的时候 find()
方法的使用;此时的 find() 方法是一个基类方法。
先说结论:get方法会遇到三种情况:
-
非扩容的情况,遇到get操作,通过扰动函数进行计算到hash值然后进行定位,如果数组上的hash桶就是目标元素就直接返回即可;如果当前hash桶为普通Node节点链表,则使用普通链表方式去遍历链表查找目标元素。如果定位到hash桶元素是TreeBin节点,则根据TreeBin内部维护的红黑树锁来确定具体采用哪种方式遍历查找元素;如果红黑树锁的状态为写锁/等待写锁,则使用链表的方式去遍历查找,如果红黑树锁的状态是无锁/读锁;则使用红黑树的遍历方式去遍历查找。红黑树是写写不互斥,读写互斥。
-
集合正在扩容,并且当前hash桶正在迁移中:遇到get操作,在扩容过程中会形成hn和ln链,形成这两条中间链是使用类似复制引用的方式,也就是ln链和hn链是复制出来的,而非原hash桶的链表剪切过去的,所以原来的hash桶上的链表没有受到链表的影响,因此从迁移开始到迁移结束这段时间都是可以正常访问原数组hash桶上的链表。
-
集合扩容还未结束但是哈希桶已经迁移完成,遇到get操作,每迁移一个hash桶后当前hash桶的位置都会被替代成 fwd 节点,遇到get操作时直接将查找操作转发到新的数组上去,也就是直接到新数组上查找目标元素,具体的查找方法看(1)
javapublic class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable { final Node<K,V> find(int h, Object k) { if (k != null) { for (Node<K,V> e = first; e != null; ) { int s; K ek; // 如果当前处于加锁状态,即有线程正在对红黑树进行写操作或者等待写操作,为了减少锁的竞争以便写操作尽快完成,查找操作将会以链表的方式去遍历红黑树节点 if (((s = lockState) & (WAITER|WRITER)) != 0) { if (e.hash == h &&((ek = e.key) == k || (ek != null && k.equals(ek)))) return e; e = e.next; } // 如果当前没有加等待写锁或者写锁,则先将 lockState + 4,也就是读锁的值叠加,然后以红黑树的方式进行遍历节点 else if (U.compareAndSwapInt(this, LOCKSTATE, s,s + READER)) { TreeNode<K,V> r, p; try { p = ((r = root) == null ? null :r.findTreeNode(h, k, null)); } // 进入该else if分支的每条线程在查找结束后将 lockState - 4 // 如果当前线程是最后一条读线程|有线程持有等待写锁 并且有等待写线程被阻塞着,则唤醒该等待线程,让阻塞的写线程去竞争写锁 finally { Thread w; if (U.getAndAddInt(this, LOCKSTATE, -READER) ==(READER|WAITER) && (w = waiter) != null) LockSupport.unpark(w); } return p; } } } return null; } }
put() 方法如何线程安全地设置 key、value 值?
- 减少锁的粒度:将
Node
链表的头节点作为锁,默认大小为16的情况下,将有16把锁,减少锁竞争,也保证了线程安全; CAS
操作:CAS-volatile
确保获取到的值为最新。
size() 方法如何线程安全地获取容器容量?
java
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
// 统计容器大小方法
private final void addCount(long x, int check) {
// 计数桶
CounterCell[] as; long b, s;
// 如果还没有被初始化 或者 CAS 操作失败(说明有竞争)后,进入 if 代码块
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
// 标记是否存在竞争
boolean uncontended = true;
// 如果计数桶还没有被初始化 或者 桶的长度为空
// 桶对应索引位置的变量为空
// 桶初始化完,且桶对应的随机索引位置不为空,尝试 CAS 操作使得桶+1失败
// 出现了 CAS 失败,说明一定有线程竞争,此时的counterCell是为空的,最终一定会进入fullAddCount方法来初始化计数桶
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<K,V>[] tab, nt; int n, sc;
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
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();
}
}
}
// 桶计数方法
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
// 获取basecount,赋值给sum
long sum = baseCount;
if (as != null) {
//遍历计数桶,将value值相加
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
}
由此可见,统计容器大小用了两种思路:
- CAS 方式直接递增:在线程竞争不大的情况下,直接使用 CAS 操作递增 baseCount 值即可,这里说的竞争不大就是 CAS 操作不会失败;
- 分而治之桶计数:CAS 操作失败了,就说明有线程竞争了,计数方式会从 CAS 方式转变为分而治之的桶计数方式。
java
public class Map {
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
// 1. 什么条件下会进行计数桶的扩容?2. 扩容操作是如何的?
if ((as = counterCells) != null && (n = as.length) > 0) {
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
CounterCell r = new CounterCell(x); // Optimistic create
if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try { // Recheck under lock
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {
rs[j] = r;
created = true;
}
} finally {
cellsBusy = 0;
}
if (created)
break;
continue; // Slot is now non-empty
}
}
collide = false;
}
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
// 若 CAS 操作失败了,到了这里,会先进入一次,然后再走一次刚刚的for循环
// 若是第二次for循环,collide = true,则不会走进去
else if (!collide)
collide = true;
// 计数桶扩容,一个线程若走了两次for循环,也就是多次CAS操作递增计数桶失败了,则进行计数桶扩容,CAS 标识计数桶busy中
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
// 将长度扩大到两倍,然后进行遍历赋值
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
rs[i] = as[i];
// 将新的计数桶引用赋值给concurrentHashMap
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
h = ThreadLocalRandom.advanceProbe(h);
}
// 这代码块代码的是计数桶进行初始化,且 CAS 设置 cellsBusy=1,表示现在计数桶正忙
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
// 初始化容量为2的数组,然后随机选一个索引来进行计算并且该索引对应的桶进行+1容量,然后将该桶赋值给ConcurrenthashMap
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
// 若有线程同时来初始化计数桶,则没有抢到busy资格的线程就先来 CAS 增加baseCount
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
}
所以上面的问题应该解决了
- 什么条件会进行计数桶扩容?
- 扩容操作如何?也是利用了 CAS 操作,只有满足 CAS 操作的线程才能进入,那么该线程其实就是串行执行,保证线程安全的问题了。
计数使用到的并发技巧:
- 利用 CAS 递增 baseCount 值感知是否存在线程竞争,若竞争不大直接 CAS 递增 baseCount 值即可
- 若存在线程竞争,则初始化计数桶,若此时初始化计数桶的过程也存在竞争,多个线程同时初始化,则没有抢到初始化资格的线程直接尝试 CAS 递增 baseCount 值的方式完成计数,最大利用了线程的并行。此时使用计数桶计数,分而治之的方式进行计数,此时两个计数桶最大可提供两个线程同时计数,同时使用 CAS 操作来感知线程竞争,若两个桶情况下 CAS 操作还是频繁失败,则直接扩容计数桶,变成4个桶,以此类推。。。同时使用位运算和随机数的方式"负载均衡"一样地将线程计数请求接近均匀地落在各个计数桶。
扩容期间数据迁移如何保证线程安全?
初始化数据结构时如何保证线程安全?
我们第一次执行 put()
方法的时候,执行 initTable()
方法,使用 CAS
操作将 sizeCtl
标记位成功设置为 -1的线程就是进行初始化哈希表数组的线程。这里的操作并不是很麻烦,总的来说,就是将被 volatile
修饰的 table
进行赋值给 内部的 tab
变量即可。
总结
上面你如果能回答出来,二十分钟准过去了吧,我觉得面试官也会很满意你的回答!最后,总结一下:
面试官:你对 concurrentHashMap 有多了解?
候选人:内心狂喜,哈哈,轮到我发挥了,于是回答道:concurrentHashMap
是并发版的 HashMap
;我对 concurrentHashMap
的了解是:了解该 concurrentHashMap
的 put()
流程、get()
流程、扩容流程以及 concurrentHashMap
在并发情况下如何实现线程安全的思想。
put()
的实现方式,如何保证线程安全?当我们第一次调用 put()
方法的时候,我们的 key
和 value
都不能是 null
,之后 concurrentHashMap
会帮我们进行初始化数组,初始化数组也是需要考虑到并发问题的,初始化数组的逻辑在 initTable()
中,在该方法中,我们以这个 sizeCtl
作为标识,如果是已经有其他线程正在初始化数组了,那么就让出 CPU
资源,如果没有的话,就进行 CAS
操作进行判断(concurrentHashMap
中大量的 CAS
实现都是在一个 if
条件判断里面的,这样还可以保证一个单线程操作,从而保证了线程安全的问题),然后进行初始化数组的操作;接下来通过扰动函数计算 key
得到的哈希值然后寻找到相应的桶位置,然后对应的桶位置是空的,就进行 CAS
操作填充即可;如果对应位置中有 Node
节点,且节点的 hash
值是 MOVED
,说明当前节点正在扩容数组,则当前线程加入并且帮助扩容(扩容的操作后面说吧);如果都不是的话,那么 synchronized
就会锁住当前的桶数组的节点,如果是链表节点,那么就一个个遍历,遍历到最后就使用尾接法进行填充 Node
即可,如果是红黑树节点那么就利用红黑树的插入操作;
get()
的实现方式,get()
方法中不需要保证线程安全问题,但是需要解决线程之间可见性问题,那如何解决?get()
方法一开始的时候也是需要进行数组初始化判断的,这部分就不说了,上面已经提过;线程之间可见性主要是使用了 volatile
字段保证;同时,当出现扩容的时候或者说是遍历红黑树节点的时候,都是需要 find()
方法来解决,否则就直接按照链表遍历的方式进行即可;那么这里就补充一下扩容的时候遇到 get()
方法如何操作的,有两种情况:
- 集合正在扩容,并且当前的
Hash
桶正在迁移中,遇到get()
操作,在扩容过程中会形成ln
和hn
链,行程这两条中间链是使用类似复制引用的方法,也就是说ln
链和hn
链是复制出来的,而非原Hash
桶的链表剪切过去的,所以原来的Hash
桶上的链表没有受到链表的影响,因此从迁移开始到迁移结束这段时间都是可以正常访问原数组Hash
桶上的链表。 - 集合扩容还未结束但是
Hash
桶已经迁移完毕,遇到get()
操作,每迁移一个Hash
桶后当前Hash
桶的位置都会被替代成FWD
节点,遇到get
操作时直接将查找操作转发到新的数组上去,也就是直接到新数组上查找目标元素
而且遇到 get
操作的时候,当前节点是红黑树的时候,他跟普通的链表节点的遍历又不是一样的,他不是像遍历链表一样只是锁住桶开始的那个节点即可,而是有一个红黑树锁,当红黑树锁的状态为写锁/等待写锁时,表示有线程正在对该红黑树进行写操作或者写操作在等待,为了避免冲突,所以用链表的形式进行遍历,因为链表的对于删除和插入元素来说更为简单,不需要复杂的旋转操作;如果是无锁或者是读锁时,表示没有写操作正在进行,可以使用红黑树形式进行遍历,红黑树在遍历大型数据集的时候有良好的时间复杂度(logN
),所以说也是综合了线程安全以及这个性能问题来考虑的。
扩容过程是如何的?那么解答这个问题之前我们首先要知道什么时候会触发这个扩容?比如说:在执行 putVal()
方法新增元素 调用 addCount()
方法后;扩容状态下,其他线程对集合进行增删改合并等操作时遇到 FWD
节点的时候;当桶数组的长度没有达到 64,且单个桶元素的个数大于 8 时都是需要进行扩容的;扩容时的准备工作是如何?多线程需要分配任务,每个线程至少要处理16个桶的迁移,且每个线程就是要处理一个长度为 stride
的任务,通过指针 i 和 bound
来进行从右往左依次进行迁移桶上面的数据,直至 i 小于 bound
时结束该次长度为 stride
的迁移任务;结束这次任务后还是可以达到循环领取任务的效果,应该是一个 while
循环。然后我们还需要知道普通链表是如何迁移的?这里有一个高位和低位 的问题,就是利用节点的 HashCode
& 原数组长度n 进行运算,1是高位,0是低位;先锁住数组上的 Node
节点,找到 lastRun
节点,将其设置为节点链表 hn
的节点,然后从首节点开始进行遍历,将节点链表设置为 ln
的节点,然后 ln
节点就放在原索引 的位置,而 hn
节点就放在 原索引 + 原数组长度 的位置,扩容期间对于请求是如何处理的?记住这个图
扩容期间如何保证线程安全和数据安全的?类似初始化数组那个样子,CAS
判断的条件加在了 if
判断条件下,那么能进入 if
语句块的肯定是单线程了。最后退出扩容流程的线程还需要重新检查一遍 Hash
桶,看看是否有遗漏,有的话就需要将其迁移到新数组即可。
还有一些并发思想:获取 concurrentHashMap
的容器大小的时候如何保证线程安全?两种思路:
-
当线程竞争不大的时候,直接使用
CAS
操作递增baseCount
值即可,这里的竞争不大的意思是CAS
操作不会失败。 -
假如失败了,说明线程竞争较为激烈的了,就需要用到了计数桶
countCell[]
数组来进行计数了,countCell
元素有一个属性是value
,类似一个分而治之的方式进行计数,刚开始的时候只是支持两个线程同时进行计数,同时使用CAS
操作来感知线程的竞争,若两个桶情况下CAS
操作还是频繁失败,就需要扩容计数桶,变成 4 个,以此类推。。。同时运用位运算和随机数的方式"负载均衡"一样将线程计数请求接近均匀地落在各个计数桶。
1.7 和 1.8 的控制并发的手段哪些不一致?
1.7 采用的是 数组+segment+
分段锁 的思想;1.8 采用的是 数组+CAS
+synchronized
的思想。
所以说:
1.7 的实现采用了分段锁的技术,只不过多了个 segment
,一个 segment
里对应一个小 HashMap
,其中 segment
继承了 ReentrantLock
,充当了锁的角色,一把锁 锁住了一个小 HashMap
(相当于多个Node
),而1.8 的实现中锁的粒度从多个 Node
的级别又减小到了一个 Node
级别,再度减少锁的粒度,减小程序同步的部分。
最后的最后!!! 该篇文章是我看了多个博主文章后进行的总结,当然也自己看了 1.8 JDK 的源码,上面的总结是我自己的,我相信你一定看不懂哈哈,所以我更加推荐你去看各大博客的原文,看懂之后再来看看我的总结,然后你也就可以写上自己的总结了,到时候面试的时候就将你的总结和面试官侃侃而谈,那么你也就稳了。