写在前面
- 文章是在前人的基础上进行总结整理再加上自己的一点理解,仅作为自己学习的记录,不作任何商业用途!
- 如果在文章中发现错误或者侵权问题,欢迎指出,谢谢!
数据结构
类变量
private static final int MAXIMUM_CAPACITY = 1 << 30; // 最大容量
private static final int DEFAULT_CAPACITY = 16; // 默认初始化容量
private static final float LOAD_FACTOR = 0.75f; // 负载系数,n - (n >>> 2),CHM 的负载系数是固定的,都是 0.75n
static final int TREEIFY_THRESHOLD = 8; // 链表超过8时,转为红黑树
static final int UNTREEIFY_THRESHOLD = 6; // 红黑树低于 6 时,转为链表
static final int MIN_TREEIFY_CAPACITY = 64; // 树化最小容量,容量小于 64 时,先扩容
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; // 可参与扩容的最大线程
transient volatile Node<K,V>[] table; // 散列表,使用 volatile 修饰保证可见性
private transient volatile Node<K,V>[] nextTable; // 扩容时的过度表
private transient volatile int sizeCtl; // 最重要的状态变量
private transient volatile int transferIndex; // 扩容进度指示
private transient volatile long baseCount; // 计数器,基础基数
private transient volatile int cellsBusy; // 计数器,并发标记
private transient volatile CounterCell[] counterCells; // 计数器,并发累计
- 最重要的 sizeCtl 变量
- sizeCtl = -1:表示有线程正在进行初始化操作
- sizeCtl = (1 + nThreads),表示有 n 个线程正在一起扩容(官方是:-(1 + nThreads),是错的)
- sizeCtl = 0:后续在真正初始化的时候使用默认容量
- sizeCtl > 0:初始化或扩容完成后下一次的扩容门槛
- table 未初始化,表示初始化容量
- table 已初始化,表示扩容阈值(0.75n)
原子操作
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);
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
- 上面三个操作都是原子操作(不可在被分割,会一次性执行完),分别是:
- tabAt 获取 i 位置的对象
- casTabAt 更新 i 位置的对象
- setTabAt 设置一个新的对象到 i 位置
hash 寻址
-
HashMap 的 hash 寻址
- hash() 方法
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }- h = key.hashCode() 表示 h 是 key 对象的 hashCode 返回值
- h >>> 16 是 h 右移 16 位,因为 int 是 4 字节,32 位,所以右移 16 位后变成:左边 16 个 0 + 右边原 h 的高 16 位
- 最后把这两个进行异或返回:如果一样返回 0,不一样则返回 1
- 寻址部分
tab[i = (n - 1) & hash]
为什么不直接用 hashCode() % length,而是要转化一下使用 hash & (n - 1)?
- 由于计算机对比取模,与运算会更快。所以为了效率,HashMap 中规定了哈希表长度为 2 的 k 次方,而 2^k-1 转为二进制就是 k 个连续的 1,那么 hash & (k 个连续的 1) 返回的就是 hash 的低 k 个位,该计算结果范围刚好就是 0 到 2^k-1,即 0 到 length - 1,跟取模结果一样
- 总的来说就是:哈希表长度 length 为 2 的整次幂时, hash & (length - 1) 的计算结果跟 hash % length 一样,而且效率还更好
为什么不直接用 hashCode() 而是用它的高 16 位进行异或计算新 hash 值?
- 首先明确一点就是 HashMap 的长度本身不大,如初始容量才 16,16 - 1 转换成二进制:0000 0000 0000 0000 0000 0000 0000 1111,再假设容量为 2^16 = 65536,这下不是很小了吧,但即使如此,前面的 16 位也是 0
- 如果直接和 hashCode 进行与操作,那么计算出来的结果很多都一样,导致 hash 冲突,以致于数据分布不均匀
- (h = key.hashCode()) ^ (h >>> 16) 进行这个操作的原因就是让高 16 位也参与运算,再和 n - 1 进行与操作的时候得到的结果更加分散,可以减少 hash 冲突
- hash() 方法
-
ConcurrentHashMap 的 hash 寻址
-
hash() 部分
int hash = spread(key.hashCode()); static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; } -
寻址部分
i = (n - 1) & hash) -
和 HashMap 的区别在于多进行了一次 & HASH_BITS
- 为什么要多进行一次 & HASH_BITS 这个操作呢?
- 因为(h ^ (h >>> 16))计算出来的hashcode,可能是负数,那么 & HASH_BITS 就是保证计算出来的值为正数
- 为什么要保证计算出来的值为正数呢?
- 因为当 hashcode 为正数时,表示该哈希桶为正常的链表结构,当 hashcode 为负数时则表示可能为 ForwardingNode 节点和 TreeBin 树,前者表示正在扩容,后者表示链表已经转化为红黑树了
- 那么当为负数的时候 get 则需要去临时的哈希表获取,put 的时候则会帮助扩容,红黑树也是另外的操作(后面面试题会讲)
-
添加元素(putVal)
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 计算 key 的 hash 值
int hash = spread(key.hashCode());
// 要插入的元素所在桶的元素个数,可用于判断何时进行树化
int binCount = 0;
// 进行死循环,结合 CAS 使用,CAS 失败就会重新进行下一次
// 可以发现循环中只会进入其中一个分支,每个分支执行完重新进入循环或者是执行到相应的 break 退出循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 情况1:table 为空(null,或者是空数组)
if (tab == null || (n = tab.length) == 0)
// 为空则进行初始化
tab = initTable();
// 情况2:计算出该 key 对应的下标 i = (n - 1) & hash,在通过 tabAt 方法去获取 i 位置的 Node 对象,并且获取到的对象f 为 null
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 对象为空,则通过调用 casTabAt 方法以 CAS 自旋的方式去赋值
// 如果赋值成功则返回 true,执行 break 退出循环,执行后面的 addCount 方法
// 如果赋值失败则会重新进入循环,再次去判断 i 位置的 Node 对象是否为 null,若是则继续 CAS,若不是的则说明被其他线程赋值了,进入其他分支中
if (casTabAt(tab, i, null,
// 创建一个新的对象去进行 CAS 赋值操作
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 情况3:当前 i 位置的对象不为 null,但是其 hash 值等于常量 MOVED,表示 hashmap 正在扩容
else if ((fh = f.hash) == MOVED)
// 调用 helpTransfer 方法,帮助其扩容:多个线程去扩容,效率更高
tab = helpTransfer(tab, f);
// 情况4:当前 i 位置对象不为 null,也不处于扩容中,那么就可以分为两种情况:一个是 i 位置是链表的头结点,一个是 i 位置是红黑树对象
else {
V oldVal = null;
// 给 f 对象加锁
synchronized (f) {
// 再次通过 tabAt 方法获取 i 位置的对象,并判断是否还是等于之前获取的对象 f:这么做是为了防止加锁的时候该节点被其他线程修改了
if (tabAt(tab, i) == f) {
// 情况4.1:当前 i 位置为链表的头结点
if (fh >= 0) {
// 因为头结点的存在,所以 binCount = 1
binCount = 1;
// 遍历链表,每遍历一次 binCount+1
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果遇上 key 一样的则进行覆盖操作,然后退出循环,会直接退到 if (binCount != 0) 这个判断上
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
// onlyIfAbsent 默认是 false,所以会进行覆盖操作
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;
}
}
}
// 情况4.2:当前 i 位置是一棵红黑树对象,这里和 HashMap 不同之处在于:HashMap 判断的是 f instanceof TreeNode
// 为什么这里是 TreeBin 对象呢?
// 首先明白红黑树在插入的时候根节点会发生变化。如果在 i 位置放入的 TreeNode 对象的话则 synchronized 锁住的就是这个 TreeNode 对象,那么在插入的时候,根节点发生了变化,如果这个时候有其他线程来获取根节点是能够获取到的,但是实际上这样是不对的,因为有可能之前的锁还没有释放
// 如果是 TreeBin 对象的话,那么加锁的就是 TreeBin 对象,在进行插入的时候,TreeBin 对象里面的 root 节点在怎么变化都没有关系,不影响已经加上锁的 TreeBin 对象,其他线程在 TreeBin 未释放锁的时候都会被阻塞住
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// 调用 TreeBin 对象的 put 方法进行插入
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
// 如果 binCount != 0 说明成功插入了元素或者是找到了元素并进行了覆盖操作
if (binCount != 0) {
// 判断 binCount 是否大于 TREEIFY_THRESHOLD
if (binCount >= TREEIFY_THRESHOLD)
// 大于的话则需要进行树化,树化的结果是:setTabAt(tab, index, new TreeBin<K,V>(hd)); 在 i 位置放入一个 TreeBin 对象
// 按理来说:这个树化的操作应该放在 synchronized 里面的呀?
// 因为在该方法内部,同样通过 tabAt 方法获取到了 i 位置的对象并对该对象加了锁,所以不影响整个树化过程
treeifyBin(tab, i);
// 如果 oldVal != null 说明是找到了元素并进行了覆盖操作,所以链表中节点的数量是没有变化的,不需要进行 addCount,可以直接 return 返回
if (oldVal != null)
return oldVal;
break;
}
}
}
// 成功插入了一个元素,则需要进行 addCount,会在 addCount 中判断是否扩容
addCount(1L, binCount);
// 插入成功返回的是 null
return null;
}
- 整个添加元素可以大致分为以下几步
- 计算 key 的 hashCode
- 进入死循环中去进行添加操作,如果 table 未进行初始化,则调用初始化方法进行初始化
- 通过 (n - 1) & hash 得到该元素需要插入的下标位置 i,调用 tabAt 方法区获取该位置的对象
- 如果 i 位置的对象为空,则使用 CAS 自旋的方式去添加元素,如果添加成功则直接 break 退出整个死循环,如果失败则重新再来一次
- 如果 i 位置的对象不为空,且该对象的 hash 值等于 MOVED,表示此时正在扩容,则调用 helpTransfer 方法将该线程则加入扩容这个过程中去
- 如果 i 位置的对象不为空,此时也不处于扩容中,则使用 synchronized 锁住该位置(对象),并在添加前再次判断该位置对象是否还是之前那个,防止在上锁的过程中,i 位置对象被其他线程修改了
- 如果此时 i 位置对象的 hash 值大于 0,表示该位置是链表的头结点,则在链表中寻找或插入该元素,没有找到就插入到链表的尾部
- 如果此时 i 位置对象是 TreeBin 类型的,表示该位置是一棵红黑树,则调用红黑树的方法进行查找或插入
- 如果该元素存在,那么就进行覆盖操作,并直接返回旧值
- 如果该元素不存在,则进行的插入操作,整个 Map 的元素个数 + 1,并判断是否需要扩容
- 在代码中解释过的几点
- 为什么在 i 位置是一个 TreeBin 对象,而不是像 HashMap 中那样是一个 TreeNode?
- 为什么如果 oldVal != null,则直接进行返回就可以了?
- 还不清楚的几点会在后文讲解
- initTable 初始化方法是如何进行的?
- treeifyBin 树化方法是如何进行的?
- addCount 元素数量加1是如何进行的?
初始化桶数组(initTable)
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 有其他线程在初始化,当前线程则放弃 CPU
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// 抢夺到进行初始化的权利,并使用 CAS 将 sizeCtl 更新为 -1
// 如果更新成功则当前线程进入下面的初始化阶段
// 如果更新失败则说明有其他线程先一步进行初始化了,则返回 false 进入下一次循环
// 如果下一次循环其他线程还没有初始化完毕,则 sizeCtl < 0,会进入 if 条件中让出 CPU 进行等待
// 如果下一次循环其他线程初始化完毕,则 table.length != 0,退出循环
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 说明:能进入到这个 if,表明 sc >= 0, 当我们使用无参构造创建 ConcurrentHashMap 对象时则是 sc=0,当我们用其他构造方法创建的则有 sc>0
try {
// 再次判断 tab 是否为空, 防止 ABA 问题
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY; // 根据 sc 的情况去初始化 table 容量大小 n,如果 sc = 0,n = 16
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 计算完初始化容量大小的后,则令 sc=0.75n,此时的 sc 表示为阈值的概念
}
} finally {
sizeCtl = sc; // 注意这里没有 CAS 更新,这就是状态变量的高明了,因为前面设置了 -1,此时这里没有竞争
}
break;
}
}
return tab;
}
- 整个初始化过程大致可以分为以下几步
- 使用 CAS 控制只能有一个线程进入初始化操作
- 根据不同的构造函数计算出桶数组的初始化容量,如果采用无参构造则默认初始化大小为 16
- 计算出扩容后的阈值:sc = n - (n >>> 2) = 0.75n
链表树化(treeifyBin)
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)
tryPresize(n << 1);
// 如果桶数组的容量大于等于 64,则进行树化
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
// 加锁
synchronized (b) {
// 再次判断 index 位置是否还是原来的对象
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;
}
// 通过 new TreeBin<K,V>(hd) 生成一棵红黑树,再将红黑树通过原子操作设置到 index 位置
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
- treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容
- 整个树化操作大致可以分为一下几步
- 判断桶数组的容量是否大于等于 64,如果不是则只进行数组扩容,如果不是则进行树化
- 树化是遍历链表生成一个 TreeNode,在通过 new TreeBin() 构造函数生成一棵红黑树
- 通过原子操作 setTabAt 将红黑树设置在 index 位置上
判断是否需要扩容(addCount)
-
相关概念
- 在无并发的情况下,使用单一的属性 baseCount 进行累计(CAS),一旦 CAS 失败,则进入并发场景
- 在并发的情况下,使用多个分区,将增加或减少的个数累计到相应的分区,可很大程度避免多线程操作同一对象的并发问题
- 最后在获取元素个数的时候(后文会将),将各分区的值和 baseCount 进行相加,得到一个弱一致性的数量值
-
addCount 方法入口
- 可以发现 adCount 方法的形参 check 只有两种形式:binCount 和 -1
- binCount 作为形参 check 的实际参数,又分为两种情况:一个是对于普通节点,binCount = 链表的长度,另一个是对于树节点,binCount = 2
- replaceNode 或者是 clear 则传入的都是 -1
- 可以发现 adCount 方法的形参 check 只有两种形式:binCount 和 -1
-
addCount 方法解析
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
// counterCells 不为空表示之前进行过并发操作,则直接进去 if 判断中使用 counterCells 进行并发增加或减少,后续都以 x=1 进行说明
// counterCells 为空表示之前没有进行过并发操作则,则使用 CAS 对 baseCount + 1,如果成功则退出该 if,如果失败则开始使用 counterCells
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
// uncontended : 不受欢迎的、不建议的,这里表示没有竞争(预想没有)
// 标记是因为 CounterCells 为空才进入 fullAddCount 还是因为 CAS 并发替换失败才进入 fullAddCount
// uncontended = true 即表示由无并发进入,否则为 CAS 并发失败进入
boolean uncontended = true;
// 如果 as 为空
// 或者 as 长度为0
// 或者当前线程所在的段为 null
// 或者在当前线程的段上加数量失败
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
// 注意只有当对 CELLVALUE CAS 失败 uncontended 才会等于 false
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 强制增加数量(无论如何数量是一定要加上的,并不是简单地自旋)
// 不同线程对应不同的段都更新失败了
// 说明已经发生冲突了,那么就对 counterCells 进行扩容
// 以减少多个线程 hash 到同一个段的概率
fullAddCount(x, uncontended);
// 并发导致执行 fullAddCount 后,直接退出,不再考虑扩容检查
return;
}
if (check <= 1)
return;
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 如果元素个数达到了扩容门槛则进行扩容,注意:正常情况下 sizeCtl 存储的是扩容门槛,即容量的0.75倍
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
// resizeStamp 纯粹只是移位来保证右 16 位为 0,可用来控制作为线程最大数
int rs = resizeStamp(n);
// 已经有线程在扩容了,则判断是否加入其中一起进行扩容
if (sc < 0) {
// 情况1、2:限制线程的最大或最小,当达到最大 65535(默认) 或 1 条时,则不再辅助转移直接跳出
// rs + 1 --> 最少线程数(相当于不正确的情况了,因为起始时最少是 rs + 2)
// rs + MAX_RESIZERS --> 最多线程数
// 情况3、4:nextable 已为 null 或 transferIndex <= 0,则不再辅助转移直接跳出
// 前两个条件是限制线程数,后两个条件是扩容已经结束
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
// 扩容未完成,则当前线程加入迁移元素中,并把扩容线程数加1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 第一个触发扩容的线程
// sizeCtl 的高16位存储着 rs 这个扩容邮戳,sizeCtl 的低16位存储着扩容线程数加1,即(1+nThreads),所以官方说的扩容时sizeCtl的值为 -(1+nThreads)是错误的(有懂的人可以在解释下)
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
// 重新计算元素个数
s = sumCount();
}
}
}
协助扩容(helpTransfer)
- 注意:线程添加元素时发现正在扩容且当前元素所在的桶元素已经迁移完成了,才会去协助迁移其它桶的元素
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// ForwardingNode:继承自 Node 且 hash = MOVED 的一种节点类型
// 如果桶数组不为空,并且当前桶第一个元素为 ForwardingNode 类型,并且 nextTab 不为空
// 说明当前桶已经迁移完毕了,才去帮忙迁移其它桶的元素
// 扩容时会把旧桶的第一个元素置为ForwardingNode,并让其nextTab指向新桶数组
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
// sizeCtl < 0 表示正在扩容
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
// 判断线程是否可以加入进行扩容,不可以则直接 break 退出
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;
}
迁移元素(transfer)
- 此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null
- 先要理解并发操作的机制
- 原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用
- 第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// stride 在单核下直接等于 n,多核模式下为 (n>>>3)/NCPU,最小值是 16
// stride 可以理解为”步长“,有 n 个位置是需要进行迁移的,将这 n 个任务分为多个任务包,每个任务包有 stride 个任务
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果 nextTab 为 null 则表示还未进行过迁移,则进行初始化,保证后面的线程进行迁移的时候 nextTab 不为 null
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;
}
// 将创建的新数组 nextTab 赋给 newTable(ConcurrentHashMap 中的属性),保证其他线程进来的时候 nextTab 不为空
nextTable = nextTab;
// transferIndex 指向数组的最后一位
transferIndex = n;
}
int nextn = nextTab.length;
// ForwardingNode 翻译过来就是正在被迁移的 Node
// 这个构造方法会生成一个Node,key、value 和 next 都为 null,关键是 hash 为 MOVED
// 后面我们会看到,原数组中位置 i 处的节点完成迁移工作后,就会将位置 i 处设置为这个 ForwardingNode,用来告诉其他线程该位置已经处理过了
// 所以它其实相当于是一个标志
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 表示索引 i 节点是否被复制成功
boolean advance = true;
// 表示所有节点复制完成
boolean finishing = false; // to ensure sweep before committing nextTab
// 注意这个 for 循环前面部分不用过于纠结,可以看完后半部分再来理解前面的代码
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
// 以这个循环目的就是为了找出允许线程扩容的索引范围 [bound, i]
// 循环结束,advance 为 false,当执行完最后一个 else 中的内容(即完成迁移),advance 又会变成 true,去进行 --i 操作
while (advance) {
int nextIndex, nextBound;
// 满足 [bound, i] 这个区间或者已经完成扩容, 跳出这个循环
if (--i >= bound || finishing)
advance = false;
// nextIndex 是边界 i 的临时保存, 如果小于 0, 说明没有要复制的节点了
// transferIndex 是共享变量, 保存区间范围的上限, 初始值是旧数组长度
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 尝试更新 transferIndex
// 如果成功, 当前线程就负责复制 [nextBound, nextIndex) 范围的节点
// transferIndex 变成 nextBound
// 注意这里 i=nextIndex-1, 所以 [nextBound, nextIndex) 也是 [bound, i]
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 下面开始复制 [bound, i] 范围的节点, 逆序复制, 从 i 开始
// i < 0 :表示扩容结束,已经没有待移动的哈希桶
// i >= n :扩容结束,再次检查确认
// i + n >= nextn : 在使用 nextTable 替换 table 时,有线程进入扩容就会出现
// 完成扩容准备退出
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 两次检查,只有最后一个扩容线程退出时,才更新变量
if (finishing) {
nextTable = null;
// 将新的 nextTab 赋值给 table 属性,完成迁移
table = nextTab;
// 扩容阈值设置为新的桶容量的 0.75 倍
sizeCtl = (n << 1) - (n >>> 1);
return;
}
// 扩容前 sizeCtl 会设置成 resizeStamp(n) << RESIZE_STAMP_SHIFT + 2(在 addCount 中设置了)
// 然后每有一个线程参与迁移就会将 sizeCtl 加 1
// 当前线程扩容完成,把扩容线程数 -1
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
// 不是最后一个线程,直接退出
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
// 执行到这表示有:(sc - 2) == resizeStamp(n) << RESIZE_STAMP_SHIFT
// 也就是说所有的迁移任务都做完了,也就会进入到上面的 if(finishing){} 分支了
finishing = advance = true;
i = n; // recheck before commit
}
}
// 如果桶中无数据,直接放入 ForwardingNode 标记该桶已迁移
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
// 如果桶中第一个元素的 hash 值为 MOVED
// 说明它是 ForwardingNode 节点,也就是该桶已迁移(在最后一个 else 中会将迁移过的位置的节点赋值为 ForwardingNode 类型)
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 锁定该桶并迁移元素
synchronized (f) {
// 再次判断当前桶第一个元素是否有修改,也就是可能其它线程先一步迁移了元素
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// 如果是链表则将一个链表拆分为两个链表,拆分的规则为:
// 桶中各元素的 hash 与桶大小 n 进行与操作,等于 0 的放到低位链表(low)中,不等于 0 的放到高位链表(high)中
// 其中低位链表迁移到新桶中的位置相对旧桶不变,高位链表迁移到新桶中位置正好是其在旧桶的位置加 n
// 这也正是为什么扩容时容量要变成两倍的原因,可以不用重新计算高位连的存放位置,原位置直接 +n 即可
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;
}
// 遍历整个链表,根据 hash&n 分化为两个链表
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);
// 高位链原位置 + n
setTabAt(nextTab, i + n, hn);
// 将该位置设置为 ForwardingNode,即标记当前桶已迁移
setTabAt(tab, i, fwd);
// advance 为 true,返回上面进行 --i 操作
advance = true;
}
// 如果是一棵红黑树,则分化为两棵树,分化规则为:
// 也是根据桶中各元素的 hash 与桶大小 n 进行与操作,等于 0 的放到低位树(low)中,不等于 0 的放到高位树(high)中
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;
// 遍历整颗树,根据 hash&n 是否为 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;
}
}
// 如果分化的树中元素个数小于等于6,则退化成链表
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);
// 高位树的位置是原位置加 n
setTabAt(nextTab, i + n, hn);
// 记该桶已迁移
setTabAt(tab, i, fwd);
// advance 为 true,返回上面进行 --i 操作
advance = true;
}
}
}
}
}
}
删除元素(replaceNode)
public V remove(Object key) {
// 调用替换节点方法
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
// 计算 hash
int hash = spread(key.hashCode());
// 自旋
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 如果目标 key 所在的桶不存在,跳出循环返回 null
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<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 找到目标节点
V ev = e.val;
// 检查目标节点旧 value 是否等于 cv
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
// 如果 value 不为空则替换旧值
if (value != null)
e.val = value;
// 如果 value 为空则是删除操作,如果前置节点不为空则修改前置节点 next 指针指向,来删除当前节点
else if (pred != null)
pred.next = e.next;
// 如果前置节点为空则说明是桶中第一个元素,则将当前节点的 next 节点设置为桶中第一个元素
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
// 遍历到链表尾部还没找到元素,跳出循环
if ((e = e.next) == null)
break;
}
}
// 如果是树节点
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
// 遍历树找到了目标节点
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
// 检查目标节点旧 value 是否等于 cv
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
// 如果 value 不为空则替换旧值
if (value != null)
p.val = value;
// 如果 value 为空则调用 removeTreeNode 方法进行删除
// 如果返回为 true,则表示删除后树中节点数量较少,需要退化成链表
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
}
}
if (validated) {
// 如果找到了元素,返回其旧值
if (oldVal != null) {
// 如果要替换的值为空,表示是删除操作则调用 addCount, Map 中元素个数减 1
if (value == null)
addCount(-1L, -1);
return oldVal;
}
break;
}
}
}
// 没找到元素则返回为 null
return null;
}
获取元素(get)
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());
// 如果元素所在的桶存在且里面有元素
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;
}
// 如果是 eh < 0:表示是红黑树或者正在扩容,则调用 find 方法进行查找获取
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;
}
}
// 没有找到则返回 null
return null;
}
获取元素个数(size)
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
// 计算CounterCell所有段及baseCount的数量之和
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;
}
小结
(1)ConcurrentHashMap 是 HashMap 的线程安全版本
(2)ConcurrentHashMap 采用(数组 + 链表 + 红黑树)的结构存储元素
(3)ConcurrentHashMap 相比于同样线程安全的 HashTable,效率要高很多
(4)ConcurrentHashMap 采用的锁有 synchronized,CAS,自旋锁,分段锁,volatile 等
(5)ConcurrentHashMap 中没有 threshold 和 loadFactor 这两个字段,而是采用 sizeCtl 来控制
(6)sizeCtl = -1,表示正在进行初始化
(7)sizeCtl = 0,默认值,表示后续在真正初始化的时候使用默认容量
(8)sizeCtl > 0,在初始化之前存储的是传入的容量,在初始化或扩容后存储的是下一次的扩容门槛
(9)sizeCtl = (resizeStamp << 16) + (1 + nThreads),表示正在进行扩容,高位存储扩容邮戳,低位存储扩容线程数加1
(10)更新操作时如果正在进行扩容,当前线程协助扩容
(11)更新操作会采用 synchronized 锁住当前桶的第一个元素,这是分段锁的思想
(12)整个扩容过程都是通过 CAS 控制 sizeCtl 这个字段来进行的,这很关键
(13)迁移完元素的桶会放置一个 ForwardingNode 节点,以标识该桶迁移完毕
(14)元素个数的存储也是采用的分段思想,类似于 LongAdder 的实现
(15)元素个数的更新会把不同的线程 hash 到不同的段上,减少资源争用
(16)元素个数的更新如果还是出现多个线程同时更新一个段,则会扩容段(CounterCell)
(17)获取元素个数是把所有的段(包括 baseCount 和 CounterCell)相加起来得到的
(18)查询操作是不会加锁的,所以 ConcurrentHashMap 不是强一致性的
(19)ConcurrentHashMap 中不能存储 key 或 value 为 null 的元素
相关面试题
-
Q1:ConcurrentHashMap 的数据结构是怎么样的?
- ConcurrentHashMap 的数据结构和 HashMap 基本是一样的,都是数组+链表+红黑树,存储数据的单元是 Node 节点,节点结构由 hash,key,value 和 指向下一个节点的 next 指针组成,这个指针的作用是解决 hash 冲突后生成链表的时候使用
-
Q2:ConcurrentHashMap 的负载因子可以指定吗?
- ConcurrentHashMap 的负载因子不可以修改,都是通过
(n >> 2 - n << 1) = 0.75n计算得到的一个定值
- ConcurrentHashMap 的负载因子不可以修改,都是通过
-
Q3:Node 节点的 hash 字段一般情况下为什么要 >= 0?
- 因为 hash 值小于 0 是有其他特殊意义的,有两种特殊的情况
- 在 map 在扩容的时候会触发数据的迁移操作,那么在 i 位置的桶数组迁移完成的时候需要进行标志,标志的节点是 ForwardingNode 节点类型,它的 hash 值为 MOVED = -1,就是小于 0 的
- 当 i 节点的类型是一棵红黑树的时候,在该位置存放的就是 TreeBin 这么一个结构,该结构中的 hash 值为 TREEBIN = -2,就是小于 0 的
- 因为 hash 值小于 0 是有其他特殊意义的,有两种特殊的情况
-
Q4:sizeCtl == -1 的时候代表什么情况?
- sizeCtl == -1 的时候:表示 map 正在初始化,要确保在并发的条件下这个 map 只创建一次
- 在 initTable 方法中通过 CAS 的方式去修改 sizeCtl 的值。CAS 成功的线程会将 sizeCtl 修改为 -1,CAS 失败的线程则会重新进入循环(自旋)去判断是否初始化完成,没有完成则调用 yield 方法放弃当前 CPU,重新去竞争 CPU,让其他更高级别的线程去获得 CPU
-
Q5:初始化完成并且 sizeCtl > 0 的时候代表什么情况?
- 此时的 sizeCtl 表示下次 map 扩容的阈值
-
Q6:sizeCtl != - 1 && sizeCtl < 0 (对于是否真的小于 0 是存疑的,上面的分析是 sizeCtl = 1 + nThreads)代表的是什么情况?
- 表示 map 正在扩容,高 16 位表示扩容标识戳,低 16 位表示当前扩容的线程数加 1,
- 扩容标识戳:每个线程计算的戳一致,能够标识是从同一小表到同一大表的扩容(16 > 32 的扩容)
-
Q7:扩容标识戳的计算方式
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1))- n 是旧的哈希表的长度
- Integer.numberOfLeadingZeros(n):该方法的作用是返回无符号整型 n 的最高非零位前面的 0 的个数,包括符号位在内;如果 n 为负数,这个方法将会返回 0,符号位为 1
- 比如说 16 的二进制表示为 0000 0000 0000 0000 0000 0000 0001 0000,Java 的整型长度为32位,那么这个方法返回的就是 27
- RESIZE_STAMP_BITS = 16,1 << (RESIZE_STAMP_BITS - 1) = 1000 0000 0000 0000
- 最后再进行或运算
- 可以发现这个扩容标识戳是和哈希表的长度强相关的,不同长度的哈希表的扩容标识戳是不一样的
-
Q8:ConcurrentHashMap 怎么保证写数据线程安全
- 当写的数据计算出来的插入的位置 index 没有值的时候则采用 CAS 的方式写入来保证安全
- 当写的数据计算出来的插入的位置 index 有值的时候则采用 synchronized 锁住该节点的方式保证安全
-
Q9:描述一下 hash 寻址算法
- 上面内容有详细讲述
-
Q10:ConcurrentHashMap 是如何统计当前元素个数的?
- 采用的是 LongAddr 的思想来计算的,在 map 内部有一个 baseCount 字段和 CounterCell 数组,通过将 baseCount 和 CounterCell 数组中的值累加起来就是最终的元素个数
- 为什么不使用 AtomicLong 来计算呢?
- 因为 AtomicLong 在并发量比较大的情况下性能不高,他的实现机制是这样的:通过 CAS 的方式去保证一次只有一个线程能够修改成功,那么其他线程都是失败,这样的话其他线程在这次的修改中相当于陪跑了,他们又需要再次去读取最新的值去进行修改,这样看来就有点浪费了 CPU 资源了
-
Q11:LongAddr 的实现机制是怎么样的?
- LongAddr 有两个核心的字段:base 字段和 Cell 数组,Cell 存储的是 long 类型的值
- 使用过程是这样的:当采用 CAS 的方式去修改 base 字段值的时候没有失败过,则只需要数据累加到 base 上即可,用不到 Cell 数组;当 CAS 去修改 base 失败之后,则需要初始化出 Cell 数组,那么后续线程在进行累加的时候则不再首先 base 字段,而是使用 Cell 数组
- Cell 数组的使用是这样的:通过分配给线程的 hashCode 进行和 Cell 数组的长度减 1 与运算得到一个 index,那么该线程就像累加的数据通过 CAS 的方式写到这个位置里面
- 这样做的目的是可以减少并发冲突,并且不影响最终的结果
-
Q12:触发扩容条件的线程,需要执行哪些预操作?
- 修改 sizeCtl:sizeCtl 高 16 位存储的是线程的唯一标识戳,低 16 位存储的是当前扩容线程数加 1,因为这个线程是触发扩容的线程所以直接将低 16 位设置为 2,表示已经有线程在进行扩容了,后续线程则是加入其中帮助扩容
- 创建出新的哈希表,其长度是旧的哈希表的两倍,并且会将新的哈希表的地址赋值给 nextTable 属性,保证全局课件
- transferIndex 最开始记录旧的哈希表的长度,意味着标记着最后一个桶数组,随着迁移的进行,会递减到指向下标为 0 的桶数组
-
Q13:迁移完的桶数组怎么标记的?
- 迁移的时候会创建一个 ForwardingNode 节点对象,如果该位置的节点 hash 为 MOVED 表示该节点是一个 FrowardingNode 类型的节点,用它来指示该位置的桶数组已经迁移完毕
-
Q14:FrowardingNode 节点还有其他功能吗?
- FrowardingNode 节点中有一个指向新表的 nextTable 字段和一个 find 方法,当我们查询的时候碰到节点的 hash 值小于 0 的时候表示正在扩容或者是已经数据已经迁移到新表了,则会调用 find 方法区查询
-
Q15:假设哈希表正在扩容,此时来的写请求会怎么处理?
- 如果插入的数据的位置为空或者是没有处于扩容的状态,那么使用 CAS 或者是 synchronized 上锁进行插入即可
- 如果插入的数据的位置恰好处于扩容状态
((fh = f.hash) == MOVED),那么当前线程就会加入其中帮助一起扩容,而且这个加入也不一定能够成功加入(addCount 方法中有说),加入其中后会根据全局的 transferIndex 规划出来的当前线程的任务区间进行迁移工作,直到 transferIndex <= 0 了才停止迁移表示扩容完成了,那么线程就可以返回到写的逻辑中将数据写入新的哈希表中
-
Q16:扩容期间,扩容线程如何维护 sizeCtl 的低 16 位的?
- 当线程加入扩容,则低 16 位会加 1,如果进入的线程分配不到任务则会退出扩容,就将低 16 位减 1
-
Q17:最后一个退出扩容的线程需要做哪些工作?
- 判断自己是否为最后一个线程(即低 16 位是否等于 1),如果不是的话则直接退出,如果是的话则会完成如下操作
- 将新的 nextTab 赋值给 table 属性,完成迁移
- 将扩容阈值设置为新数组的 0.75 倍,保存到 sizeCtl 中
-
Q18:链表升级为红黑树,且当前红黑树上有读请求,那么再来写请求会怎么做?
- 不能进行写操作,因为写操作可能会导致红黑树失衡,从而触发红黑树的自平衡,那么就不能在一颗在发生变化的红黑树上进行读操作,那么 TreeBin 的一个总的处理方式如下所述
- TreeBin 中有一个 lockState 字段,每个线程在读取数据之前会将 lockState 的值加上 4,读取完之后再将 lockState 的值减去 4,并且减完后又其他操作(下面将)
- 那么写数据在写之前就会检查 lockState 的值是否为 0:为 0 则表示没有读线程,则可以进行写操作,写操作会将 lockState 的值设置为 1 来表示加了排它锁,这样其他线程来了就不能够访问红黑树结构了;不为 0 则表示有读线程,那么写线程会将自己的 Thread 引用暴露到 TreeBin 内,然后让 lockState 的 bit 位的第二个位置设置为 1 来表示有写线程处于等待状态,这个
写线程会使用 LockSupport.park() 将自己挂起 - 那么何时会将写线程唤醒呢?读线程读取完之后再将 lockState 的值减去 4,并判断减去的 lockState 是否为 2(二进制的 10 = 十进制的 2),如果为 2 说明最后一个读线程了已经读取结束了,还有一个写线程在等待,那么
最后一个读线程会使用 LockSupport.unpark() 将写线程唤醒
-
Q19:链表升级为红黑树,且当前红黑树上有写请求,那么再来读请求会怎么做?
- TreeBin 内部还保留了 first 节点这个字段,当 lockState 为 -1 的时候表示有写操作,那么读请求就会到以 first 为头结点的链表中去查找读取