ConcurrentHashMap类源码解析
1.总体架构
类注释信息
- 所有操作都是线程安全的,在使用ConcurrentHashMap的过程中不需要额外加锁
- 多个线程同时进行put、remove操作时不会阻塞
- 迭代过程中,即使Map的结构被修改也不会抛出ConcurrentModificationException
- 除了数组、链表和红黑树外,新增加了转移节点,目的是为了保证扩容时的线程安全
结构
ConcurrentHashMap的底层数据结构和方法大体与HashMap一致,但是两者在继承关系上使无关的。
ConcurrentHashMap与HashMap相同点:
- 数组、链表结构几乎相同,底层对数据结构操作的思路是相同的(思路相同,但实现不同)
- 都实现了Map接口,继承了AbstractMap抽象类,所以大多数方法也是相同的。当从HashMap切换到ConcurrentHashMap时,无需关心二者的兼容问题
不同点:
- 红黑树结构略有不同,HashMap中红黑树的节点是TreeNode,TreeNode通过属性维护着红黑树结构,还实现了红黑树相应的方法。ConcurrentHashMap中红黑树被拆分为两块,TreeNode仅维护属性和查找功能,新增了TreeBin,用于维护红黑树结构,同时负责根节点的加锁和解锁
- 新增ForwardingNode(转移节点),扩容时会使用到,该节点能够保证扩容时的线程安全
2.源码解析
构造方法
1)指定初始化大小的构造方法
ConcurrentHashMap的构造方法如下,
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
//如果传入的初始化容量值超过最大容量的一半,那么sizeCtl会被设置为最大容量。
//否则通过tableSizeFor方法就算出一个2的n次方数值作为size
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
上面是有参数的构造方法,sizeCtl
变量在ConcurrentHashMap创建时表示的是当前底层数组的容量。该变量在整个类中具有多种含义,具体见下方对put
方法的说明。
如果对未来存储的数据量有预估,可以指定哈希表的大小,避免频繁的扩容操作。tableSizeFor
方法确保底层数组的大小永远都是
2
n
2^n
2n。如果数组大学不是
2
n
2^n
2n ,那么 hash 算法计算的下标发生的碰撞概率会大大增加。tableSizeFor
方法确保了返回大于传入参数的最小
2
n
2^n
2n。
注意tableSizeFor
方法传入的参数不是 initialCapacity
,而是
i
n
i
t
i
a
l
C
a
p
a
c
i
t
y
×
1.5
+
1
initialCapacity×1.5+1
initialCapacity×1.5+1。这样做是为了保证在默认0.75的负载因子下,能够足够容纳initialCapacity
数量的元素。
2)tableSizeFor方法
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;
}
以参数c=9
为例进行推演,
1)int n = 9-1 = 8 对应2进制数字 1000
2)n >>> 1 = 0100 与n进行位&运算 1000 | 0100 = 1100
3)n >>> 2 = 0011 与n进行位&运算 1100 | 0011 = 1111
如果 c
足够大,可使得 n
很大,那么运算到 n |= n >>> 16
时,整数类型变量n
的 32 个二进制位都为 1。上面的逻辑总结起来把整型变量 n
有数值的 bit 位全部置为 1。就得到了一个肯定大于等于 n 的值。最后一行代码,最终返回的是 n+1
,原因是所有位都是 1 的二进制数字表示的值为
2
n
−
1
2^n-1
2n−1,+1 后得到的就是一个
2
n
2^n
2n的值。
3)构造方法流程总结
- 构造函数中并不会初始化底层数组
- 构造函数中类成员变量
sizeCtl
表示底层数组大小 - 构造对象时传入的
initialCapacity
并不是底层数组实际大小。数组的大小为 i n i t i a l C a p a c i t y × 1.5 + 1 initialCapacity×1.5+1 initialCapacity×1.5+1 后,向上取最小的 2 n 2^n 2n的值。如果超过最大容量一半,那么就是最大容量
put方法
1)put方法概览
ConcurrentHashMap在put
方法的思路上与HashMap是相同的,但是在线程安全方面写了很多保障代码,具体思路如下,
- 调用put方法时,如果发现底层数组为空,则对数组进行初始化
- 通过hash值得到底层数组对应位置,判断数组该位置上是否存在元素。如果该位置目前没有元素,CAS进行创建,CAS创建失败则继续自旋,直至成功;如果该位置存在元素,则进入步骤3
- 如果该位置上存在节点对象,且该对象为转移节点(说明正在扩容),就会一直自旋等待扩容完成后再新增节点。如果该节点不是扩容节点,则进入步骤4
- 锁定该节点,保证其余线程不能操作。如果该节点是链表节点,则新增值添加到链表尾部。如果是红黑树节点,则使用红黑树的方式新增
- 新增完成后再检查一次是否需要扩容
具体源码如下,
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;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 如果table数组为空,则初始化
// 空的时候不上锁
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { // 如果数组该位置上没有元素
//cas 在 i 位置创建新的元素,当 i 位置是空时,即能创建成功,结束for自循,否则继续自旋
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
// 如果当前位置的节点是转移节点(hash值固定为MOVED)
// 表示正在扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 数组当前位置有节点且不是转移节点
// 则锁定当前节点,其余线程无法操作
else {
V oldVal = null;
synchronized (f) {
//这里再次判断 i 索引位置的数据没有被修改
//binCount 被赋值的话,说明走到了修改表的过程里面
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 链表
binCount = 1;
// 遍历链表,如果key已存在则依据onlyIfAbsent决定是否替换
// 如果不存在,则将新元素添到队尾
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;
}
}
}
// 如果是数组该位置是红黑树
// TreeBin 持有红黑树的引用,并且会对其加锁,保证其操作的线程安全
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
// putTreeVal方法在给红黑树重新着色旋转的时会锁住红黑树的根节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
//binCount不为0且oldVal有值说明新增成功了
if (binCount != 0) {
// 链表是否需要转化成红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
// 这个break只有在链表或红黑树新增节点失败时候才执行,一般不会执行
break;
}
}
}
// 检查底层数组是否需要扩容
addCount(1L, binCount);
return null;
}
上述代码整体思路如下方流程图,
为保证线程安全,更新节点的过程在上方代码中被封锁在synchronized
代码块中,同步代码块的锁对象f
是数组该位置上链表的头节点或红黑树的根节点
- hash值映射位置在数组中是链表
i. 遍历该链表
ii. 如果存在同样hash值的节点,根于onlyIfAbsent
的指定决定是否要覆盖
iii. 如果不存在同样hash值的节点,创建新节点对象放在链表尾部 - hash值映射位置在数组中是红黑树
i. 通过TreeBin
对象的putTreeVal
方法存放键和值
保存完成后依据binCount
变量的大小觉得是否将链表转换为红黑树,binCount
变量用于保存链表节点的数量。
2)底层数组初始化的线程安全—initTable方法
数组初始化时,保证线程安全所采取的措施,
- 通过自旋保证一定可以初始化成功
- 通过CAS设置SIZECTL变量的值来保证同一时刻只能有一个线程对数组进行初始化
- CAS成功争锁后会再次判断数组是否已经完成初始化。如果已经完成初始化就不会再次执行初始化
故ConcurrentHashMap类通过自旋+CAS+双重检验的方式保证了数组在初始化时的线程安全。初始化数组的源码如下,
//初始化 table,通过对 sizeCtl 的变量赋值来保证数组只能被初始化一次
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//通过自旋保证初始化成功
while ((tab = table) == null || tab.length == 0) {
// sizeCtl小于0代表有线程正在初始化table,释放当前 CPU 的调度权
// table创建完成后,while循环跳出。if中同时还把sizeCtl的值赋值给了sc
if ((sc = sizeCtl) < 0)
Thread.yield();
// 以CAS方式修改sizeCtl为-1,表示本线程已经开始创建table
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 争锁后再次判断数组没有被初始化
if ((tab = table) == null || tab.length == 0) {
//如果sc有值,那么使用sc的值作为table的size,否则使用默认值16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
// 0.75*n,对应load factor
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
上面代码中变量sizeCtl
格外重要,该值同时拥有多个含义,
- -1,表示ConcurrentHashMap的底层数组正在被创建
- -N,表示有 N-1个线程正在复制table数组
- 在 table 被初始化前,代表根据构造函数传入的值计算出的应被初始化的大小
- 在 table 被初始化后,则被设置为 table 大小 的 75%,代表 table 扩容的阈值
initTable
方法中使用到了第1和4中sizeCtl
的含义,第3个含义在构造方法中被使用。第2个含义在扩容方法中使用。
3)新增节点时的线程安全
新增节点时线程安全方面在原有思路上进行了4处优化,
- 通过自旋保证新增成功
- 如果新添加的节点是数组元素(hash值对应的数组的位置为null),通过CAS新增
此处的代码非常严谨,线程争锁过程中,原本为null的位置可能已经添加了新元素,此时CAS失败。失败后会再次for循环,执行数组该索引处已存在元素时对应的代码 - 数组当前索引处已存在节点对象,则锁住该节点,进行后续添加工作
- 红黑树添加节点后恢复平衡过程中锁住红黑树的根节点,保证同一时刻当前红黑树只能被一个线程操作
4)数组扩容或链表转换红黑树—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);
// 将数组index位置的链表转为红黑树。首先验证数组该位置不为null且是节点对象
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;
}
// TreeBin代表红黑树,将TreeBin保存在数组的index位置
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
tryPresize
方法实现了对数组的扩容,传入的参数 size
是原来数组大小的一倍。假定原来数组大小为 16,则传入的 size
参数值为 32。以此数值为例分析源代码,
// size为32,sizeCtl为原大小16的3/4,也就是12
private final void tryPresize(int size) {
// 根据tableSizeFor方法计算出满足要求的数组大小
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
// 初始时sc和sizeCtl均为12,进入while循环
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 若底层数组table还未初始化,这是由于putAll操作不调用initTable方法,而是直接调用tryPresize方法
if (tab == null || (n = tab.length) == 0) {
// putAll第一次调用时,假设putAll进来的map只有一个元素,那么size传入1,计算出c为2.而sc和sizeCtl都为0,因此n=2
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=2
sc = n - (n >>> 2);
}
} finally {
//sizeCtl设置为2.第二次循环时,因为sc和c相等,都为2,进入下面的else if分支,结束while循环。
sizeCtl = sc;
}
}
}
// 扩容已经达到C值,结束扩容
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
// 数组已经存在,那么就对已有table进行扩容
else if (tab == table) {
int rs = resizeStamp(n);
// sc小于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;
// 未达上限,参与扩容,更新sizeCtl值。transfer方法负责把当前数组数据移入新的数组
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 本线程为第一个扩容线程,transfer第二个参数传入null,代表需要新建扩容后的数组
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
5)底层数组扩容时的线程安全—transfer方法
ConcurrentHashMap和HashMap都会在put方法的最后判断数组是否需要扩容。但是二者扩容的过程完全不同,ConcurrentHashMap中对应的方法为transfer方法。该方法被put方法代码中的addCount方法调用。transfer方法的主要思路是,
- 首先将原数组的元素全部拷贝到扩容后的新数组上。拷贝方向是从尾到头的方向
- 拷贝数组时,拷贝到哪个位置就会先将该位置的节点上锁,保证该位置不会被其他线程操作。每次成功拷贝一个节点到新数组后,把原数组中该节点赋值为转移节点,表示该节点在扩容过程中已经添加到新数组里
- 当数组上节点为转移节点时,如果put方法要在该位置添加新节点,就会一直等待,直至扩容完成
- 全部拷贝完成后,直接把新数组赋值给原数组的变量table
transfer方法源码如下,
// tab:原数组,nextTab:新数组
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// 原数组的长度
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果新数组为空,初始化,大小为原数组的两倍,n << 1
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) {
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
// 新数组的长度
int nextn = nextTab.length;
// 转移节点,如果原数组上是转移节点,说明该节点正在被扩容
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false;
// 无限自旋,i 的值会从原数组的最大值开始,慢慢递减到 0
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 结束循环的标志
if (--i >= bound || finishing)
advance = false;
// 已经拷贝完成
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 每次减少 i 的值
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 任意条件满足说明拷贝结束了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 拷贝结束,直接赋值,因为每次拷贝完一个节点,都在原数组上放转移节点,所以拷贝完成的节点的数据一定不会再发生变化。
// 原数组发现是转移节点,是不会操作的,会一直等待转移节点消失之后在进行操作。
// 也就是说数组节点一旦被标记为转移节点,是不会再发生任何变动的,所以不会有任何线程安全的问题
// 所以此处直接赋值,没有任何问题。
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
// 进行节点的拷贝
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 如果节点只有单个数据,直接拷贝,如果是链表,循环多次组成链表拷贝
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 在新数组位置上放置拷贝的值
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 在老数组位置上放上 ForwardingNode 节点
// put 时,发现是 ForwardingNode 节点,就不会再动这个节点的数据了
setTabAt(tab, i, fwd);
advance = true;
}
// 红黑树的拷贝
else if (f instanceof TreeBin) {
// 红黑树的拷贝工作,同 HashMap 的内容,代码忽略
…………
// 在老数组位置上放上 ForwardingNode 节点
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
扩容过程一定要记住的几个关键点,
- 拷贝过程中,会将原数组中该索引位置的节点上锁
- 拷贝成功后,把原数组中的节点设置为转移节点。这样做的目的是扩容过程中,其他线程的put方法不会对原数组中的值进行改动
- 拷贝方向是从原数组尾部到头部
get方法
get方法比较简单,整体思路和HashMap是相同的,
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算hashcode
int h = spread(key.hashCode());
// 底层数组非空且索引处节点元素非空
// 否则该key对应的值不存在,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 若数组索引处第一个节点的哈希值和计算所得的hash值相等,直接返回该节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果是红黑树或者转移节点,使用对应的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;
}
}
return null;
}
总结
ConcurrentHashMap 中,通过大量的 CAS 操作加上 Synchronized 来确保线程安全。对 ConcurrentHashMap 的学习把重点放在哈希算法和扩容上,面试的时候是考察的重点。