文章简称ConcurrentHashMap为map
文章深入分析了get函数,put函数和remove函数,以及并发扩容过程。ConcurrentHashMap的一半精华在于并发扩容,多个线程是如何协作加速迁移哈希槽中的元素。
sizeCtl是map的重要控制变量
通过几种值来传递当前容器处于什么状态。-1是map正在初始化中。-2~-n是有abs(sizeCtl)个线程帮助扩容中。正常的正整数是map下一次扩容的阈值,当前capacity*0.75。
/**
* Table initialization and resizing control. When negative the
* table is being initialized or resized: -1 for initialization
* else -(1 + the number of active resizing threads). Otherwise
* when table is null holds the initial table size to use upon
* creation or 0 for default. After initialization holds the
* next element count value upon which to resize the table.
*/
private transient volatile int sizeCtl;
哈希槽中节点hash值含义,链表节点hash值是>=0的正整数,树根节点时-2,-1是正在扩容的哈希槽虚节点。
static final int MOVED = -1; // hash for forwarding nodes
static final int TREEBIN = -2; // hash for roots of trees
static final int RESERVED = -3; // hash for transient reservations
initTable()函数
初始化操作很简单,数组初始化的长度是由sizeCtl控制的,sizeCtl 是被 volatile 修饰的变量,保证在并发环境下的内存可见性。
负数:-1 正在初始化。-N 表示有 N-1个线程正在扩容。
所以不必担心在并发时有多个线程多次初始化表格,因为当 sizeCtl < 0 时,线程就 yield() 了(放弃CPU执行权)。而且前面提到过 sizeCtl 是 volatile 的。
如果是正数就用 CAS 把 sizeCtl 设置为 -1 表示正在初始化(这里的CAS可理解为类似乐观锁作用)。new了数组后,就是 n - n/4 的阈值给了 sizeCtl。即 75% 的扩容阈值。
put()函数
大致和HashMap的put操作差不多,区别在于,会检查是否正在扩容,且会帮助扩容。hash计算的函数和HashMap算法差不多,将Key的哈希值高16位与16位异或后跟table.length-1相与(对于2的n次幂-1后做与运算等价于对table.length取模,原理是什么?很简单想想取模后的余数二进制是不是在table.length-1范围内),得到index。对于寻址得到的bucket若是空结点,会用CAS直接创建新节点。(以下bucket称为哈希槽)
如果当前哈希槽的节点的hash值时-1,代表整个哈希表正在扩容,进入helpTransfer()帮助扩容。
1.首先检查k v,key不能为空,HashMap可以为空 此时key的hash为0
5.哈希槽非空,判断当前节点hash是否-1代表正在扩容,则帮助扩容
6.非空,非扩容,走正常的加锁节点,链表插入或红黑树插入节点
bucket有节点则会以该节点上锁,锁粒度还是控制在 bucket 级别。进去临界区后再检查一次当前槽位的节点有没被改动过(锁双重检查),OK,线程安全了,开始添加操作。之后就是判断链表还是树形,链表会插入到尾部,红黑树则是红黑树的节点插入算法。
从这里可以看出ConcurrentHashMap是尾插法。
链表添加的过程会涉及一个binCount,这个变量代表当前哈希槽中链表的长度,每遍历一个节点binCount++,当遍历了8个节点以上才将当前元素加入链表后,在putVal()最后会判断binCount > 8 && table.length >= 64就将链表转红黑树。
最后在 addCount() 中将binCount加1,由于涉及并发,这个计数逻辑也变得比较复杂。
private final void treeifyBin(Node<KV>[] tab int index) {
Node<KV> b; int n sc;
if (tab != null) {
// 前面提过当一个链表中超过8个节点就会尝试树化,但是不一定会执行
// 还要看哈希表长度是否>64,如果<64会将哈希表扩容2倍
// 因为哈希表太小而某个槽的元素过多,代表哈希冲突比较严重,需要扩大槽位降低冲突
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<KV> hd = null tl = null;
// 从第一个节点遍历链表,将每个节点转成树形节点,并且这个树形节点还保持原链表的前后节点指针。hd是头结点,tl尾结点
// 这里只是将节点转树形节点,真正的链表转红黑树逻辑在new TreeBin()
for (Node<KV> e = b; e != null; e = e.next) {
TreeNode<KV> p =
new TreeNode<KV>(e.hash e.key e.val
null null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
// new TreeBin(hd) 是构造一颗红黑树,TreeBin顾名思义树形槽,里面维护一个root的TreeNode,并且树形槽节点hash=-2
// forwardingNode hash = -1
// 构造红黑树后,将这个树形槽设置在哈希表的槽位上,至此某个槽位的树化过程完毕
setTabAt(tab index new TreeBin<KV>(hd));
}
}
}
}
}
get()函数
get函数和普通hashMap差别不大,其中需要关注的是如果map在并发扩容中该怎么查找对应的key? 如果一个哈希槽是迁移中的它的首节点肯定是被替换成了forwdingNode,然后迁移线程开始迁移链表,所以访问线程只需要到新哈希表去找对应的entry就行。
如果同时有另个线程在put元素,因为table是volatile修饰的,保证数组元素发生写操作后立即对所有线程可见。所以只要put线程先行插入元素到链表尾部,get线程可立即读到新元素。所以get函数没有加锁访问。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算key的哈希值
int h = spread(key.hashCode());
// 哈希值 % table.length
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 比较key
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果哈希槽的entry是forwdingNode,它的哈希值固定位-1
else if (eh < 0)
// 在新的哈希表中查找entry
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;
}
}
// 哈希槽是空的,或链表没有找到entry
return null;
}
remove()函数
具体实现是 replaceNode(key, null, null) 函数。通过寻址后定位key所在某个槽,哈希表是空或未初始化返回null。正常的第一步判断槽节点是否 ForwardingNode 它的hash=-1,是的话会进入扩容环节。
没有扩容的话,仍然是以节点为锁 synchronized(f),如果节点hash >= 0 代表是普通链表节点。链表的替换逻辑,其中 传进来的 cv value 和 当前节点的旧值 ev 的比较不是很明白,为什么需要一个 cv 和 ev的比较?
可以看到找到目标节点后直接覆盖了旧值,如果传进来的 value 是空直接将上一个节点指向下一个节点,将目标节点剔除链表(没有任何指向)。如果没找到节点就跳出循环。
TreeBin 的hash = -2,红黑树的替换逻辑貌似就简单很多(实际内部应该比较复杂,又是涉及红黑树平衡算法)找到节点后,若value非空直接替换旧值,若为空代表要删除树节点,剩下的就是红黑树的删除算法。
transfer()
关于 transfer() 操作,如果下面的解析看不懂,还是建议看开头的文章,比较好理解。在每次put()操作后,会调用addCount(),更新baseCount和检查是否需要扩容。
扩容操作,是很复杂的一个函数。借助解析文章和源码看了下,Doug Lee 写这个扩容函数很吊的地方在于扩容操作允许多线程并发扩容,提高性能,还保证了安全性。
transfer()主要由2部分:扩容时确定每个线程负责的哈希槽范围。对该范围内每个节点数据的迁移动作。
扩容时哈希槽分配算法:
while(advance) 循环主要是给当前参加扩容的线程分配一个哈希槽区间,由bound和i 2个变量组成区间。循环体中每次都读取成员变量 volatile transferIndex,以它为本次分配的边界,假设现在是第一个扩容的线程,transferIndex=old table length=64
stride是步幅 每个线程默认处理的哈希槽区间范围 16,nextIndex - stride = 64 - 16 =48,如果剩下的待分配区间不够一个stride步幅,那么这个线程就包揽剩下全部哈希槽(小于16个)。用CAS替换内存中transferIndex地址,把旧值64替换成48,成功就代表这个线程抢到了这部分哈希槽区间,失败就重新读transferIndex,重新计算区间。所以经过这个循环,线程就得到 bound = 48 边界,起点 i = 63,从后往前迁移每个哈希槽的数据。
这里得到待转移的槽区间,下面逻辑完成某个槽的数据迁移后,会将advance=true 重新回到while(advance),目的是检查有没有完成区间内全部槽位的数据迁移。if(--i >= bound || finishing) 代表当前线程还在边界内 或 全局finishing标志位=true代表扩容已完成,也意味着该线程不需要再领取槽区间,离开 while 循环继续去下面的迁移逻辑。
while (advance) {
// 首先bound是本次线程领取迁移范围的左边界,i是右边界。迁移是从右->左顺序。
int nextIndex nextBound;
// --i >= bound还没迁移完毕当前领取的范围。finishing=true扩容已经完成了
if (--i >= bound || finishing)
advance = false;
// transferIndex可以看做是迁移的起始位置,每个线程从起始位置领取一段区间去迁移。起始位置<=0时扩容完毕了。
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 从transferIndex起始位置开始领取一段stride范围的区间,CAS缩减起始位置,i从后往前迁移区间内的节点
else if (U.compareAndSwapInt
(this TRANSFERINDEX nextIndex
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// 迁移完毕了,更新新的哈希表和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
}
}
在上面扩容线程分配一个负责的哈希槽区间后,这里的逻辑就是开始处理每个哈希槽位的数据。如果当前哈希槽是空的,就插入一个ForwardingNode节点,它的hash是固定的-1标识这个槽位的数据正在被转移。
casTabAt(tab, i, null, fwd) 就是用CAS提供的原子操作在指定内存地址替换成fwd节点,通知其它线程遍历到这个节点后就跳过。
else if ((fh = f.hash) == MOVED) 判断就是表达这个逻辑 advance置为true,重新回到while中判断当前线程是否处理完分配的哈希槽位。
如果当前线程处理完闭而全局的哈希槽还没处理完(finishing=false),就再次分配。
节点迁移算法:
上面判断完,一个槽位非空又非ForwardingNode,那么就是一个正常的Node,锁住它。开始前需要先讲下大致逻辑:整个槽位的链表会被划分成2条新的链表,低位链表和高位链表,低位链表会被放置在原index位置上 高位链表被放到 index + old table.length 位置上
synchronized (f) {
// 在锁里面再检查一次,免得被别人改变了
if (tabAt(tab i) == f) {
// 低位节点,高位节点。这个概念比较重要,低位节点组成低位链表,高位节点同理。学过HashMap扩容原理应该知道,
// 因为数组总是以2的幂次方增长,所以一个节点被rehash后要么在原位置上要么会被移动到 index + old table.length的位置上
// 原本 [0~old table.length) 的范围称为低位,[old table.length ~ new table.length) 是高位
Node<KV> ln hn;
// 链表节点hash >= 0,树形节点 hash = -2
if (fh >= 0) {
// 根据扩容后的1位看节点hash是否会被移动,参看 HashMap 源码学习(JDK 1.8)
int runBit = fh & n;
Node<KV> lastRun = f;
// 从头到尾遍历链表,判断每个节点新增的1位是否为1,这里的目的没有别的就是让 lastRun 指针指向最后一个runBit有差异的节点
// 这个 lastRun 节点后的所有节点的 runBit 位都和它相同,而且它们本身已经有链条关系,直接将以 lastRun开头的这个剩余K个节点的链表
// 放到对应高位或低位链表中就OK了。说那么多这个小算法就是想优化效率
for (Node<KV> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 经过上面计算,lastRun指向的链表是低位链表还是高位链表已经得知
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 这个循环才是真正移动每个节点到新的位置同时构造新的链表
// 从头开始到 lastRun 指向的链表之前,将每个节点要么放进低位链表要么放进高位链表
// 这里用的是 新节点 -> 老节点,最新的节点被当做头结点放在哈希槽中,头插法。
for (Node<KV> 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<KV>(ph pk pv ln);
else
hn = new Node<KV>(ph pk pv hn);
}
// 在新数组中CAS设置低位链表原来 index 中
setTabAt(nextTab i ln);
// 在新数组中CAS设置高位链表在 index + old table.length 中
setTabAt(nextTab i + n hn);
// 该哈希槽数据被处理完毕,将旧数组的这个哈希槽节点替换为ForwardingNode,通知其它线程跳过此槽
setTabAt(tab i fwd);
// advance置为true,重新回到while中前进一个哈希槽或重新分配一个范围的哈希槽继续迁移数据
advance = true;
}
else if (f instanceof TreeBin) {
TreeBin<KV> t = (TreeBin<KV>)f;
TreeNode<KV> lo = null loTail = null;
TreeNode<KV> hi = null hiTail = null;
int lc = 0 hc = 0;
for (Node<KV> e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode<KV> p = new TreeNode<KV>
(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<KV>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin<KV>(hi) : t;
setTabAt(nextTab i ln);
setTabAt(nextTab i + n hn);
setTabAt(tab i fwd);
advance = true;
}
}
}
支持并发扩容,实现方式是,将表拆分,让每个线程处理自己的区间。下面几张图来描述上面的过程,黑色节点为低位节点,白色节点是高位节点。一开始在原本链表中是这样的
中间计算过程,构造2条链表,可以看到 lastRun指向的 9、10链表被加入到低位链表,表现形式也是头插法。
扩容后低位链表被留在了原位置,高位被放到了 index + old table.length上,这里的old table.length = 16
参考文章:
并发编程——ConcurrentHashMap#transfer() 扩容逐行分析 - 简书并发编程——ConcurrentHashMap#transfer() 扩容逐行分析