1 创建
1.1 构造器
创建HashMap的时候,可以指定初始化容量,也可以不指定初始化容量
//不指定初始化容量
public ConcurrentHashMap() {}
//指定初始化容量
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
//如果指定的容量大于最大容量,则使用最大容量,否则对通过tableSizeFor对指定容量进行处理后的值
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
1.2 tableSizeFor
tableSizeFor方法是返回大于等于C的最小2的幂。所以,ConcurrentHashMap要做的事情就是要保证其容量必须是2的幂。为什么要这样做后面会详细说明。
private static final int tableSizeFor(int c) {
int n = c - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
需要注意一个点,再ConcurrentHashMap通过tableSizeFor处理我们给定的容量时,传入的参数是initialCapacity + (initialCapacity >>> 1) + 1
这个是ConcurrentHashMap为开发人员的考虑,和扩容有关,这里先简单说明一下:当ConcurrentHashMap的容量达到阀值容量的时候,就会扩容。而扩容阀值计算为:
- 阀值 = n - (n >>> 2) = n * 0.75
站在使用ConcurrentHashMap的角度,只需要关心自己的数据需要的空间,至于自己的数据量是否会引起出现扩容,不用考虑,ConcurrentHashMap通过initialCapacity + (initialCapacity >>> 1) + 1
已经帮我们申请到了足够的空间。
ConcurrentHashMap是数组加链表/红黑树的方式存储数据,而在创建ConcurrentHashMap的时候,不管是否指定了容量,都不会初始化数组,其实初始化的操作,是在第一次put操作的时候做的,这样避免了初始化了容量并不存储数据而占用空间的情况。
2 初始化
tab数组是在第一次put操作的时候进行初始化的:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//......
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//如果tab为null,则进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//......
}
//......
}
//...
}
2.1 initTable初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//tab没有初始化成功的时候就无限循环,直到初始化好为止跳出循环
while ((tab = table) == null || tab.length == 0) {
//这里把sizeCtl的值赋值给了局部变量sc。如果创建ConcurrentHashMap给定了初始容量值,此时sizeCtl是有值的。
//这里只是暂时将sizeCtl初始值先放到sc中
//这里默认sizeCtl值为0,为什么会小于0,在else逻辑中能看到
//通过else的逻辑可知:如果sc小于0,那么说明其他线程已经在初始化了,所以这里就通过yield + 循环方式等待初始化,只等循环条件不满足(初始化好)跳出
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//因为可能多个线程同时访问并初始化,所以这里需要通过CAS做拦截,只允许一个线程初始化成功。
//如果一个线程已经将SIZECTL值修改为-1那么其他的线程就不能就行初始化了,就会在if中通过yield + 循环方式等待初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
//sc > 0说明创建ConcurrentHashMap的时候指定了初始化容量,如果没有指定则使用默认容量16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//这里就是创建一个tab数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//这里计算出sc,并在finally 中赋值给sizeCtl。
//这里计算过程等同于 sc = n * 0.75。 n >>> 2 = n / 4。n >>> 1 = n / 2
//这里计算的额sc,在扩容的时候,容量达到sc的时候就会做扩容处理
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
初始化要点:
- 初始化在第一次put的时候完成。
- 可能有多个线程同时初始化,通过
U.compareAndSwapInt(this, SIZECTL, sc, -1)
控制只能一个线程初始化成功。其他线程如果发现已经有线程正在初始化,则通过while + yiled方式等待初始化成功。 - 初始化成功后,设置
sizeCtl = n - (n >>> 2)
,相当于sizeCtl = n * 0.75
,通过移位运算,速度会更快。这里就是设置扩容阀值,当存储容量超过sizeCtl = n - (n >>> 2)
,就会进行扩容。
3 put
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//获取key的hash值
int hash = spread(key.hashCode());
//binCount用来记录当前的链表的长度
int binCount = 0;
//无限循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//第一次设置值得时候,tab还没有被初始化,tab的值就为null,就需要初始化
//第一次访问初始化结束后,会进行下一次循环将值放入tab
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//这里就是从tab中,通过前面tab计算出的hash取出对应的tab[i],
//这里为什么用tabAt方法而不直接用数组下标取,可以看tabAt方法注解。
//如果取出的位置的Node为null,则通过CAS的方式放值,不为null,说明出现了hash冲突,则走else流程。
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//因为同时可能又多个线程同时放。只允许一个成功,失败的通过下一次循环。
//如果放值成功了,则break跳出循环
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//这个地方,如果== MOVED,说明正在扩容,则调用helpTransfer,即,帮助扩容。
//(3)*********************
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//执行到这里,说明出现了Hash冲突,就需要将数据放入链表或者红黑树中
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
//这里fh >= 0,说明当前tab的这个位置还是数组。
//红黑树的节点hash值会设置成-2
if (fh >= 0) {
binCount = 1;
//遍历链表,放置Node
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果hash和key都是相等的,就覆盖value
//这里是否覆盖,通过onlyIfAbsent判断,这个onlyIfAbsent可以再put的时候指定
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;
//将新增的Node放到链表末尾
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;
}
}
}
}
//如果链表的长度大于等于8,则需要将其转为红黑树
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//执行到这儿,说明值已经放置成功了,需要对count进行++的操作
//当有很多的线程同时对同一个count进行++操作,肯定会出现线程安全问题。addCount需要使用CAS保证线程安全。
//但是大量的线程同时addcount,只有一个线程成功,其他的线程都在自旋,很浪费资源,所以addcount采用了分而治之的思量进行计数。
addCount(1L, binCount);
return null;
}
put主要步骤
(1)如果put的时候,发现tab还是null,说明tab还没有被初始化,需要进行初始化。
(2)通过hash得到对应的tab下标的时候,使用的是(n - 1) & hash)
,我们平时如果要散列,使用的都是 hash % n
, 其实在这里这两种方式是等价的,因为tab的长度是2的幂,所以才是等价,这也是tab的长度要保持在2的幂的一个原因。
(3)在获取tab数组对应位置的Node的时候,使用的是f = tabAt(tab, i = (n - 1) & hash)
,而非tab[ i = (n - 1) & hash]。tabAt实现如下:
transient volatile Node<K,V>[] table;
//tabAt中没有通过数组下标直接取值,而是通过getObjectVolatile去取。
//原因:全局变量table虽然被修饰为了volatile,但是其只是一个指针,其指针地址发成变化,才会被其他线程立即可见
//因为他是一个数组,其内部的值并没有被volatile修饰,所以其内部的值发生了变化,通过普通的取值方式不能被立即可见,所以要通过getObjectVolatile去取值,才会立即可见
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
tab数组引用虽然被volatile修饰,但是tab里面的值并没有被volatile修饰,所以为了保证多线程之间数组内容改变后的立即可见,所以就使用了Unsafe类中的getObjectVolatile,在执行的时候加入内存屏障,保证了修改后线程间的立即可见。
(4)通过判断如果 (fh = f.hash) == MOVED
,则执行helpTransfer,在这里如果条件满足,说明当前曾在扩容,后面你会看到,在扩容的时候,如果当前tab对应的链表或者红黑树已经转移完成,则会将tab对应的位置设置为ForwardingNode,而ForwardingNode的hash值就是MOVE。
(5)真正的进行put数据了,此时会判断当前tab的位置是链表还是红黑树,如果是链表,则将Node添加到链表的末尾,如果是红黑树,就通过红黑树的逻辑加入节点即可。这里区别链表和红黑是使用的判断是fh >= 0
则为链表,之所以这样判断,是因为链表在tab中存储的节点是TreeBin,在将TreeNode转换为TreeBin的时候,将其hash设置为 -2:,代码如下:
static final int TREEBIN = -2; // hash for roots of trees
TreeBin(TreeNode<K,V> b) {
//创建根节点的时候,这里把hash设置为TREEBIN == -2,
//这也就是前面判断,如果hash > 0,则为链表,否则为红黑树的原因
super(TREEBIN, null, null, null);
//......
//关于红黑树的逻辑,这里不分析
}
4 AddCount
每次执行完put,在put的末尾,都会执行addCount计数:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//......
//执行到这儿,说明值已经放置成功了,需要对count进行++的操作
//当有很多的线程同时对同一个count进行++操作,肯定会出现线程安全问题。addCount需要使用CAS保证线程安全。
//但是大量的线程同时addcount,只有一个线程成功,其他的线程都在自旋,很浪费资源,所以addcount采用了分而治之的思量进行计数。
addCount(1L, binCount);
return null;
}
计数逻辑整体思路:
如果我们只使用一个Long类型进行扩容。那势必在扩容的时候我们需要对计数器的++操作需要加锁。如果我们使用CAS避免锁操作,但是在多线程的情况下,也只有一个线程能CAS成功,其他线程会浪费CPU的资源。所以为了提高计数效率,ConcurrentHashMap就采用数组进行计数,将多个线程的++操作散列到数组的不同位置,这样可以有效的缓解线程的竞争,提升技术效率。
当然,如果我们虽然使用了ConcurrentHashMap,但是不存在多线程竞争计数的情况,次数就没必要使用数组技术,可以直接使用一个Long类型即可。
所以ConcurrentHashMap的计数就分为两部分,一个是baseCount,在不存在线程竞争的情况下使用。如果有多线程竞争计数,则会创建一个CounterCells数组来计数。最终中的数据 = baseCount + 数组数量总和。
addCount实现如下:
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//首先判断counterCells是否为null,如果是,则将值累加到baseCount。
//如果只有不存在竞争,累加baseCount肯定是成功的。
//一旦counterCells不为null,或者对baseCount使用CAS失败,则就要使用counterCells进行计数了
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包含了CounterCell初始化,扩容,计数累加的功能
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
//下面是扩容的逻辑,可以先忽略
if (check >= 0) {
//这里开始扩容的逻辑
//扩容的时候支持多个线程同时扩容,会使用SIZECTL记录当前参与扩容的线程的数量。
//使用SIZECTL的高16位作为扩容戳,扩容戳计算和当前tab长度有关,详细的看resizeStamp
//使用SIZECTL的第16位记录当前参与扩容的线程的数量。参与扩容的线程的数量 = 低16位 - 1
Node<K,V>[] tab, nt; int n, sc;
//在while里面为sc赋值了,sc = sizeCtl = 扩容阈值 = count * 0.75
//只有当已经存放的数量 > 扩容阀值,才进行扩容操作
//这里还有一点需要注意:因为支持多个线程同时进行扩容,而一开始扩容,sizeCtl的值一直将会是负数。
//只有当扩容结束,sizeCtl才会被重新设置为扩容阀值。所以,想要退出循环,必须等到扩容结束。
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//这里拿到的rs,第16位一定为1
int rs = resizeStamp(n);
//第一次,sc一定不小于0,执行else
//第一次执行else,在else中,会将其设置为负数。
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//继续有其他线程来扩容,为SIZECTL加1记录线程数量。
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//因为会有多个线程同时进入到这里进行扩容,因为公用SIZECTL来记录当前有多少个线程参与扩容,所以要使用CAS修改SIZECTL
//第一次进行的时候,设置SIZECTL = rs << RESIZE_STAMP_SHIFT) + 2。得到的一定是负数,需要看resizeStamp
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//transfer是真正的扩容逻辑
transfer(tab, null);
s = sumCount();
}
}
}
(1)首先就判断了counterCells不等于空。则肯定存在过线程竞争,则直接使用counterCells进行计数。如果counterCells等于空,则尝试使用CAS + baseCount计数,如果CAS失败了,说明发生了竞争,则使用counterCells计数。
(2)要使用counterCells计数,那counterCells位null的情况下,必须要执行fullAddCount初。如果counterCells不为空,则通过ThreadLocalRandom.getProbe() & as.(length - 1)
随机数 + 取模的方式(后面会看到,这里之所以不用%,也是因为counterCells数组长度是2的幂)拿到计数数组对应位置的counterCell进行计数。如果对应位置的counterCell为null,则执行fullAddCount。如果对应位置的counterCell不为null,则通过CAS进行计数,如果CAS失败,说明存在竞争,则执行fullAddCount。
所以,fullAddCount包含了counterCells数组和各个位置counterCell的初始化,以及线程竞争情况下的CAS计数,
在fullAddCount中频繁用到cellsBusy变量,这里提前介绍一下,方便理解后面的代码:
private transient volatile int cellsBusy;
因为fullAddCount具有初始化,扩容,计数的功能。在多线程的情况下,会通过cellsBusy变量加上CAS进行控制:
(1)保证只能一个线程初始化成功。
(2)保证在扩容的时候不允许进行CountCells对应数组位置的CountCell初始化操作。
fullAddCount实现如下:
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
//从ThreadLocalRandom中获取随机数
if ((h = ThreadLocalRandom.getProbe()) == 0) {
//如果发现获得的probe值为0,说明ThreadLocalRandom还没有被初始化,需要先进行初始化。
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;
//如果counterCells为null,说明counterCells还没初始化,所以需要对counterCells进行初始化
if ((as = counterCells) != null && (n = as.length) > 0) {
//如果counterCells不为null,就获取counterCells相应位置的counterCell
//如果定位到的counterCell为null,说明counterCells相应位置的counterCell还是null,需要new一个counterCell
if ((a = as[(n - 1) & h]) == null) {
//执行到这儿,说明定位到的位置CounterCell为null,需要给相应位置new一个CounterCell,并赋值。
//只有cellsBusy为0的情况下,才允许进行CountCells对应数组位置的CountCell初始化操作,因为此时可能正在扩容
if (cellsBusy == 0) { // Try to attach new Cell
//创建一个CounterCell
CounterCell r = new CounterCell(x); // Optimistic create
//进行CountCells对应数组位置的CountCell初始化操作,也不允许扩容操作
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
//执行到这儿,说明定位到的counterCell一定不为null
//通过CAS对count进行累加。
//如果线程的数量很多,即使散列但是依旧存在多线程竞争的情况,这里依旧会失败。
//如果依旧失败,则会去尝试扩容
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
//这里的判断,就是为了看是否还能扩容
//如果counterCells != as,说明肯定已经有其他线程已经扩容完毕了,因为扩容会创建新的数组,那就不需要再进行扩容了
//n >= NCPU,如果当前counterCells 数组的长度大于等于CPU的核数,也不能再继续扩容。
else if (counterCells != as || n >= NCPU)
collide = false; // At max size or stale
else if (!collide)
collide = true;
//执行到这儿,说明散列后还存在竞争,并且还能够继续扩容
//通过CAS设置cellsBusy,保证只能有一个线程扩容,并且扩容期间不允许进行CountCells对应数组位置的CountCell初始化操作
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
//counterCells 的长度扩大为原来的两倍,
//将counterCell移到新的数组中,
//并用新的counterCells替换旧的counterCells
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
}
h = ThreadLocalRandom.advanceProbe(h);
}
//对counterCells进行初始化,因为存在多线程,所以通过CAS设置状态值cellsBusy来保证只有一个线程能够初始化成功
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try { // Initialize table
if (counterCells == as) {
//初始化的过程,就是创建一个长度为2的CounterCell数组
CounterCell[] rs = new CounterCell[2];
//因为是第一初始化,直接给数组的位置创建一个CounterCell并赋值即可。
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
//退出的时候恢复cellsBusy
cellsBusy = 0;
}
if (init)
break;
}
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
fullAddCount包含了CounterCell初始化、扩容、计数累加的功能。
(1)一开始,就先获取一个随机数,用于后面的散列操作。fullAddCount计数因为没有hash进行散列,所以他通过ThreadLocalRandom获取随机数的方式来散列实现多线程计数,对ThreadLocalRandom不太熟悉的,可以看 Random和ThreadLocalRandom原理分析
(2)如果counterCells为空,就会对其初始化,初始化之前通过CAS将cellsBusy设置为1,保证只有一个线程能进行初始化。counterCells的初始化长度为2.
(3)如果counterCells不为null,则开始计数。如果拿到的counterCells数组对应位置的counterCell为null,则需要初始化当前位置的counterCell。如果不为null,则直接通过CAS进行计数。
(4)当在用数组进行计数还是存在竞争导致CAS失败的情况下,判读当前的counterCells数组长度是否大于等于当前操作系统的CPU核数,如果不大于,则进行扩容,如果已经大于等于了,则只能通过不断的CAS完成最终的计数。
(5)addCount结束后,会通过sumCount计算总的count数量,用于后面的扩容判断,sunCount实现如下,就是一个累加的操作。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
5 扩容
在addCount之后,就会进行扩容的操作:
private final void addCount(long x, int check) {
//......
if (check >= 0) {
//这里开始扩容的逻辑
//扩容的时候支持多个线程同时扩容,会使用SIZECTL记录当前参与扩容的线程的数量。
//使用SIZECTL的高16位作为扩容戳,扩容戳计算和当前tab长度有关,详细的看resizeStamp
//使用SIZECTL的第16位记录当前参与扩容的线程的数量。参与扩容的线程的数量 = 低16位 - 1
Node<K,V>[] tab, nt; int n, sc;
//在while里面为sc赋值了,sc = sizeCtl = 扩容阈值 = count * 0.75
//只有当已经存放的数量 > 扩容阀值,才进行扩容操作
//这里还有一点需要注意:因为支持多个线程同时进行扩容,而一开始扩容,sizeCtl的值一直将会是负数。
//只有当扩容结束,sizeCtl才会被重新设置为扩容阀值。所以,想要通过while条件退出循环,必须等到扩容结束。
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//这里拿到的rs,第16位一定为1
int rs = resizeStamp(n);
//第一次,sc一定不小于0,执行else
//第一次执行else,在else中,会将其设置为负数。
if (sc < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
//继续有其他线程来扩容,为SIZECTL加1记录线程数量。
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
//因为会有多个线程同时进入到这里进行扩容,因为公用SIZECTL来记录当前有多少个线程参与扩容,所以要使用CAS修改SIZECTL
//第一次进行的时候,设置SIZECTL = rs << RESIZE_STAMP_SHIFT) + 2。得到的一定是负数,需要看resizeStamp
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
//transfer是真正的扩容逻辑
transfer(tab, null);
s = sumCount();
}
}
}
(1)支持多线程同时扩容。
(2)扩容的时候使用的while循环中有条件s >= (long)(sc = sizeCtl)
,这个判断有三个作用:
-
扩容前,sizeCtl就是之前计算的扩容阀值。当前的数量大于等于sizeCtl,才进行扩容操作。
-
扩容中的时候,会将sizeCtl设置为负数,只有在扩容结束,sizeCtl才会被设置为新的阀值。所以扩容中,这个条件一直是成立的,保证了参与扩容的多个线程想要通过while条件退出循环,必须等到扩容结束,否则这些个线程会一直帮助扩容,知道扩容完成。
(3)开始扩容的时候,将sizeCtl设置为负数,其高16位表示扩容戳,扩容戳和tab数组长度相关。使用其低16为来记录当前有多少个线程正在帮助扩容。扩容中sizeCtl扩容的计算依赖于resizeStamp方法。
static final int resizeStamp(int n) {
//numberOfLeadingZeros获取的是n的二进制的最高位到第一个非0之间,有多少个0。得到的结果范围在0 - 32之间.
//和 1 << 15去或运算,得到的最终结果:其第16位一定为1。这个为了后面将其左移16位变成负数。
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
(4)第一个线程来扩容的时候,记录参与扩容的线程数量为(rs << RESIZE_STAMP_SHIFT) + 2
,即使用低16位记录参与扩容的线程数量。后面有线程来参与扩容,记录参与扩容的线程数量为(rs << RESIZE_STAMP_SHIFT) + 1
。所以参与扩容的线程数量就是(rs << RESIZE_STAMP_SHIFT) - 1
结果的低16位。
(5)真正的扩容,执行的是transfer
前面提到,ConcurrentHashMap支持多线程同时扩容,其实描述为支持多个线程同时迁移节点数据到扩容后的新的tab中更为准确。所以可想而知,为了实现多线程迁移数据,必须划定好迁移范围,每个线程迁移一定范围的数据。
//参数中:tab是原tab,nextTab是扩容后新的tab
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//扩容过程由于tab长度变化,导致数据在tab中的位置也会发生变化,所以需要转移数据。
//转移数据支持多个线程共同转移,所以要划分好每个线程转移那一部分。
//stride就是用于定义每个线程转移tab的长度。
//这里,如果CPU核数为1,则tride = n, 则只一个线程进行处理。
//如果CPU核数大于1,则将数组分为 (CPU数 * 8)段。允许(CPU数 * 8)个线程处理。
//但是,最小的线程处理单位长度为16
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//如果是第一个线程来扩容,则创建一个新的tab,长度为原tab的2倍
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 赋值为原tab的长度lenth
//下面就会通过这个transferIndex分配每个线程的处理长度
//后面分配处理的时候,用bound 和i分别指向范围的左端和右端
//i = transferIndex - 1; bound = transferIndex - stride; transferIndex - bound
//当然,这里也只有第一个来扩容的线程才会进入到这里进行赋值
transferIndex = n;
}
int nextn = nextTab.length;
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//这里for循环里面,就是真正的迁移。
//给每个线程分配处理的范围,范围大小为前面计算的stride 。
//用bound 和i分别指向范围的左端和右端,用全局变量transferIndex来控制每个线程处理范围的不重复。
//分配好范围后,通过--i来遍历处理每一个Node,如果当前节点Node处理完成,将其置为fwd
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
//这里的while循环:线程第一次来的时候,用于分配处理范围。分配好之后,通过--i实现遍历处理
//advance:开始处理当前的tab[i]的时候,advance会置为false
// 当前的tab[i]处理完成的时候,advance会置为true
while (advance) {
int nextIndex, nextBound;
//这里需要关注一下,--i就是用于遍历使用的。
//如果线程第一次来,还没有分配范围,此时i = bound = 0,所以条件不满足,这里走else
if (--i >= bound || finishing)
advance = false;
//如果这个if满足条件,说明,tab已经全部分配完了。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//执行到这里,说明当前线程第一次进入,还没有分配长度,这里就会就行长度分配
//首先因为是多个线程同时进入,所以通过CAS修改transferIndex。修改成功,说明当前线程获得了此范围的转移权
//用bound 和i分别指向范围的左端和右端
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
//在前面的while中分配处理范围的时候,如果已经分配完了,则 i = -1
//所以这里,只有分配到的最后一个线程才会执行到这里
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//finishing为true,说明已经全部转移完成了,则重新为sizeCtl 何 tab赋值。
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//每个参与扩容的线程,如果扩容结束,就将参与扩容的线程减一
//只有当所有的参与扩容的线程全部执行结束,才会将finishing 设置为true
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;
}
//这里是完全创建了新的节点,而非继续使用原节点
//这样做,保证了原tab不变,所以在扩容期间,依旧可以进行get。
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)通过transferIndex来划分每个线程的迁移范围。迁移范围的大小stride通过CPU的核数和tab数组长度计算得出,stride最小为16。假设tab数组长度为32,stride为16,第一个线程来,就迁移[16, 31]区间范围的数据,第二个线程就迁移[0, 15]区间范围的数据。使用bound和i来确定范围[bound, i],通过–i来遍历每个区间的Node进行迁移。
if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
(2)每处理完一个tab[i]对应位置的数据的迁移,就将tab[i]设置为fwd节点,fwd节点的hash值为MOVE,说明当前tab[i]已经完成了数据迁移
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
(3)真正迁移每一条链的时候,ConcurrentHashMap并不是去遍历链中的每一个Node,重新通过hash & (n - 1)
放入的新的tab中。而是通过hash & n
将原链的Node区分并拼接成为低位链以及高位链,然后将高位链和低位链放入到新的tab指定的位置。这里具体说明一下:
假设当前的tab长度为16,现在tab[5]位置的链如下,并且此时在扩容迁移tab[5]的链。
区分高低链:如果Node.hash & n == 0
则属于低位链,否则属于高位链。为什么要这样区分呢?这个和二进制相关:
hash是任意的,在使用hash计算tab的index的时候,如果目前tab长度为16和长度为32,计算如下:
从上面的计算过程看,可以看出:
当hash的从低到高第5位为0的时候,(hash & (32 - 1)) = (hash & (16 - 1))
。
当hash的从低到高第5位为1的时候,(hash & (32 - 1)) = (hash & (16 - 1)) + 16
所以,一条链上的节点迁移后,要么在原来的位置,要么会被迁移到原位置 + n的位置。而计算迁移后位置的关键,在于hash的从低到高第X位是否为0,而通过Node.hash & n
可以判断出其第X位是否为0。所以:如果Node.hash & n == 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);
我讲上面的代码分为三步,第二步是循环遍历拼接高低链的,第三步是将拼接好的高低链一次性迁移到指定的位置。那第一步是干什么的?为什么要有第一步?
可以看一下ConcurrentHashMap的get方法就会发现,get方法并没有加锁:
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;
}
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;
}
也就是在ConcurrentHashMap进行put或者迁移的过程中,都可以进行get操作。put的时候确实可以,因为put将Node追加在链表末端。迁移的时候,如果想做到不影响get,只能在迁移的过程中重新创建新的Node节点,从迁移过程中的第二步能看到,确实是这样做的。但是这样做必定会创建很多的Node节点,那怎样做才能尽力减少新的Node节点的创建呢?答案是:链表最后的几个迁移后在同一链(高低链)的Node可以不重新创建,可同时连接在新的tab和原tab中,这里有点绕,用图表示如下:
左边是原tab,长度为16,右边是新tab,长度为32,。经过迁移之后,两个tab的情况如图所以,只创建了4个新的Node,来链表的最后的3个Node是可以复用的。
所以在迁移过程中,第一步就是为了找出最后的那三个可以被复用的Node。
(4)迁移完成后,会重新设置tab的值,以及新的阀值sizeCtl
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
}
6 Hash冲突
hash冲突就是通过hash值计算得到的index相同,此时需要将hash冲突的Node节点放到链表或者红黑树中。当然hash冲突后需要比较equals,如果equals,那就需要覆盖了value(默认是需要覆盖了,也可以通过参数指定)。
ConcurrentHashMap为了降低hash冲突的概率,会对hash值进行高低位交叉处理增加散列程度:
//高低位交叉,增加散列程度。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
7 为什么重新equals必须重写hashCode。
重新equals必须重写hashCode是为了保证equals相等的情况下hashcode必须也要相等,为什么要这样?
首先hashCode相等,equals可以不必相等。如果hashCode相等,会出现hash冲突,map通过链表和红黑树的方式进行解决。
但是,如果equals相同,hashcode不相等了,会出现什么问题呢?会导致不同链上存在equals相等的Node,如果在map扩容的过程中将这些Node放在了一条链上,那问题就来了,导致那个Node在前面,则get就会得到哪个Node,会导致在不同时间点上,可能多次get获得的值是不同的。
所以要保证equals相等的情况下hashcode必须也要相等,这样在put的时候,就会将equals相同的值进行覆盖。
8 扩容阀值
tab的长度为n,当map中存放的数据达到扩容阀值的时候需要扩容。扩容是为了防止某一条链或者树上的节点个数过多降低了效率。扩容阀值的计算是:
sc = n - (n >>> 2) 等价于 sc = n * 0.75
9 什么情况下,链表会转换为红黑树
当一条链上的节点个数大于8的时候,就会将其转换为红黑树。但是在转换红黑树之前还会再判断,判断当前tab的长度是否大于64。如果小于64,仅扩容即可。
//如果链表的长度大于8,则会调用这个方法
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//再转红黑树之前,先判断,此时tab的长度是否大于64。如果小于64,仅扩容即可。
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
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;
}
//将红黑树根据节点,设置到tab指定位置
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
10 什么情况下get/put会被阻塞
get:文中已经分析过,任何情况下都可以进行get操作。因为put的时候属于尾插,不影响get。而扩容转移链表数据的时候会创建新的Node,所以扩容过程中也不影响原链表。
put:put操作的时候会对tab[ i ]进行synchronized加锁,在对tab[i]进行put的时候,会阻塞tab[ i ]上的其他put操作,但是不影响tab[ j ]的put操作。
在扩容过程中迁移的时候,也会对tab[ i ]进行synchronized加锁,所以迁移过程中,也会阻塞当前正在迁移的tab[ i ]的put操作。