概述
对于ConcurrentHashMap的定义应该很多人都知道,是一个可以在并发环境下确保线程安全的HashMap。 下聚焦于ConcurrentHashMap几个关键点进行分析:
- put()如何处理?
- 获取size()是如何处理的?
- 如何扩容的?
- 多线程如何辅助扩容?
- get()如何处理?
内部关键对象及相关状态
Node
HashMap中通过一个table数组来实现,里面的每个元素是一个Entry对象(通过传入的kv封装成Entry)。 ConcurrentHashMap也有这玩意,不过名字叫Node(也实现了Map.Entry),Node中有一个next属性形成单向链表。
除了Node自身之外还有以下几种Node子类型:
- ForwardingNode 扩容过程中,会将老table空的位置或已经迁移的位置设置成ForwardingNode,ForwardingNode会持有一个新table的引用,用来将扩容过程中其它线程put等操作辅助其扩容(实现多线程扩容)
- TreeBin 红黑树, 在table[i]位置的链表元素个数超出阈值会转换成TreeBin。
- TreeNode 代表TreeBin树节点
- ReservationNode 在computeIfAbsent和compute中使用的占位节点
Node的hash值
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
上面各个常量代表各个Node子类型的hash值。除了Node自身会通过key计算hash之外,其它Node子类型的hash都是固定的:
- ForwardingNode的hash值是MOVED
- TreeBin的hash值是TREEBIN
- ReservationNode的hash值是RESERVED
put()如何处理?
put()方法添加一个键值对封装成Node对象到ConcurrentHashMap中, 通过key的hashcode计算出一个数组下标索引用来存储(关于如何计算之前的HashMap文章中有描述)。
在put()方法中主要关注几个重点:
- 如何解决hash冲突问题
- 如何解决多线程并发安全问题
- 多线程拓容的实现
秘密都在源码中,put()方法直接调用putVal()方法来处理,下面是putVal()方法的源码:
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 1. 空值校验
if (key == null || value == null) throw new NullPointerException();
// 2. 计算key的hash值
int hash = spread(key.hashCode());
int binCount = 0;
// 3. 遍历table进处理
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 3.1 校验table是否初始化,没有则进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 3.2 计算要存储的索引位置是否为空,是则尝试cas设置node
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 3.3 当前正在扩容, 帮助扩容迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 3.4 存储的位置hash冲突处理
else {
V oldVal = null;
// 3.4.1 锁定table[i]桶中头节点
synchronized (f) {
// 3.4.1.1 二次校验头结点是否变化
if (tabAt(tab, i) == f) {
// 3.4.1.1.1 当前桶中的冲突是链表结构
if (fh >= 0) {
binCount = 1; // 计算链表元素个数,用于判断是否需要转换红黑树
// 3.4.1.1.1.1 循环遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 3.4.1.1.1.1.1 判断有相同key则进行修改(根据ifAbsent标识判断)
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;
// 3.4.1.1.1.1.2 没有相同key将新的kv封装node并加入链表尾端
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 3.4.1.1.2 头结点是红黑树结构, 将节点put到红黑树中
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;
}
}
}
}
// 根据table[i]桶node个数超出TREEIFY_THRESHOLD阈值进行红黑树转换
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// size计算以及判断是否扩容
addCount(1L, binCount);
return null;
}
putVal()方法中的处理流程还是比较清晰的, 这里我们主要看看如何解决上面几个问题的:
- 当出现hash冲突,也就是计算出的table[i]位置已经有node了, 通过链表以及红黑树两种方式来处理的。(链表长度在超出阈值会转换成红黑树)
- 多线程安全处理主要体现在两点:
- 判断在table[i]位置没有node时会尝试cas原子性的方式插入(在很多地方会看到这种逻辑, 先乐观的来一次cas不行再重试或者synchronize方式来处理。 梦想要有,万一成功了呢)
- table[i]位置已经有node了,会锁定头结点(synchronized(table[i]))来操作链表或红黑树(如果是红黑树table[i]存储的是TreeBin一整棵树的引用,而不是单个树节点)锁定头结点来确保线程安全, 可以在后续逻辑体现,如链表:所有节点新增都是从尾部插入,头结点是没有变的
- 在注释3.3中((fh = f.hash) == MOVED)会辅助扩容, 这里的扩容逻辑后面详细分析。
获取size()是如何处理的?
通过size()方法获取ConcurrentHashMap中的元素个数。
对于ConcurrentHashMap元素个数只需要关注两点:
- 认识CounterCell
- 如何增加元素个数的
- 如何获取ConcurrentHashMap元素个数
1. 认识CounterCell
CounterCell可以理解为一个计数器, 里面维护了一个数值v。下面是它的定义:
@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
...
}
ConcurrentHashMap中CounterCell的作用是什么呢?
首先要说下ConcurrentHashMap通过一个baseCount来记录元素个数,baseCount通过名字也很好理解是一个基数。当我们新增或减少map中的元素时会更新baseCount。
由于ConcurrentHashMap是为并发而生的,所以在多线程环境中会先试图通过cas的方式来更新baseCount, 然而如果线程都去通过cas更新baseCount容易失败,通过自旋等方式虽然能解决,但如果线程竞争过大需要多次自旋影响性能,所以设计了CounterCell来解决这个问题。
CounterCell来干吗呢? 当baseCount更新失败之后,ConcurrentHashMap会建立一个CouterCell数组,每个线程随机生成一个数(可以理解成hash值)并根据数组的大小计算出一个下标,通过这个下标的CounterCell来帮助记录这次更新的个数。这样有效的将baseCount的竞争分散到各个CounterCount。 最终只需要ConcurrentHashMap的元素个数只需要将baseCount以及CounterCell数组中各个数值累加即可。
2. 如何增加元素个数的
其实在putVal()方法最后一句执行代码addCount()方法中就实现来这个逻辑, 下面是源码:
private final void addCount(long x, int check) {
/*
CounterCell[] as 用来计数,每个CounterCell维护一个值
b 用来表示baseCount
s 表示baseCount+x
*/
CounterCell[] as; long b, s;
// 1. 判断as不等于null,或者cas操作baseCount+x失败进入内部循环
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
// 1.1 判断是否执行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;
}
// 1.2 无需扩容直接返回
if (check <= 1)
return;
// 1.3 再次统计元素个数
s = sumCount();
}
... // 扩容部分
}
这里代码虽然简单,里面细节还是挺多的。详细描述下:
- 注释1中判断as不等于null代表之前有多线程操作cas失败更新baseCount+x失败了(第一次执行这里是false)。而后面的||判断通过cas操作(baseCount+x)会在第一次执行到这个条件,如果执行成功即可完成容器元素个数的增加(baseCount会增加)。
- 注释1.1中(as == null || (m = as.length - 1) < 0)部分判断没有初始化as直接进入执行fullAddCount()。而后面的判断就是先计算找出下标中的CounterCell是否存在并通过Cas更新这个CounterCell的值,否则执行fullAddCount()。
fullAddCount()是CounterCell更新的核心实现,下面是其源码:
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
// 1. 当前线程生成一个随机数作为hash值(getProbe()这个方法有点特殊,线程多次调用会生成同一个值, 但每个线程是不一样的)
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // 标识是否有冲突 // True if last slot nonempty
// 2. 死循环遍历处理
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
// 2.1 验证as是否初始化
if ((as = counterCells) != null && (n = as.length) > 0) {
// 省略,下面单独分析
...
}
// 2.2 以cas方式更新cellsBusy来控制初始化as
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
CounterCell[] rs = new CounterCell[2];
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
cellsBusy = 0;
}
if (init)
break;
}
// 2.3 前面的处理都失败了,尝试更新baseCount
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
// 再次进入循环
}
}
上面的源码先省略了注释2.1中if的处理代码(下面会详细分析), for中的第一级ifelse控制分支还是比较容易理解的:
- 先看CounterCell[]是否初始化,已经初始化则进行更新CounterCell相关处理。
- 对CounterCell[]进行初始化(一开始数组大小是2, 并将本次初始化成功线程的x(更新元素值)作为其中的一个CounterCell初始值)。
- 最后由于前面的操作反正都失败了,试图去更新baseCount+x。
接下来再详细看看了注释2.1中if的省略的代码:
if ((as = counterCells) != null && (n = as.length) > 0) {
// 1. 通过计算出的下标在CounterCell[]为null进行处理
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
// 1.1 创建CounterCell对象
CounterCell r = new CounterCell(x); // Optimistic create
// 1.2 通过cellsBusy控制标志,将新建的CounterCell更新到数组中
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;
}
// 2. 上次cas操作失败,更新wasUncontended再次进入for循环处理
else if (!wasUncontended) // CAS already known to fail
wasUncontended = true; // Continue after rehash
// 3. 尝试更新对应CounterCell的值
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
// 4. 当counterCells发生更新或者其长度>=CPU核心数,更新collide并再次进入for循环
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
// 5. 当collide并不是因为counterCells发生更新或者其长度>=CPU核心数等原因,将collide更新为true执行后续控制逻辑
else if (!collide)
collide = true;
// 6. 通过cellsBusy控制状态,对CounterCell[]扩容
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];
counterCells = rs;
}
} finally {
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
// 7. 由于上述操作都失败了,对hash再次生成并进入下一次for循环
h = ThreadLocalRandom.advanceProbe(h);
}
上面的代码基本意图就是通过当前线程生成的hash值,对CounterCell[]中指定下标对象value进行更新。 如果指定下标没有初始化则进行初始化,否则以cas方式更新value,如果还是失败则判断CounterCell数组长度是否超出当前cpu核心数并进行拓容,然后再进行前面的处理逻辑。
这里有两个核心的标志变量:
- cellsBusy,当试图对CounterCell[]扩容,更新CounterCell中的value等,都要先通过cas方式更新cellsBusy成功后才能操作。
- collide有点不太好理解,初始默认为false。意图大致为CounterCell更新失败且数组长度没有超过cpu核心数,通过在注释5中collide标志更新为true,下次循环才有机会进入注释6扩容。
另外在注释7中,当所有ifelse分支处理完还没有执行成功,会再次生成hash值进入下一次for循环处理
size()方法实现
public int size() {
// 通过sumCount()方法计算元素个数
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
// 将sum初始值为baseCount
long sum = baseCount;
if (as != null) {
// 遍历各个CounterCell,并将value累加到sum
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
size()方法实现逻辑比较简单,通过baseCount并累加CounterCell[]中各个CounterCell的value得出最终元素个数。
如何扩容的?
扩容一词很好理解,当原容量不够的时候,扩展更多的容量来存储元素。
ConcurrentHashMap的扩容有一大亮点就是多线程辅助扩容。我们先看看扩容是如何实现的,后面再分析如何辅助扩容。
扩容的源码主要在transfer()中,下面是源码:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 1. 计算步长
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 2. 如果新table还未创建进行相关操作
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
// 2.1 新建一个table为old的两倍
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记录老table的长度
transferIndex = n;
}
... // 省略元素迁移代码
}
上面先省略元素迁移代码, 主要做了两件事:
- 计算步长,每个线程进来会根据cpu核心数以及元素个数计算步长(也就是当前线程负责迁移多少个元素, 这里指的是table数组中的元素,不是每个node链表或红黑树)
- 新建一个table为oldTable的两倍,并进行相关赋值。
下面看迁移是如何实现的:
// 1. 新table的长度
int nextn = nextTab.length;
// 2. fwd用来将老table的put等操作转发到新table进行处理
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 3. advance标志用来判断是否往前增长
boolean advance = true;
// 4. finishing标志是否完成迁移
boolean finishing = false; // to ensure sweep before committing nextTab
// 5. for循环执行迁移处理
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 5.1 根据advance标志来计算nextIndex, nextBound
while (advance) {
/*
bound, nextIndex, nextBound三个变量确定了当前线程要负责迁移的区间:
1. bound表示当前线程负责迁移区间的边界(迁移是从后往前迁移的)。
2. nextIndex是通过cas更新TRANSFERINDEX索引获得的下一次迁移下标,最后赋值给i。
3. nextBound是通过cas更新TRANSFERINDEX索引获得的下一次边界,最后赋值给bound。
*/
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;
}
}
// 5.2 判断当前线程将其负责的区间迁移完成了
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
}
}
// 5.3 oldtable下标i的位置没有元素,设置为fwd
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 5.4 判断下标i的元素是否已经被设置为fwd
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 5.5 下标位置有元素执行具体的迁移逻辑
synchronized (f) {
// 5.5.1 再次校验下标i位置头结点是否发生变化
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 5.5.1.1 节点有有效hash值则为链表
if (fh >= 0) {
// 5.5.1.1.1 按链表迁移
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);
}
// 5.5.1.1.2 设置新table的i以及i*2的位置node , 并将oldtable的下标i设置为fwd
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
// 5.5.1.2 按红黑树迁移
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;
}
}
// 5.5.1.2.1 红黑树迁移完成后,根据阈值判断是否改回链表结构
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;
// 5.5.1.2.2 设置新table的i以及i*2的位置node , 并将oldtable的下标i设置为fwd
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
迁移代码的处理逻辑还是比较多的,主要做了以下几件事情:
- 在注释5.1的while循环中,主要是根据之前的stride步长计算出当前线程负责迁移的区间。迁移是从后往前迁的(如table长度未16,迁移是从下标15-->往下标0的方向迁移),bound为迁移区间边界,i为当前迁移的下标位置。
- 注释5.2根据下标i判断整个迁移过程是否完成,或当前线程负责的区间是否迁移完成。
- 注释5.5通过synchronize锁定头结点,并按照节点类型进行相应迁移
- 当迁移下标的位置i完成或为null都将老table相应i的位置设置为fwd,以实现其它线程辅助迁移。
分析链表迁移细节:
// 1. runBit记录最后一个&操作不同的结果标志(看懂这里需要知道容量的计算结果始终是一个2的幂数值)
int runBit = fh & n;
// 2. 记录最后一个runBit结果不同的node,代表lastRun之后的节点runBit结果都一致
Node<K,V> lastRun = f;
// 3. 遍历链表,计算出runBit以及lastRun
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 4. 最后计算的runBit是0, 代表lastRun以及之后的node都属于原下标i的位置
if (runBit == 0) {
ln = lastRun;
hn = null;
}
// 5. 最后计算的runBit不等于0,代表lastRun以及之后的node都属于原下标i*2的位置
else {
hn = lastRun;
ln = null;
}
// 6. 遍历迁移f到lastRun之间的节点
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);
}
看懂上面的代码首先需要先知道两件事:
- 容量始终是一个2的幂数值
- oldtable下标i的位置迁移到newtable中,i只有两个值:i不变,或者i*2
上面链表迁移代码第一个for循环(注释3)为了计算出runBit以及lastRun的目的就是为了减少不必要的链表节点更新。 可以看到在注释6遍历循环,只会处理f到lastRun之间的节点。
多线程如何辅助扩容?
当调用put()等方法时, 发现当前插入位置table[i]的hash为MOVE,则辅助扩容,源码如下:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// 判断当前位置是ForwardingNode
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
// 执行辅助扩容前,进行相关检查(如transferIndex迁移下标已经小于等于0, 或者到达辅助扩容线程的最大数量都不执行)
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 更新sizeCtl,进行将fwd中的新table传入并进行扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
helpTransfer()辅助拓容主要是通过获取ForwardingNode中的新table,判断相关条件并操作sizeCtl成功后调用transfer()进行扩容,并将新table传入(transfer()方法会判断第二个参数新table不等于null将直接使用)。 后续transfer()处理逻辑就是计算步长,计算当前线程迁移的区间然后迁移等。
扩容小结
- 每个线程执行扩容时,会计算一个步长,并计算出一个需要迁移的区间进行迁移(第一个触发扩容的线程会负责创建一个oldtable两倍的新数组)。
- 当扩容过程中执行put等操作,通过ForwardNode实现辅助扩容。
- 不管是触发扩容还是辅助扩容都需要通过cas方式更新sizeCtl成功后才执行后续操作。这样也保证了新table不会重复创建。
- ConcurrentHashMap的put操作在发生hash冲突时,如果是链表是在尾部插入, 如果是红黑树则存储的是TreeBin(整棵树的引用),所以在迁移过程中,会锁定table[i]的头结点确保线程安全。
get()处理流程
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 1. 计算hash值
int h = spread(key.hashCode());
// 2. 验证table是否初始化,且通过hash计算的下标位置不能为null,否则直接返回
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 2.1 先判断table[i]的node是否相等
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 2.2 table[i]的node为红黑树,通过红黑树搜索返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 2.3 通过链表搜索返回
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
这里的处理流程相对比较易懂,主要做以下几件事:
- 获取hash值
- 先比对table[i]的node是否相等
- 通过table[i]的node类型进行相应的搜索并返回
可以看到get()方法的处理逻辑中,完全没有synchronized, lock等相关内容。
总结
- ConcurrentHashMap的处理细节非常多,本文分析只是流于表面,提供一个思路。(膜拜Doug Lea)
- 将自己理解的部分写出来并不是一件容易的事情。
- 源码带来的不单单是对实现原理的认识,更体现在细节的处理、边界判断、性能的优化。 这种精神其实不管是自己练就技术,或在工作开发业务代码中都非常值得学习。