并发编程-ConcurrentHashMap
本篇我们讲述的是 J.U.C 中的并发安全集合——ConcurrentHashMap。那什么是并发安全集合,就是相对于普通集合(例如 HashMap)而言能够保证在多线程环境下向集合中添加元素时的线程安全性
概念
ConcurrentHashMap
是 Java 并发包(java.util.concurrent
)中的一个线程安全的哈希表实现。它提供了与 HashMap
类似的功能,但特别设计用于并发环境,允许多个线程同时读写而不会导致数据不一致或其他并发问题。
以下是 ConcurrentHashMap
的一些核心概念:
- 线程安全:
ConcurrentHashMap
通过其内部复杂的并发控制机制,保证了多个线程在访问和修改映射时的线程安全。这意味着多个线程可以同时读取和写入ConcurrentHashMap
,而不需要额外的同步。 - 分段锁(Java 7 及之前):在 Java 7 及之前的版本中,
ConcurrentHashMap
内部将数据分成多个段(segment),每个段都有自己独立的锁。这种设计允许多个线程并发地修改映射的不同部分,从而提高了并发性能。每个线程在修改映射时,只需要获取相应段的锁,而不是整个映射的锁。 - CAS 和同步控制(Java 8 及之后):从 Java 8 开始,
ConcurrentHashMap
的实现方式发生了重大变化。它不再依赖分段锁,而是使用了基于 CAS(Compare-and-Swap)操作和精细化的同步控制来实现更高的并发性能。CAS 是一种无锁技术,它可以在多线程环境下实现无锁的数据修改,从而避免了线程间的竞争和阻塞。 - 弱一致性:
ConcurrentHashMap
提供了弱一致性的迭代器,这意味着在迭代过程中,映射的内容可能已经发生了改变。这种弱一致性通常是可以接受的,因为它换来了更高的并发性能。如果需要更强的一致性保证,可以在迭代时对整个映射加锁,但这会降低并发性能。 - 可扩展性:
ConcurrentHashMap
能够根据需要进行自动扩展,以适应不断增长的键值对数量。当哈希表的大小不足以容纳更多的数据时,它会自动进行扩容操作,以确保性能不会因为数据量的增加而急剧下降。
总的来说,ConcurrentHashMap
是一个为并发环境设计的哈希表实现,它通过复杂的并发控制机制保证了线程安全,并提供了高性能的读写操作。这使得它在处理大量并发读写请求时非常有用,特别是在需要高并发性能的场景中。
注:本篇中所讲述的 ConcurrentHashMap 内容均是基于 JDK1.8 版本,至于1.7版本 分段锁的内容就不进行分析了(因为当前很少有使用JDK 1.7版本)感兴趣的小伙伴可自行查阅相关资料
使用
对于 Map 集合大家都不陌生,甚至使用的都非常熟练。同样的 ConcurrentHashMap 也是一个集合所以它的基本使用也是 put() 和 get()。这里我们要关注它的几个稍微特别点的方法。
举个例子,假设我们需要通过 ConcurrentHashMap 来记录每个用户的访问次数,对指定用户已经存在访问次数的则加1,否则增加一个新的访问记录,来看一段伪代码
public class ConcurrentHashMapExample {
private static final ConcurrentHashMap<String ,Integer> USER_LOGIN_COUNT = new ConcurrentHashMap<>(16);
public static void main(String[] args) {
Integer loginCount = USER_LOGIN_COUNT.get("张三");
if(null == loginCount){
USER_LOGIN_COUNT.put("张三",1);
}else{
USER_LOGIN_COUNT.put("张三",loginCount+1);
}
}
}
在上述代码中我们对名叫 “张三” 的用户进行登录记录的存储,但是在多线程环境下会存在安全性问题。因为 ConcurrentHashMap 虽然是线程安全的集合,但它仅限于对数据本身的操作,而代码中则是一个 读——修改——写的这么一个操作,整个过程是非原子性的。多线程同时访问时,存储的次数会出现偏差。那怎么处理的这个问题呢,常规操作我们可以加一个锁,但加锁不是我们这次关注的重点。ConcurrentHashMap 提供了另外一种解决方案—— ConcurrentMap
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
private static final long serialVersionUID = 7249069246763182397L;
//。。。。。。代码省略。。。。。。
}
从上述代码来看 ConcurrentHashMap 实现了 ConcurrentMap 接口,而 ConcurrentMap 是一个支持并发访问的集合。相当于在原基础上进行了扩展,ConcurrentMap 提供了下面几个方法
-
computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)
这个方法是判断key是否存在,如果存在,不做任何处理。如果不存在,则通过调用 mappingFunction 这个函数式接口计算出value值,然后存储到 ConcurrentHashMap 中。由于 ConcurrentHashMap 不允许value 为 null 所以就算 mappingFunction 计算的value == null 也不会进行存储
那根据这个方法我们可以把伪代码中的内容做一下修改,如果张三不存在,初始化值设为1 代码可以改为
USER_LOGIN_COUNT.computeIfAbsent("张三",k->1);
-
computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction)
computeIfPresent 跟 computeIfAbsent 的作用刚好相反,它是对已经存在的 key 的 value 进行修改,如果 key 不存在,则返回 null,如果 key 存在,则调用 remappingFunction (也是个函数式接口)进行运算,运算结果存入 ConcurrentHashMap 中。
若 remappingFunction 返回结果不为 null 则修改当前 key 的 value 为 remappingFunction 返回的值。
若 remappingFunction 返回结果为 null,则删除当前的key。
若 remappingFunction 返回结果异常,则不进行任何修改。
根据 computeIfPresent() 的特点,我们可以对之前的伪代码做如下修改
USER_LOGIN_COUNT.computeIfPresent("张三",(k,v)->v+1);
-
compute(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction)
compute() 相当于 computeIfAbsent 和 computeIfPresent 的结合体,不管key是否存在都会调用 remappingFunction 这个函数式接口,如果 key 存在,则调用 remappingFunction 对 value 进行修改 ,若 key 不存在,则调用 remappingFunction 进行初始化
根据这个特点,我们又可以把伪代码进一步的改进,如果 “张三”存在,则通过 lambda 表达式对 value 进行加1的操作,否则直接赋值为1
USER_LOGIN_COUNT.compute("张三",(k,v)->(v==null) ? 1:v+1);
源码分析
在进入源码分析之前,我们要先进行一下剧透,因为 ConcurrentHashMap 的源码整体比较复杂,我们无法进行推导逻辑——验证源码这样一个过程。所以我们需要把前置内容先要有个大致了解,后面源码分析的时候不至于晦涩难懂。我们来看一下 ConcurrentHashMap 的数据结构。
数据结构
在 JDK 1.8 里面 ConcurrentHashMap 采用的是数组+链表+红黑树的方式来进行数据的存储,整体结构如下图所示
我们来解释一个这个结构的含义
- 当调用 ConcurrentHashMap.put(key,value) 方法的时候,ConcurrentHashMap底层是一个数组,会先通过 hash 计算出一个数组下标,并将数据封装成一个 Node 并存储在该位置。
- 在多线程环境下通过 hash 计算数组下标会存在 hash 冲突。所以把计算的得到数组下标相同的 key 构建成一个单向链表
- 链表的长度有限制,当大于某个值时(具体数值我们会在源码分析中说明)将链表转化成红黑树。
- 引入红黑树的设计也是为了提高查找性能
既然 ConcurrentHashMap 的数据结构数组+链表+红黑树 那它到底是怎么定义的呢,我们先大致看一下
定义了什么样的数组
//采用 Node 数组来存储数据,这个我们在 ThreadLocal 中也接触过 Entry 数组也是 Node 构建的
transient volatile Node<K,V>[] table;
Node 有哪些属性
static class Node<K,V> implements Map.Entry<K,V> {
//key 对应的 hash 值
final int hash;
final K key;
volatile V val;
//当是链表结构时,表示指向的是下一个Node节点的指针
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
//。。。。。代码省略。。。。
}
当链表长度大于等于8且Node数组长度大于64,则链表会转化为红黑树,那红黑树是通过TreeNode来表示的
static final class TreeNode<K,V> extends Node<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
//。。。。代码省略。。。。
}
另外 ConcurrentHashMap 为了在性能和安全性之间做好平衡,有一些很巧妙地设计,有如下几个方面
- 分段锁的设计
- 多线程并发协助扩容
- 高低位迁移设计
- 链表转红黑树以及红黑树转链表
熟悉了 ConcurrentHashMap 整体结构之后我们正式进入主题分析它的源码是如何做的。我们 put() 方法入手
put()
public V put(K key, V value) {
return putVal(key, value, false);
}
//putVal是核心方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
//ConcurrentHashMap 为什么 value 不能为空就是因为这里做了判断,为空会抛出异常
if (key == null || value == null) throw new NullPointerException();
//计算hash值
int hash = spread(key.hashCode());
int binCount = 0;
//下面的代码比较长,我们分开来看,首先下面的代码是个自旋操作,这里用自旋是因为涉及到多线程的竞争
//多线程环境下在进行put()操作的时候我们通过cas来实现的,而cas也会有失败的情况。所以通过不断自旋来保证cas成功
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//part1 这里是第一部分 ,主要作用是初始化 table 数组和把key,value封装成 Node 放入table中
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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
}
//part2 这里是第二部分,主要作用是多线程并发迁移
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
//part3 这里是第三部分,构建链表以及链表转为红黑树
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> 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<K,V> pred = e;
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;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
我们先来看part1 第一部分的内容
//如果table为空则初始化table
if (tab == null || (n = tab.length) == 0)
tab = initTable();
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
}
initTable()
在看这个方法之前,我们来捋一下逻辑。假设我们自己写 initTable() 大致要怎么做,是不是可以这样
- 定义一个Node 数组 new Node[16]
- table = new Node[16]
但实际肯定不是这样做的,在多线程的环境下存在多个线程同时调用 initTable() 方法的情况,结合代码的上下文中我们可以看到,截止到
initTable() 方法之前都没有加锁,那不加锁怎么能保证安全性呢,我们就能联想到它是基于cas来实现的。我们从代码里面详细分析
//通过上述分析之后,下面的代码看起来就会通顺很多
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
/**这个 if 条件是用来判断当前是否有其他线程正在初始化,如果有则通过 Thread.yield() 释放CPU资源
我们会看到 下面的代码都是基于 sizeCtl 进行判断的,sizeCtl 有多重含义,稍后我们会详细介绍,这里先
理解为它是一个状态标记
*/
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
//基于 CAS 操作修改 sizeCtl 的值表示抢占到了锁并设置成-1,如果 cas 失败则进入下一次while循环继续重试
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
//cas 成功则进行 table 初始化
if ((tab = table) == null || tab.length == 0) {
//DEFAULT_CAPACITY 默认是16,假设恰好是第一次循环,那么此时sc == 0,那么n == 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
// 构建一个长度为 n 的 Node 数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
//这里计算下一次扩容的阈值,阈值的计算是当前数组容量的0.75倍
//16- (16/4) = 12
sc = n - (n >>> 2);
}
} finally {
//计算出的扩容阈值赋值给 sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
通过以上分析我们发现判断都围绕着 sizeCtl 这个变量,那么它到底代表了什么,我们来解释一下
-
当 sizeCtl = -1 表示当前有线程通过 U.compareAndSwapInt(this, SIZECTL, sc, -1) 这个cas 操作抢占到了锁,正在进行初始化数组。
-
当 sizeCtl = -N 表示用来记录当前参与扩容的线程数,有小伙伴会疑惑怎么还能统计出复数,这里是用的二进制低16位来记录的
-
当 sizeCtl = 0 表示数组还未初始化,并且没有通过构造方法传入容量的值 也就是 new ConcurrentHashMap()
-
当 sizeCtl > 0 如果数组已经初始化即 sizeCtl = sc 那么 sizeCtl 表示扩容阈值。如果 table 还未初始化,则表示数组的初始容量。这里我们可以通过 ConcurrentHashMap 构造方法加深理解
//参数 initialCapacity 最终赋值给 sizeCtl 但此时 table 还未初始化 public ConcurrentHashMap(int initialCapacity) { if (initialCapacity < 0) throw new IllegalArgumentException(); int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY : tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1)); this.sizeCtl = cap; }
我们通过图形的方式表示一下 sizeCtl 的整个流转过程
弄懂了 sizeCtl 之后我们再回到 part1 剩余这部分中来
// n是 table 数组的长度 通过 (n - 1) & hash 计算出 key 对应的数组下标位置
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该位置还没有任何值,则把当前的key和value封装成Node 并通过cas操作放入指定数组下标的位置
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
至此整个 part1 部分的内容就分析完了,主要内容就是table数组的初始化以及初始化完成之后如何加入到数组指定下标位置中。我们还是通过一张图来加深下印象
我们回到主线剧情上来,我们跳过part2 ,先来看part3 部分的内容。我们知道这部分的主要作用是构建链表以及链表转为红黑树,在阅读源码之前,我们有必要补充一下hash冲突的相关内容。
众所周知,我们在进行put() 操作的时候是基于 Key 通过hash函数计算得到的数组下标。那在多线程环境下有可能存在不同的 Key 通过hash函数计算得出相同的数组下标,这种现象就是我们常说的hash冲突,该如何解决hash冲突呢?常见的方法有
- 开放寻址法,例如在ThreadLocal中采用的线性探索(开放寻址中的一种)通俗来讲就是假设 i 位置被占用就去往下查找 i+1,i+2,I+3,i+4…的位置。
- 链式寻址法(这个是ConcurrentHashMap 中使用的)这是解决哈希冲突最常用的方法之一。当哈希冲突发生时,将具有相同哈希值的元素存储在一个链表中,并将链表的头指针存储在哈希表的相应位置。这种方法也被称为“哈希链表。ConcurrentHashMap 中就是在存在hash 冲突的位置上构建一个链表。
- 再哈希法 如果冲突太多,可以考虑使用另一个哈希函数重新计算哈希值。但是,这种方法通常不单独使用,而是与其他方法(如链地址法)结合使用。
接下来我们就来看一看源码中是如何做的。
//这部分代表就是我们拆开的part3的内容
else {
//初始化了一个变量 oldVal 来存储旧值
V oldVal = null;
//对 f 进行加锁 f是table数组中的Node,这里是jdk1.7和jdk1.8中比较显著的区别
// 在jdk1.7中锁的是整个table数组,而在jdk1.8中进行了优化锁的是当前node节点。锁的粒度更细
//16位长度的数据理论上可以支持16个线程并发写入数据
synchronized (f) {
//判断通过计算下标获取到的节点与 f是否相同
if (tabAt(tab, i) == f) {
//fh是 f的hash值,如果 fh >= 0 表示它是一个链表节点
if (fh >= 0) {
//binCount 统计的是链表长度
binCount = 1;
//从链表的头节点开始遍历
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果存在相同的key,则覆盖原来的值
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;
//如果没有相同的key (e = e.next) == null 代表遍历到尾部了
//则把当前的key和value封装成Node 并插入链表的尾部
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
//判断 f 是不是红黑树的节点,这里我们先跳过后面会详细解释红黑树
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;
}
}
}
}
if (binCount != 0) {
//如果链表长度大于等于8 则要进行扩容或转化为红黑树
if (binCount >= TREEIFY_THRESHOLD)
//treeifyBin 扩容以及把链表转化为红黑树的方法
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
整个part3 部分的内容我们通过一个图来简单体现一下
接下来我们来看一下它是如何扩容以及把链表转化为红黑树的
treeifyBin(tab, i)
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// table 不为空并且长度小于 MIN_TREEIFY_CAPACITY(64)则调用tryPresize()进行扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//扩容的长度是 n << 1也就是当前长度的2倍
tryPresize(n << 1);
//else中处理的就是链表转化为红黑树,红黑树我们先跳过
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;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
很明显 tryPresize() 方法就是对 table 数组进行扩容,在看源码之前我们要搞清楚扩容的本质是什么。如果我们来做扩容那应该怎么做呢?
- 创建一个新的数组
- 将原数组中的数据复制到新的数组中
所以无论是其他中间件或者其他工具类中凡是涉及到数组扩容的都是这么一个套路。有了这个思路之后我们再来详细看 tryPresize() 是如何做的。
tryPresize()
//tryPresize() 稍微的复杂一些,我们还是把它拆开来看更容易理解
private final void tryPresize(int size) {
//part1 第一部分
//对需要扩容大小的size 进行判断 MAXIMUM_CAPACITY 是做大扩容值
//如果 size大于等于最大扩容值的一半则直接把size赋值为最大扩容值
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
//如果size < MAXIMUM_CAPACITY/2
//因为扩容的大小必须是 2的幂次方 所以tableSizeFor()是计算当前size的最小的幂次方
//假设 size == 15 则tableSizeFor(15 + (15 >>> 1) + 1) = 16
tableSizeFor(size + (size >>> 1) + 1);
int sc;
//sizeCtl >= 0 表示要做数组的初始化
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
/**part2 第二部分
*看这一部分代码我们会感觉非常的眼熟。没错,在初始化table数组的时候也是这段代码
*那为什么这里还会判断 table是否初始化呢?是因为tryPresize() 这个方法不光是在这个地方调用
*在 putAll() 方法中也调用tryPresize()方法
public void putAll(Map<? extends K, ? extends V> m) {
tryPresize(m.size());
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putVal(e.getKey(), e.getValue(), false);
}
*/
if (tab == null || (n = tab.length) == 0) {
//初始容量和扩容的目标容量比较,谁最大选谁
n = (sc > c) ? sc : c;
//下面的初始化动作就不再重复解释了
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
// part3 第三部分
// 当扩容的目标容量小于初始化容量证明已经完成扩容,不需要再扩容了
// MAXIMUM_CAPACITY 是最大容量的值,当 n >= MAXIMUM_CAPACITY 时说明已经是最大容量,不能再扩容了
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
// part4 第四部分
// 这一部分涉及到多线程并发协助扩容的开端
// 这一部分涉及到的常量计算比较多,小伙伴们看起来可能比较懵,我们先建立整体概念,再详细剖析
else if (tab == table) {
//计算得到扩容戳. 保证当前扩容范围的唯一性.
int rs = resizeStamp(n);
//sc < 0 也就是 sizeCtl<0 表示当前有其他线程正在扩容
if (sc < 0) {
Node<K,V>[] nt;
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);
}
}
}
在上述代码中 part4 这一部分需要单独详细剖析一下,我们从 resizeStamp(n) 开始,我们已经知道了它是作为一个扩容戳,保证扩容时的唯一性。那它是怎么计算并且与后续逻辑之间有什么联系呢,我们来看一下它是如何进行计算的
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
Integer.numberOfLeadingZeros(n) n是 table 数组的长度,该方法的计算结果是返回无符号整数 n 最高非 0 位前面的 0 的个数。什么意思呢,假设 n 是 10 那么转化成二进制是 1010(Java语言中,int类型占用的二进制位数是32位)所以完整二进制是
0000 0000 0000 0000 0000 0000 0000 1010 很显然最高非 0 位前面的 0 的个数也就是 1010 前面 0 的个数即 28。
(1 << (RESIZE_STAMP_BITS - 1) 中 RESIZE_STAMP_BITS 为16 计算结果为 32768(1<< 15 左移15位 )。众所周知按位或运算符 |
对两个整数的二进制表示执行“或”操作。对于每一对相应的二进制位,只要其中一个位是1,结果的那个位就是1。那么它的计算过程应该如下
int a = 28; // 二进制 0000 0000 0000 0000 0000 0000 0001 1100
int b = 32768; // 二进制 0000 0000 0000 0000 1000 0000 0000 0000
int c = a|b; // 结果为 32796 二进制 0000 0000 0000 0000 1000 0000 0001 1100
有了上面这些基础铺垫之后,我们来看当第一次进行扩容的时候怎么做
else if (U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2)){
transfer(tab, null);
}
第一次扩容,通过U.compareAndSwapInt(this, SIZECTL, sc,(rs << RESIZE_STAMP_SHIFT) + 2) 判断 SIZECTL 与 sc 偏移量相同将 SIZECTL 修改为 (rs << RESIZE_STAMP_SHIFT) + 2 的值。看到这里有的小伙伴可能有点懵,这加2是什么意思,其实这里是运用的高低位存储,高位(高 16位) 存储扩容标记,低位(低 16位) 存储扩容线程的数量。具体是怎么做的呢?我们通过一个例子来套数推导一下。
假设 n == 16 那么通过 resizeStamp() 方法我们可以得出扩容戳的值即 rs = 32795。在(rs << RESIZE_STAMP_SHIFT) + 2 中先计算 rs << RESIZE_STAMP_SHIFT 也就是 32795 << 16(注:这个值的计算理论上我们会得到一个正数,但因为有二进制补码表示法,所以在程序执行时会是一个负数)32795 的二进制为 0000 0000 0000 0000 1000 0000 0001 1011 我们把它左移的16位后就变成了 1000 0000 0001 1011 0000 0000 0000 0000 然后再进行加2(2的十进制是10)也就是 1000 0000 0001 1011 0000 0000 0000 0010 。
高16 位 1000 0000 0001 1011 表示扩容标记。低16位 0000 0000 0000 0010 表示扩容线程的数量。有小伙伴可能会有疑问这个地方为什么用加2来记录。这里的“+2”可能是一个设计选择,用于区分“没有线程在扩容”(可能是0或1,取决于具体实现)和“至少有一个线程在扩容”的状态。使用2而不是1作为起始值可能有助于避免与某些特殊值(比如1的二进制还是1)的混淆。
基于以上的逻辑分析,我们也就知道了为什么 sc<0 表示有其他线程正在扩容。因为sc(也就是sizeCtl) 通过位运算得到的是个负数。就表明了不是第一次进行扩容了且已经有线程正在扩容。并通过U.compareAndSwapInt(this, SIZECTL, sc, sc + 1) 在低位增加一个扩容线程。
分析到这里我们还没有看到具体的扩容的动作(比如创建新的数组)。接下来我们会对多线程如何进行协助扩容作分析,在这之前我们要对这个整体过程有个大致的猜想。在前面部分我们也清楚了扩容的本质在这基础之上多个线程又是如何同时进行扩容这项工作的呢。
- 多线程进行并发协助扩容时,每个线程应该负责指定区间内的数据迁移工作(通俗来说就好比一个项目每个人都开发属于自己的那部分功能)
- 记录当前的线程数量
- 当每个线程数据迁移完成之后,要减掉协助扩容的线程数量
我们通过一个图来转述一下这个扩容动作的大致含义
当多线程并发协助扩容以及数据迁移时,会给每个线程分配一个区间,这个区间的值默认为16 (假设 数组长度为64,那么线程A进行数据迁移的区间为 [48,63],线程B迁移的区间范围则为[32,47])而每个区间开始所在的数组下标位置用 transferIndex 来表示。假设当前只有线程A和线程B两个线程进行迁移,那么长度为64的数组则需要进行多次迁移,每次迁移的起始位置就是 transferIndex 。
我们来详细看 transfer() 这个方法
transfer()
整个方法看起来非常的长,要想一行一行读下来真的需要勇气。我们还是拆开进行解读
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//stride 是每个线程处理的区间长度默认是16。通过 NCPU(cpu核心数)来计算。
//目的是让每个CPU处理的数据区间相同,避免出现数据迁移任务分配不均匀的情况。
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//part1 第一部分,当第一次进行扩容时
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//这里就验证我们的猜想,构建一个新的数组且数组的长度时原来的2倍(n<<1),假设n=16 那么新数组的长度是32
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 标志这次扩容结束
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
//transferIndex = 原table数组的长度
transferIndex = n;
}
int nextn = nextTab.length;
//ForwardingNode 需要注意一下,这里表示已经迁移完的一个标记,表明已经处理过了
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//part2 第二部分
//我们在一开始的迁移猜想中提到过它一次迁移默认区间的大小是16,假设数组长度是16的倍数,且线程数不足以一次性迁移完
//那么肯定需要进行多次迁移。所以这里用了一个无限循环,当完成动作时 必然有return退出
//整个迁移的过程都是在这个for循环中来完成,我们继续拆开来看
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//这里是判断数组区间是否分配完成或者整个迁移过程是否完成
if (--i >= bound || finishing)
advance = false;
//这里是个特殊情况下值的一个判断,需要注意的是 nextIndex = transferIndex 这里已经赋值给了nextIndex
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//数组区间分配的核心,假设数组长度是64,当线程A第一次进行迁移通过U.compareAndSwapInt 运算得到
// nextBound = 48(nextIndex - stride = 64-16)
// i= 31(nextIndex - 1 = 64-1)
// 线程A 负责的迁移区间是 [48,63]
// 同理线程B 负责的迁移区间就是[32,47]
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
}
}
//part3 第三部分 更新扩容的标记
//判断当前数组位置i节点的值是否为空
else if ((f = tabAt(tab, i)) == null)
//如果为空则不需要迁移并标记为ForwardingNode
advance = casTabAt(tab, i, null, fwd);
//如果当前i位置节点的hash值 等于 MOVED 说明当前节点已经被迁移,继续往下遍历
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//part4 第四部分,这里才是开始数据迁移
//当前需要迁移的节点加synchronized 锁,避免多线程竞争
//整个synchronized 代码块中的代码比较长,我们还是先有大致了解,再逐步分析细节
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
//从整体代码结构来看,这个代码快中分了两大部分
//fh >= 0 表示f节点为链表或者普通节点,这里是专门处理链表或者普通节点的逻辑
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;
}
//若 f instanceof TreeBin则表示 f节点是红黑树。很明显下面就是处理红黑树的逻辑
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;
}
}
}
}
}
}
在详细解析 part4 部分的代码之前,我们要先想明白一件事情,ConcurrentHashMap 底层的数据结构是一个数组,那么当调用 put() 方法也就是等同于往数组中添加元素,数组下标是如何计算出来的呢。在 putVal() 方法中是通过 (n - 1) & hash 计算得出的数组下标。其中hash是 key的hashCode ,n是数组的长度。同理如果数组扩容之后,数组长度是原来的2倍也就是n=2n。那么再用 (n - 1) & hash 去计算下标位置可能会得到不同的结果。我们还是通过画图来举例说明一下
上图中在数组下标位置为4的链表的hash值(4,20,52,68,84,100) 。当数组扩容成32 时再用链表上的hash值分别进行计算,可以得出20,52,84 这三个hash值计算出的数组下标不为4。这就说明,在扩容时原链表中有一部分节点的数据不需要进行迁移,一部分节点需要进行迁移。那具体是怎么做的呢?在ConcurrentHashMap 中采用了高低位整体迁移的方式来进行数据迁移,我们去代码中一探究竟
synchronized (f) {
if (tabAt(tab, i) == f) {
// ln (代表low nodes,即低位)和hn(代表high nodes,即高位链)
Node<K,V> ln, hn;
if (fh >= 0) {
//这里的runBit 可以理解为区分低位还是高位的状态
int runBit = fh & n;
//lastRun 通过上下文我们可以理解为是遍历链表的一个结束位置
Node<K,V> lastRun = f;
//遍历链表
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
//遍历链表过程中判断通过 p.hash & n 计算得到值与runBit 进行比较(具体作用我们后续会详细解释)
if (b != runBit) {
//赋值 runBit
runBit = b;
//当前节点赋值 lastRun
lastRun = p;
}
}
// runBit == 0 代表低位链,反之代表高位链
// 低位链代表不需要迁移的数据即数组扩容后下标位置不变
// 高位链代表需要迁移的数据即数组扩容后下标位置发生变化
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
//再次遍历链表,遍历截至到lastRun
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
//再次通过 ph & n 计算区分出是低位链还是高位链
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) {
//。。。。红黑树部分代码暂时省略
}
}
}
小伙伴们第一次看这段代码可能比较懵,会产生两个疑问
(1) runBit 和 lastRun 的作用是什么?
(2) ln 和 hn 又是怎么划分出来的?
初次看此段代码确实比较晦涩。我们还是把这段代码分开解读。我们先来看一下 runBit 和 lastRun 的设计思想
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;
}
}
上述代码表示通过for循环遍历链表,计算出当前链表最后一个需要迁移或者不需要迁移的节点位置。我们通过图形来描述一下这个过程
正如图中所示,runBit = 4&16 = 0 。继续往下遍历链表计算 b = p.hash & n (20&16) ,判断 b 和 runBit 是否相等,若不相等则记录当前的节点为 lastRun,runBit = b 直到链表遍历完。需要注意一点,lastRun记录的是最后一个需要迁移或者不要迁移的节点,这说明 lastRun可能不是链表的最后一个节点,而是指节点后续不存在runBit变化的节点中最靠前的那个节点。假设在图中100这个节点后面还存在 b=0;runBit=0 的节点,那么 lastRun就还是100 对应的节点。这么做的原因是因为后续如果存在连续节点计算得到的 runBit 相同。那么 lastRun 后面的节点本身就是链表,所以在区分高低位链时就可以遍历到 lastRun 。从而减少遍历次数。我们还是通过图来解释一下
我们再来看一下 高低位链 ln 和 hn 是怎么构建出来的
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);
}
当 runBit == 0 说明 lastRun位置的节点通过计算 p.hash & n = 0,表示该节点在扩容后的数组中仍然在该位置。所以不需要进行迁移并把该节点赋值给低位链 ln,反之则把该节点赋值给高位链 hn。
紧接着再次遍历整个链表,只不过不用全部遍历,只需要遍历到lastRun之前的即可。同样去计算 ph & n 是否等于0,如果等于0 则并将这个新节点的 next 指针指向 ln 链表当前的头部 ln 。然后,将 ln 更新为这个新节点,使其成为新的链表头部。反之,不等于0 hn 进行同样的操作。这里是运用的头插法,这种头插法的好处是添加操作的时间复杂度是 O(1),因为总是将新节点连接到头部。转化之后的高低位链像如下图所示
进行迁移,将构建好的高低位链一次性放入新的数组中,提高了效率
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
i+n 是高位链所在数组的下标位置,迁移之后的效果如下图所示
了解完以上迁移操作之后,我们来看迁移完需要做什么
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
//如果 finishing == true 说明已经扩容迁移完成
if (finishing) {
//nextTable 赋值为null(扩容完之后这个变量就不需要了)
nextTable = null;
//将新数组赋值给 table
table = nextTab;
//修改sizeCtl 此时sizeCtl>0
sizeCtl = (n << 1) - (n >>> 1);
//扩容迁移彻底结束
return;
}
// 执行到这里也就是 finishing == false,说明还有其他线程正在执行中且当前线程已经完成了扩容迁移。
// 所以通过 U.compareAndSwapInt() 操作修改sizeCtl的值,即低位线程数量减1
// 这也验证了我们一开始对扩容的猜想
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
//如果(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT == true
//说明其他线程扩容迁移还没有完成,当前线程不需要任何操作
//如果(sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT == false
//则说明其他线程已经完成了数据的扩容迁移,但还没有进行 table = nextTab赋值
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
//当前线程把 finishing 状态改为 true表明扩容迁移完成
finishing = advance = true;
i = n; // recheck before commit
}
}
至此,除了红黑树的部分,整个transfer() 方法我们就分析完了。这个方法比较长,乍一看会比较头疼。经我们逐步拆分之后会清晰很多。我们再来看一下get() 方法
get()
get() 方法相对就比较简单了
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算得到key的hash值
int h = spread(key.hashCode());
//判断table以及 通过(n - 1) & h 得到的下标的节点是否存在
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//如果节点的hash值和计算key的hash值相同,并且key相同则返回对应的value
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果eh < 0 则说明当前节点是个红黑树的根节点
else if (eh < 0)
//通过find方法找到树中对应的节点并返回value
return (p = e.find(h, key)) != null ? p.val : null;
//不是红黑树,也不是单独的节点,那就只能是链表,不断遍历链表,直到key相同返回value
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() 方法,集合中得元素就会增加一个,对应得元素个数就得加1。这样我们才能通过 size() 方法获取到集合中元素得个数 。那是如何进行统计的呢?在 ConcurrentHashMap 中putVal() 方法中有这样一行代码 addCount(1L, binCount)
final V putVal(K key, V value, boolean onlyIfAbsent) {
//。。。。前面的代码省略
//这个方法就是将元素的个数加1
addCount(1L, binCount);
return null;
}
在进行 addCount() 分析源码之前,我们来整理下思路。在常规集合中只需要一个int类型的全局变量来作为统计的个数,每次进行put操作该int类型变量加1即可。但是 ConcurrentHashMap 会存在在多线程同时调用put() 方法的场景,那如何保证对统计个数全局变量的线程安全性呢?这里我们提前剧透一下,ConcurrentHashMap 是通过自旋锁的设计来保证统计元素个数的安全性的。
我们先要来理解它的设计理念,否则直接看源码会非常的头疼。我们得先找到存储个数的变量
//我们通过size() 进行查找
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
//在 sumCount() 这个方法中我们找到了 CounterCell 和 baseCount
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;
}
很显然用来存储元素个数的两个变量分别是 CounterCell[] 和 baseCount。我们来看看它们的注释是怎么说的
/**
* Base counter value, used mainly when there is no contention,
* but also as a fallback during table initialization
* races. Updated via CAS.
* 粗略翻译过来是:基础计数器值,主要用于没有竞争的情况,但也作为表初始化过程中竞态条件的回退选项。通过CAS(Compare-and-Swap)进行更新。
*/
private transient volatile long baseCount;
/**
* Table of counter cells. When non-null, size is a power of 2.
粗略翻译过来是:这是一个计数单元。当非空时,其大小是2的幂。
*/
private transient volatile CounterCell[] counterCells;
在 ConcurrentHashMap 中采用以上两只方式保存元素个数
- 在线程竞争不激烈的情况下,直接通过cas操作使 baseCount+1 来增加元素个数(源码中注释也提到过)
- 在线程竞争比较激烈的情况下,cas操作可能会经常失败。此时会构建一个 CounterCell 数组,默认长度是2,通过随机选择一个counterCells,对其中保存的value+1
这里我们把这个设计理念转换成图形更便于理解,如下图所示
有了上述的设计思想推导之后,我们再来仔细看 addCount() 方法
addCount()
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
//从代码结构上看,整个方法我们可以分为两个部分,也就是两个if代码块
// 判断条件中 counterCells != null 或者U.compareAndSwapLong()==false 则证明竞争激烈
// 反之 U.compareAndSwapLong()==true 则证明竞争不激烈,直接通过cas修改 baseCount
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
/**下面这个if判断中有一个条件成立就会调用执行 fullAddCount() 方法
* as == null 和 (m = as.length - 1) < 0 都是代表CounterCell 还未初始化
* (a = as[ThreadLocalRandom.getProbe() & m]) == null 证明 CounterCell[] 已经创建了,但数组中没有具体的 * 实例。
* (uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) 表示 CounterCell数组已经 * 创建了。
* 通过cas操作去更改CounterCell中存在的value如果这个条件结果为false则证明竞争仍旧很激烈
* 调用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;
//统计CounterCell 与 baseCount的总数
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();
}
}
}
fullAddCount()
这个 fullAddCount() 一眼看上去又比较长且判断比较多,没有看下去的欲望。所以我们基于前面的理解来猜测一下这个方法要干什么
- 构建 CounterCell[] 数组,如果没有初始化则创建数组
- 如果 CounterCell[] 数组已经初始化完成,则随机选择改数组中的一个元素对其value值进行累加
- 如果还是存在线程竞争比较激烈得情况,则尝试对CounterCell[] 数组进行扩容
//基于上面的推导我们还是将代码拆分来看,先总体情况进行概述再分析细节
private final void fullAddCount(long x, boolean wasUncontended) {
// h是一个随机数,用来计算得到一个数组下标从而获取CounterCell[]数组中的元素
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
//如果 h ==0 则强制进行初始化
ThreadLocalRandom.localInit(); // force initialization
h = ThreadLocalRandom.getProbe();
wasUncontended = true;
}
boolean collide = false; // True if last slot nonempty
//我们从这个for循环和下面代码中的cas操作中就能看出这又是个自旋锁
for (;;) {
CounterCell[] as; CounterCell a; int n; long v;
//part 1 第一部分
//主要功能是 CounterCell[] 已经初始化完成,进行value累加以及并发情况下 CounterCell[] 的扩容
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
else if (!collide)
collide = true;
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
}
h = ThreadLocalRandom.advanceProbe(h);
}
//part 2 第二部分 CounterCell[] 数组的初始化
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;
}
//part 3 第三部分
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
我们顺着之前的猜想思路走,CounterCell[] 数组初始化要怎么做?part 2 部分就是初始化的操作,我们来看这一部分的代码。
在part 2部分中有个比较重要的变量 cellsBusy,它是用来区分是否加锁的标记,源码中是这样描述的
/**
* Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
* 大致翻译为:调整大小和/或创建CounterCells时使用的Spinlock(通过CAS锁定)。
*/
private transient volatile int cellsBusy;
我们正式进入这一部分代码的分析
//在CounterCell[] 未初始化时,此时cellsBusy == 0
//通过cas操作去尝试获取锁,如果cas成功则cellsBusy == 1
else if (cellsBusy == 0 && counterCells == as && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try {
if (counterCells == as) {
//创建一个新的CounterCell[] 数组 rs 且长度是2(默认长度)
CounterCell[] rs = new CounterCell[2];
//创建一个包含value值(x)的对象放入 rs[h & 1]位置
rs[h & 1] = new CounterCell(x);
//rs 赋值给 CounterCell[]
counterCells = rs;
//标记初始化完成
init = true;
}
} finally {
//释放锁
cellsBusy = 0;
}
if (init)
break;
}
CounterCell[] 数组已经初始化完成,则随机选择改数组中的一个元素对其value值进行累加。我们看源码中是如何做的(part 1 部分)
//判断 CounterCell 是否初始化完成
if ((as = counterCells) != null && (n = as.length) > 0) {
// 如果通过as[(n - 1) & h] 获取到数组中的元素为空
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) { // Try to attach new Cell
//创建一个包含value值(x)的CounterCell对象 r
CounterCell r = new CounterCell(x); // Optimistic create
//通过cas 操作获取锁 把cellsBusy改为1
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 &&
// 通过 (m - 1) & h 计算出元素为空的数组下标位置
rs[j = (m - 1) & h] == null) {
//把 r 赋值到CounterCell[]数组中
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
// 如果通过as[(n - 1) & h] 没有获取到数组中元素为空的情况则直接把该位置的value 通过cas操作进行累加
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
//......代码省略
}
如果还是存在线程竞争比较激烈得情况,则尝试对CounterCell[] 数组进行扩容。我们来看CounterCell[] 是如何进行扩容的
// CounterCell 的扩容就相对比较简单了
// 同样都是先去获取锁
else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {// Expand table unless stale
//构建一个新的数组,长度是原数组长度的2倍
CounterCell[] rs = new CounterCell[n << 1];
for (int i = 0; i < n; ++i)
//遍历原数组,把原数组的数据赋值到新的数组
rs[i] = as[i];
// rs 赋值给 CounterCell
counterCells = rs;
}
} finally {
//释放锁
cellsBusy = 0;
}
collide = false;
continue; // Retry with expanded table
}
整个 fullAddCount() 方法就分析完了,理解了其中的设计思想后再拆分来看整体也不是很复杂。最后我们再看一下汇总的方法
sumCount()
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)
//遍历 CounterCell[] 数组,获取到其中的value 与 baseCount 进行累加
sum += a.value;
}
}
return sum;
}
红黑树
在前面的源码分析中我们都是基于链表的结构来分析,红黑树也是 ConcurrentHashMap 非常重要的数据结构, 之前我们一直都跳过红黑树部分是因为直接解释红黑树比较抽象,形成不了对红黑树的整体认知。红黑树最主要的是要理解它的规则。首先我们得先认识一下红黑树
什么是红黑树
红黑树是一种特殊的二叉查找树(特殊的平衡二叉树),其中每个节点都包含一个颜色属性,颜色为红色或黑色。这种数据结构通过颜色和特定规则的约束,在插入、删除等操作时保持树的平衡性,从而确保所有操作的平均时间复杂度为O(log n),其中n是树中元素的数量
二叉树与平衡二叉树
说到红黑树,就不得不提二叉树与平衡二叉树。这里我们不对这两个树展开篇幅作分析,我们从它们的特征和缺点来入手,逐步理解红黑树的规则。我们先来看二叉树
二叉树
二叉树(Binary Tree)是一种树形数据结构,其中每个节点最多有两个子节点,通常被称为左子节点和右子节点。左子节点的值比父节点小,而右子节点的值比父节点大 。如果二叉树的左子树不为空,则左子树的所有节点的值均小于根节点。反之,如果二叉树的右子树不为空,则右子树的所有节点的值均大于根节点。根据这些特征规则我们就能得到如下的树
按照这个规则,理论上随着节点的插入只要满足二叉树的条件就可以直接加入的树中,极端情况下,如果插入的节点的值均大于根节点时,会形成一个链表,这大大增加了数据操作时的时间复杂度。如下图所示
造成这种情况的本质是二叉树没有一个自动平衡的机制。由此我们就引出平衡二叉树
平衡二叉树
平衡二叉树最主要的特点就是左子树和右子树高度差的绝对值不超过1。所以无论加入多少节点,依据这个特点,树的结构仍然能保持平衡。看似平衡二叉树能解决二叉树存在的问题,但是平衡二叉树为了满足绝对的平衡就需要通过节点的旋转来保持。那么整个平衡的过程所用的时间就会增多,效率大大降低。为了在效率和平衡之间做权衡引入红黑树。而红黑树则是在原来规则的基础上放宽限制,所以它也被叫做特殊的平衡二叉树。
红黑树的规则
- 红黑树的每个节点必须是红色或者黑色
- 根节点的颜色是黑色
- 如果当前节点的颜色是红色,那么它的子节点必须是黑色
- 所有的叶子节点(包含NIL节点,NIL节点表示叶子节点是空的节点)的颜色都是黑色
- 从任一节点到其每个叶子节点的所有简单路径都包含相同数目的黑色节点。
以上这些规则我们汇总到图形中来
众所周知,我们对任何一个树结构的增删改操作都会影响树的平衡。同理,在红黑树中数据的操作会破坏红黑树的平衡规则。若要保持平衡则需要对节点进行左旋、右旋以及变色的操作。
左旋
以某个节点作为旋转节点,其右子节点变为旋转节点的父节点,右子节点对应的右子节点保持不变,旋转节点的左子节点保持不变。右子节点对应的左子节点变为旋转节点的右子节点。这是怎么一个变化过程呢?我们还是通过图形来演示一下
右旋
以某个节点作为旋转节点,其左子节点变为旋转节点的父节点,左子节点对应的左子节点保持不变,旋转节点的右子节点保持不变。左子节点对应的右子节点变为旋转节点的左子节点。过程如下图所示
变色
是指节点的颜色由黑色变成红色或者由红色变为黑色,从而保证红黑树的平衡,通常发生在插入或删除节点时。当红黑树插入新的节点时,一般将新加的节点设置为红色原因是红色节点破坏原红黑树的平衡的可能性小。但可能破环红黑树的其他特性,所以通过将节点变色来解决。
举例说明
趁热打铁,在熟悉了红黑树的基本规则之后,我们通过一组具体的数据来模拟一下红黑树是如何进行平衡的。假设有这样一组数据:20、8、29、2、13、11、12、7 需要加入到红黑树中,我们根据红黑树的规则来做平衡
-
根节点必须是黑色,把20作为根节点。8和29正好满足二叉树的规范作为红色节点,此时整个树是平衡状态,不需要调整
-
继续加入新节点2(红色),此时它的父节点8 和 叔叔节点 29均为红色,不满足 如果当前节点的颜色是红色,那么它的子节点必须是黑色的规则,我们需要将 8 和 29 这两个节点变为黑色,同时新节点2 作为8的左子节点
-
继续加入新节点 13(红色)此时的红黑树处于平衡状态且 13 满足二叉树的规范作为节点 8 的右子节点
-
继续加入新节点 11(红色)根据二叉树的规范,新节点 11 作为节点 13 的左子节点。此时节点 11 的父节点 13 与叔叔节点 2 都为红色不满足红黑树的规则。所以我们要对节点进行变色,先把节点 13与节点 2 都变为黑色。但这时节点 8 是黑色,需要把节点 8 变为红色(这里参照的原则是当父节点和叔叔节点都为红色时,先把父节点和叔叔节点变为黑色,再把祖父节点变为红色。其实就是把不平衡的问题往上推交给祖父节点,如果祖父节点因为这个变化又导致了新的不平衡,重复这个过程继续调整)。过程如图所示
-
最后加入新节点 12 (红色),先根据二叉树的规范新节点 12 会作为节点 11 的左子节点。此时不满足规则 “如果当前节点的颜色是红色,那么它的子节点必须是黑色” 也就是说不能出现连续的红色节点。我们需要对节点进行旋转,旋转分为两步
第一步:以新节点 12 的父节点 11 作为旋转节点进行左旋,左旋之后,节点 12 就变成了节点 11 的父节点。并且把节点 12 变成黑色。
第二步:此时节点 13、12、11 还没有完全平衡,根据二叉树的规范我们还需要再次旋转。以节点 13 为旋转节点进行右旋,旋转之后节点 12 作为节点 11 和节点 13 的父节点,左子节点 11 保持不变,右子节点 13 由黑色变为红色。
以上就是红黑树如何保持平衡的简单操作,红黑树的平衡规则有很多,无论怎么变化都离不开那 5 条基本规则,基本规则+二叉树的规范就是理解红黑树平衡的最核心的理念。感兴趣的小伙伴可自行研究(如删除节点保持平衡)这里就不做过多描述了。
回到代码
在摸清楚了红黑树的来龙去脉之后,我们还是需要回归代码。看代码中是如何构建红黑树的,之前在 putVal() 方法中我们看到这样一段代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
//........代码省略.........
if (binCount != 0) {
//众所周知,链表转红黑树的条件是 链表长度大于等于8,且数组长度大于64
//如果不满足条件,优先对数组进行扩容
if (binCount >= TREEIFY_THRESHOLD)
//treeifyBin() 方法既有扩容又包含了红黑树的处理
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
//........代码省略.........
return null;
}
来看 treeifyBin() 方法中红黑树的部分
treeifyBin()
private final void treeifyBin(Node<K,V>[] tab, int index) {
//.......代码省略.........
//判断index这个位置数据是否为空
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
//加锁保证安全性
synchronized (b) {
//再次验证为了确保在获取锁和开始处理链表之间,没有其他线程修改
if (tabAt(tab, index) == b) {
//hd 是头节点,tl是尾节点
TreeNode<K,V> hd = null, tl = null;
//遍历 index 位置上的单向链表
for (Node<K,V> e = b; e != null; e = e.next) {
//把当前链表中的数据封装成一个TreeNode
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
//参照 TreeNode 的属性可以得知构建的是一个双向链表
//如果当前的节点p prev指向为null,则p是头节点
if ((p.prev = tl) == null)
hd = p;
else
//如果当前的节点 prev指向不为null 则证明 p不能作为头节点,把双向链表尾节点的next 指向p
tl.next = p;
//将尾节点更新为 p,进入下一次循环时,将下一个新节点添加到链表的末尾
tl = p;
}
// 此时还没有转化为红黑树,通过 TreeBin 进行转化
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
在看 TreeBin 之前,我们还是先来了解一下 TreeNode 和 TreeBin 里面都有什么属性
TreeNode
// TreeNode 继承自 Node
static final class TreeNode<K,V> extends Node<K,V> {
//指向父节点的链接
TreeNode<K,V> parent; // red-black tree links
//指向左子节点的链接
TreeNode<K,V> left;
//指向右子节点的链接
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
//标记节点为红色或黑色
boolean red;
TreeNode(int hash, K key, V val, Node<K,V> next,
TreeNode<K,V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
//......代码省略.......
}
TreeBin()
TreeBin 同样也继承 Node ,我们看一下它的这些属性是什么含义
static final class TreeBin<K,V> extends Node<K,V> {
// root 表示根节点
TreeNode<K,V> root;
//first 代表 TreeNode 构成的链表的头节点
volatile TreeNode<K,V> first;
//记录最近一个获得锁的线程
volatile Thread waiter;
// 看名字也能猜个大概——锁的状态
volatile int lockState;
// 写锁状态
static final int WRITER = 1;
//等待获取写锁状态
static final int WAITER = 2;
// 读锁状态
static final int READER = 4;
//......代码省略........
}
链表是如何转化成红黑树的,我们在treeifyBin() 看到它是通过new TreeBin<K,V>(hd) 也就是 TreeBin 的构造方法来实现的,接下来我就来看一下这个方法
TreeBin(TreeNode<K,V> b) {
super(TREEBIN, null, null, null);
this.first = b;
//表示红黑树的根节点
TreeNode<K,V> r = null;
//遍历 b节点开头的链表(也就是之前传入的hd)
for (TreeNode<K,V> x = b, next; x != null; x = next) {
//链表头节点的下一个节点
next = (TreeNode<K,V>)x.next;
//把当前这个节点的左子节点和右子节点先设为null
x.left = x.right = null;
//如果r == null证明还没有根节点,则此时的x就作为根节点
if (r == null) {
//根节点没有父节点,所以设置为null
x.parent = null;
//节点变成黑色
x.red = false;
//赋值根节点r
r = x;
}
//下面这部分代码就是在存在根节点的情况下,新节点加入到红黑树中
else {
//当前节点的key
K k = x.key;
//当前节点的hash值
int h = x.hash;
Class<?> kc = null;
//从根节点开始遍历
for (TreeNode<K,V> p = r;;) {
// dir从全局角度来看是用来判断加加入到左子树还是加入到右子树
//根据二叉树的规则,所以左子树的节点均小于根节点,右子树的节点均大于根节点
int dir, ph;
K pk = p.key;
//如果 ph>h,则证明应该加入到左子树,赋值 dir = -1
if ((ph = p.hash) > h)
dir = -1;
//如果 ph<h,则证明应该加入到右子树,赋值 dir = 1
else if (ph < h)
dir = 1;
//下面这个判断是用来解决hash冲突的
//首先比较哈希值,然后(在哈希值相等的情况下)比较键
//最后(在键也相等的情况下)调用tieBreakOrder() 比较得到的值赋值dir
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
//下面这段代码则是判断加入的树中是左子节点还是右子节点
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//balanceInsertion() 方法就涉及到我们之前所说的根据规则如何保持红黑树的平衡
r = balanceInsertion(r, x);
break;
}
}
}
}
this.root = r;
assert checkInvariants(root);
}
如何根据红黑树的规则保持平衡就在 balanceInsertion() 方法中处理
balanceInsertion()
//第一眼看这个方法的代码又是比较头疼,掺杂着各种判断和赋值。万变不离其宗它无论怎么写都是围绕红黑树的规则来的
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,TreeNode<K,V> x) {
//新加入的节点设为红色
x.red = true;
//for循环中的这几个参数有必要说明一下,xp代表父节点、xpp代表祖父节点、xppl代表祖父节点的左节点
// xppr 代表祖父节点的右节点
//这个for循环代表从x节点向上遍历
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
//如果x的父节点xp为空则把 x节点设为黑色
if ((xp = x.parent) == null) {
x.red = false;
//如果x没有父节点,那么x就是根节点
return x;
}
//如果父节点xp为黑色,祖父节点xpp为空则直接返回根节点root
else if (!xp.red || (xpp = xp.parent) == null)
return root;
//如果 父节点xp 是 祖父节点的xpp的左子节点
if (xp == (xppl = xpp.left)) {
//此时 祖父节点xpp的右子节点不为空(也就是叔叔节点) 并且为红色
if ((xppr = xpp.right) != null && xppr.red) {
//不满足红色树的规则,所以需要把父节点和叔叔节点进行变色
//叔叔节点变为黑色
xppr.red = false;
//父节点变为黑色
xp.red = false;
//祖父节点变为红色
xpp.red = true;
//记录调整后的位置,便于再向上遍历
x = xpp;
}
//反之 叔叔节点不存在或为黑色
//根据之前的if条件判断可以推导出下面这段逻辑中 父节点是红色,而叔叔节点叔叔节点不存在或为黑色
//说明还可以通过旋转来实现平衡
else {
//如果新加入节点是父节点的右子节点
if (x == xp.right) {
//以父节点为旋转节点进行左旋
root = rotateLeft(root, x = xp);
//左旋之后新加入的节点就变成了父节点,所有原本的祖父节点也发生了变化
xpp = (xp = x.parent) == null ? null : xp.parent;
}
//同理下面这段逻辑就是进行一个右旋操作
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
//以下else{} 仍旧是根据红黑树的规则来进行判断,旋转与变色操作内容相对重复就不一一解释了
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
总结
本篇内容篇幅比较长,但仍旧无法覆盖到 ConcurrentHashMap 的方方面面,感兴趣的小伙伴也可自行阅读其他源码。整体来说我们也是围绕着核心逻辑作了一下分析。其中有些比较经典的设计也是值得借鉴的,比如 它的数据结构方面的设计,如何实现的多线程并发扩容机制等等。作者本身水平也有限,若在文中出现错误的地方还请广大小伙伴批评指正。