一. 前言
以JDK1.8版本的ConcurrentHashMap为例,分析一下针对并发场景是如何保证线程安全的,以及从中能得到哪些并发设计的启发。
二. 核心思想
2.1 设计要解决问题
由于ConcurrentHashMap是JDK中的一个基础容器,因此设计时要保证以下三点:
- 结构简化。
- 高性能。
- 线程安全。
结构简化:是对比1.7版本来说的(后面我们会讲1.7时设计思路),在1.7中使用segment锁来控制并发粒度,这样使得concurrentHashMap的结构变的复杂一层,很多其他的操作都需要兼容这个复杂的结构变动。
高性能:尽可量的减少get时的阻塞,加快put,扩容的效率。
线程安全:保证高并发下get,put,扩容的线程安全。
2.2 设计核心思路
- 仍然用Node[]来存储ConcurrentHashMap的元素,变成和hashMap一样了。
- 通过volatile确保获取到的值为最新,减少同步阻塞。
- 通过减小锁粒度增加并发量,理想情况下线程的put操作都为并行操作。
- 当table[i]上没有值时,对table[i]做cas操作保证第一次安全。
- 当table[i]上有值时,不管时链表还是红黑树,都对table[i]加锁,直接锁住头节点,保证了线程安全。
三. 初始化节点时线程安全
3.1 初始化时机
初始化ConcurrentHashMap的时候这个Node[]数组是还未初始化的,会等到第一次put方法调用时才初始化的initTable方法。
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//判断Node数组为空
if (tab == null || (n = tab.length) == 0)
//初始化Node数组
tab = initTable();
...
}
复制代码
3.2 初始化时并发安全
此时如果多个线程同时调用initTable初始化Node数组怎么办?
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//每次循环都获取最新的Node数组引用
while ((tab = table) == null || tab.length == 0) {
//sizeCtl是一个标记位,若为-1也就是小于0,代表有线程在进行初始化工作了
if ((sc = sizeCtl) < 0)
//让出CPU时间片
Thread.yield();
//CAS操作,将本实例的sizeCtl变量设置为-1
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//如果CAS操作成功了,代表本线程将负责初始化工作
try {
//再检查一遍数组是否为空
if ((tab = table) == null || tab.length == 0) {
//在初始化Map时,sizeCtl代表数组大小,默认16
//所以此时n默认为16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//Node数组
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
//将其赋值给table变量
table = tab = nt;
//通过位运算,n减去n二进制右移2位,相当于乘以0.75
//例如16经过运算为12,与乘0.75一样,只不过位运算更快
sc = n - (n >>> 2);
}
} finally {
//将计算后的sc(12)直接赋值给sizeCtl,表示达到12长度就扩容
//由于这里只会有一个线程在执行,直接赋值即可,没有线程安全问题
//只需要保证可见性
sizeCtl = sc;
}
break;
}
}
return tab;
}
复制代码
- 上面方法当一个线程通过CAS获取到锁资源后,回立即更改sizeCtl = -1,其他线程循环判断时发现sizeCtl < 0就进行yield操作释放cpu资源,这样就降低了锁循环导致的cpu升高。
- 但通过yield方法并不能杜绝cpu问题,因为释放后线程还是要重新竞争,有可能一直竞争到cpu还是会高。
- table变量和sizeCtl都用volatile来保证可见性;
- CAS操作保证了设置sizeCtl标记位的原子性,保证了只有一个线程能设置成功
四. TreeNode变成TreeBin
在1.8的hashMap中,红黑树是由TreeNode节点之间关系组成的,而在concurrentHashMap用一个treeBin对象来包裹了一颗树。treeBin里面保留的树的结构。
为什么要这么设计呢?
因为线程安全concurrentHashMap在链表是锁住头节点,但在红黑树里面根节点是可能发生变化的,因此不能锁住根节点,将整个树封装起来加锁更好实现。
五. PUT结点的线程安全
put结点是只要安全的锁住头结点,下面的操作则和hashMap区别不大,我们直接看代码
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//对key的hashCode进行散列
int hash = spread(key.hashCode());
int binCount = 0;
//一个无限循环,直到put操作完成后退出循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//当Node数组为空时进行初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//Unsafe类volatile的方式取出hashCode散列后通过与运算得出的Node数组下标值对应的Node对象
//此时的Node对象若为空,则代表还未有线程对此Node进行插入操作
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//直接CAS方式插入数据
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
//插入成功,退出循环
break; // no lock when adding to empty bin
}
//查看是否在扩容,先不看,扩容再介绍
else if ((fh = f.hash) == MOVED)
//帮助扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//对Node对象进行加锁
synchronized (f) {
//二次确认此Node对象还是原来的那一个
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
//无限循环,直到完成put
for (Node<K,V> e = f;; ++binCount) {
K ek;
//和HashMap一样,先比较hash,再比较equals
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) {
//和链表头Node节点不冲突,就将其初始化为新Node作为上一个Node节点的next
//形成链表结构
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
}
复制代码
核心是tabAt(tab, i)方法,其使用Unsafe类volatile的操作volatile式地查看值,保证每次获取到的值都是最新的。
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);
}
复制代码
注意:虽然上面的table变量加了volatile,但也只能保证其引用的可见性,并不能确保其数组中的对象是否是最新的,所以需要Unsafe类volatile式地拿到最新的Node。
六. 扩容线程安全
6.1 扩容时机
由于其减小了锁的粒度,若Hash完美不冲突的情况下,可同时支持n个线程同时put操作,n为Node数组大小,在默认大小16下,可以支持最大同时16个线程无竞争同时操作且线程安全。当hash冲突严重时,Node链表越来越长,将导致严重的锁竞争,此时会进行扩容。
在put时根据hash找到Node[]数组中的一个位置,如果这个位置有节点值并且有线程正在处理busy = true,则从新进行hash计算,试图找到最新位置,如果最新位置还是被锁定,说明Node[]离散性不够(冲突严重),需要进行扩容。
扩容限制:不会无限制的扩容下去,如果在扩容时发现原数组被其他线程更改,则不会扩容。
6.2 扩容方式
-
每个Node节点支持仅支持一个线程进行扩容转移,多个Node节点可支持多线程,具体哪个线程控制哪个节点随机决定,控制多少由步长决定。
-
当发生扩容时,正在转移的Node被设置成fwd,如果此时有线程想要put到这个位置,不能直接put进来(直接put就不安全了,但可以PUT到其他没有扩容到的节点中 )。
-
没能成功put的线程也不会闲着等待,而是帮助扩容线程完成转移(如果一个线程完成了负责转移的节点也会去看是否需要帮忙),完成扩容后重新计算index再put。如果线程发现所有的Node节点都有线程负责转移,那么就将finish = true,也就是不需要帮助。
-
在扩容过程中同时支持get查数据,若有线程put数据,还会帮助一起扩容,这种无阻塞算法,将并行最大化的设计。
6.3 扩容线程独立作用域
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//根据机器CPU核心数来计算,一条线程负责Node数组中多长的迁移量
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
//本线程分到的迁移量
//假设为16(默认也为16)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//nextTab若为空代表线程是第一个进行迁移的
//初始化迁移后的新Node数组
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//这里n为旧数组长度,左移一位相当于乘以2
//例如原数组长度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;
return;
}
//设置nextTable变量为新数组
nextTable = nextTab;
//假设为16
transferIndex = n;
}
//假设为32
int nextn = nextTab.length;
//标示Node对象,此对象的hash变量为-1
//在get或者put时若遇到此Node,则可以知道当前Node正在迁移
//传入nextTab对象
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
//i为当前正在处理的Node数组下标,每次处理一个Node节点就会自减1
if (--i >= bound || finishing)
advance = false;
//假设nextIndex=16
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
//由以上假设,nextBound就为0
//且将nextIndex设置为0
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//bound=0
bound = nextBound;
//i=16-1=15
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
}
}
//此时i=15,取出Node数组下标为15的那个Node,若为空则不需要迁移
//直接设置占位标示,代表此Node已处理完成
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
//检测此Node的hash是否为MOVED,MOVED是一个常量-1,也就是上面说的占位Node的hash
//如果是占位Node,证明此节点已经处理过了,跳过i=15的处理,继续循环
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//锁住这个Node
synchronized (f) {
//确认Node是原先的Node
if (tabAt(tab, i) == f) {
//ln为lowNode,低位Node,hn为highNode,高位Node
//这两个概念下面以图来说明
Node<K,V> ln, hn;
if (fh >= 0) {
//此时fh与原来Node数组长度进行与运算
//如果高X位为0,此时runBit=0
//如果高X位为1,此时runBit=1
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
//这里的Node,都是同一Node链表中的Node对象
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
//正如上面所说,runBit=0,表示此Node为低位Node
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
//Node为高位Node
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;
//若hash和n与运算为0,证明为低位Node,原理同上
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
//这里将高位Node与地位Node都各自组成了两个链表
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
//将低位Node设置到新Node数组中,下标为原来的位置
setTabAt(nextTab, i, ln);
//将高位Node设置到新Node数组中,下标为原来的位置加上原Node数组长度
setTabAt(nextTab, i + n, hn);
//将此Node设置为占位Node,代表处理完成
setTabAt(tab, i, fwd);
//继续循环
advance = true;
}
....
}
}
}
}
}
复制代码
其本质是分离出每个线程对Node的作用域是独立的,防止一个Node被多个线程并发处理。
采用的方式就是对transIndex进行CAS,根据每个线程对transIndex的值计算出bound,nextIndex,nextBound等等。
6.4 高低链表设计
从上面代码可以看到,这迁移时要分一个ln(低位Node)、hn(高位Node)。为什么要这么设计呢?
我们知道,在put值的时候,首先会计算hash值,再散列到指定的Node数组下标中:
//根据key的hashCode再散列
int hash = spread(key.hashCode());
//使用(n - 1) & hash 运算,定位Node数组中下标值,n为Node数组的长度,假设为16
(f = tabAt(tab, i = (n - 1) & hash);
复制代码
举例看下扩容规律:
假设有一个key进来,它的散列之后的hash=9,那么它的下标值是多少呢?
-
(16 - 1)和 9 进行与运算 -> 0000 1111 和 0000 1001 结果还是 0000 1001 = 9
假设Node数组需要扩容到32,那么下标值会是多少呢?
-
(32 - 1)和 9 进行与运算 -> 0001 1111 和 0000 1001 结果还是9
再假设一个key进来,散列之后的hash换成20,会怎样呢?
- (16 - 1)和 20 进行与运算 -> 0000 1111 和 0001 0100 结果是 0000 0100 = 4
- (32 - 1)和 20 进行与运算 -> 0001 1111 和 0001 0100 结果是 0001 0100 = 20
如果hash在高X位为1,(X为数组长度的二进制-1的最高位),则扩容时是需要变换在Node数组中的索引值的,不然就hash不到,丢失数据,所以这里在迁移的时候将高X位为1的Node分类为hn,将高X位为0的Node分类为ln。
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);
}
复制代码
这个操作将高低位组成了两条链表结构,由下图所示:
然后将其CAS操作放入新的Node数组中:
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
复制代码
其中,低位链表放入原下标处,而高位链表则需要加上原Node数组长度,这样就可以保证高位Node在迁移到新Node数组中依然可以使用hash算法散列到对应下标的数组中去了。
最后将原Node数组中对应下标Node对象设置为fwd标记Node,表示该节点迁移完成,到这里,一个节点的迁移就完成了,将进行下一个节点的迁移。
6.5 扩容时的get操作
假设Node下标为16的Node节点正在迁移,突然有一个线程进来调用get方法,正好key又散列到下标为16的节点,此时怎么办?
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;
}
//假如Node节点的hash值小于0
//则有可能是fwd节点
else if (eh < 0)
//调用节点对象的find方法查找值
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;
}
复制代码
判断Node中的hash是否小于0,是否还记得我们的占位Node,其hash为MOVED,为常量值-1,所以此时判断线程正在迁移这个节点。我们看下find方法:
//内部类 ForwardingNode中
Node<K,V> find(int h, Object k) {
// loop to avoid arbitrarily deep recursion on forwarding nodes
// 这里的查找,是去新Node数组中查找的
// 下面的查找过程与HashMap查找无异,不多赘述
outer: for (Node<K,V>[] tab = nextTable;;) {
Node<K,V> e; int n;
if (k == null || tab == null || (n = tab.length) == 0 ||
(e = tabAt(tab, (n - 1) & h)) == null)
return null;
for (;;) {
int eh; K ek;
if ((eh = e.hash) == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
if (eh < 0) {
if (e instanceof ForwardingNode) {
tab = ((ForwardingNode<K,V>)e).nextTable;
continue outer;
}
else
return e.find(h, k);
}
if ((e = e.next) == null)
return null;
}
}
}
复制代码
get方法不会修改数据,因此在扩容迁移时应该支持不阻塞的get操作,其实现方式就是占位Node需要保存新Node数组的引用。
6.6 多线程协助扩容
6.6.1 协助扩容时机
- 在put值时,发现Node为占位Node(fwd)时,会协助扩容。
- 在新增节点后,检测到链表长度大于8时,且数组长度小于64,会主动扩容。
- 在每次新增节点之后,都会调用addCount方法,检测容器元素是否达到阈值。
6.6.2 协助扩容方式
在put操作时,假设正在迁移,正好有一个线程进来,想要put值到迁移的Node上,怎么办?
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
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
}
//若此时发现了占位Node,证明此时HashMap正在迁移
else if ((fh = f.hash) == MOVED)
//进行协助迁移
tab = helpTransfer(tab, f);
...
}
复制代码
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
//sizeCtl加一,标示多一个线程进来协助扩容
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//扩容
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
复制代码
此时sizeCtl变量用来标示HashMap正在扩容,当其准备扩容时,会将sizeCtl设置为一个负数,(例如数组长度为16时)其二进制表示为:
1000 0000 0001 1011 0000 0000 0000 0010
复制代码
无符号位为1,表示负数。其中高16位代表数组长度的一个位算法标识,低16位表示有几个线程正在做迁移,刚开始为2,接下来自增1,线程迁移完会进行减1操作,也就是如果低十六位为2,代表有一个线程正在迁移,如果为3,代表2个线程正在迁移以此类推。
只要数组长度足够长,就可以同时容纳足够多的线程来一起扩容,最大化并行任务,提高性能。
七. 统计容器大小的线程安全
上文中提到一个关键值,容器大小,直接控制hash和扩容等一系列操作,每次添加节点时都会addCount,怎么保证一个这个大小的多线程安全呢?
7.1 计数桶
在设计中,使用了分而治之的思想,将每一个计数都分散到各个countCell对象里面(下面称之为桶),使竞争最小化,又使用了CAS操作,就算有竞争,也可以对失败了的线程进行其他的处理。
/计数,并检查长度是否达到阈值
private final void addCount(long x, int check) {
//计数桶
CounterCell[] as; long b, s;
//如果counterCells不为null,则代表已经初始化了,直接进入if语句块
//若竞争不严重,counterCells有可能还未初始化,为null,先尝试CAS操作递增baseCount值
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
//进入此语句块有两种可能
//1.counterCells被初始化完成了,不为null
//2.CAS操作递增baseCount值失败了,说明有竞争
CounterCell a; long v; int m;
//标志是否存在竞争
boolean uncontended = true;
//1.先判断计数桶是否还没初始化,则as=null,进入语句块
//2.判断计数桶长度是否为空或,若是进入语句块
//3.这里做了一个线程变量随机数,与上桶大小-1,若桶的这个位置为空,进入语句块
//4.到这里说明桶已经初始化了,且随机的这个位置不为空,尝试CAS操作使桶加1,失败进入语句块
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;
//统计容器大小
s = sumCount();
}
...
}
复制代码
7.2 计数桶优化
计数桶的优化,当竞争比较少时,计数桶的意义不大。因此实际使用了两种思路:
- CAS方式直接递增:在线程竞争不大的时候,直接使用CAS操作递增baseCount值即可,这里说的竞争不大指的是CAS操作不会失败的情况
- 分而治之桶计数:若出现了CAS操作失败的情况,则证明此时有线程竞争了,计数方式从CAS方式转变为分而治之的桶计数方式
private final void fullAddCount(long x, boolean wasUncontended) {
int h;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
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;
...
//如果计数桶!=null,证明已经初始化,此时不走此语句块
if ((as = counterCells) != null && (n = as.length) > 0) {
...
}
//进入此语句块进行计数桶的初始化
//CAS设置cellsBusy=1,表示现在计数桶Busy中...
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
//若有线程同时初始化计数桶,由于CAS操作只有一个线程进入这里
boolean init = false;
try { // Initialize table
//再次确认计数桶为空
if (counterCells == as) {
//初始化一个长度为2的计数桶
CounterCell[] rs = new CounterCell[2];
//h为一个随机数,与上1则代表结果为0、1中随机的一个
//也就是在0、1下标中随便选一个计数桶,x=1,放入1的值代表增加1个容量
rs[h & 1] = new CounterCell(x);
//将初始化好的计数桶赋值给ConcurrentHashMap
counterCells = rs;
init = true;
}
} finally {
//最后将busy标识设置为0,表示不busy了
cellsBusy = 0;
}
if (init)
break;
}
//若有线程同时来初始化计数桶,则没有抢到busy资格的线程就先来CAS递增baseCount
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break; // Fall back on using base
}
}
复制代码
7.3 计数桶扩容
上面的分析中我们知道,计数桶初始化之后长度为2,在竞争大的时候肯定是不够用的,所以一定有计数桶的扩容操作。
7.3.1 计数桶扩容时机
在CAS操作递增计数桶失败了3次之后,会进行扩容计数桶操作,注意此时同时进行了两次随机定位计数桶来进行CAS递增的,所以此时可以保证大概率是因为计数桶不够用了,才会进行计数桶扩容
7.3.2 扩容操作
计数桶长度增加到两倍长度,数据直接遍历迁移过来,由于计数桶不像HashMap数据结构那么复杂,有hash算法的影响,加上计数桶只是存放一个long类型的计数值而已,所以直接赋值引用即可。
7.3.3 设计启发
- 利用CAS感知是否存在线程竞争,若竞争不大直接CAS递增值即可,性能与直接加差别不大。
- 若存在线程竞争,采用分治思想分散线程,减少冲突,“负载均衡"一样的将线程计数请求接近均匀的落在各个桶中。
八. JDK1.7版本设计思路
因为主流的版本都是1.8以上,因此1.7的设计显得有些落后了,不过依然有很多值得借鉴的地方,这里我简单过一下。
8.1 改造hashMap成为线程安全的思路
- 第一种:借鉴hashTable
为put方法用synchronized上对象锁,保证安全,缺点是粒度粗,并发性能差。
- 第二种:分段锁
针对第一种粒度粗的问题,解决方案就是降级,将原本的HashMap=>Entry[]变成ConcurrentHashMap => Segment[] => Entry[],中间多了一个segment类型,对segment进行加锁,这样粒度变小了一些。
第一种性能缺陷太明显,1.7采用了第二种方案。
8.2 如何分段
根据三个重要参数推导分段规则:
- 第一个参数代表entry的总个数
- 第二个参数是负载因子
- 第三个参数是segment的总个数
那么一个segment里有几个entry呢,这个关系不是固定的,通过entry总数/ seg总数向上取整
比如CEIL(17 /16) = 2
另外沿用hashMap的标准,每一个entry数组的长度必须是2的幂,因此如果上面算的不是2的幂次,需要转化成2的幂次
比如CEIL(33 /16) = 3, toSize(3) = 4。也就是说一个segment下有4个entry
8.3 put时线程安全
先找到segment,再找到下面的entry。
找segment的index时用hashCode & (segments.length - 1);
插入segment时使用unsafe保证线程安全,内部机制是自旋锁 + cas
找entry的index时用hashCode & (entrys.length - 1);
插入entry时使用tryLock和lock保证线程安全
实现时的几个很绕的细节:
-
使用while + tryLock自旋非阻塞,非阻塞期间创建节点
-
设置retries重试次数,超过时lock阻塞,避免CPU消耗
-
偶数次重试时判断头节点是否被别的线程更新,如果更新需要从新的头节点重新获取,重复123。
注意:put插入时还是头插法
8.4 如何扩容
不破坏HashMap的思想,扩容还是在entry中做,并且采用局部扩容,也就是一个segmeng内的entry进行扩容。
segment长度初始化后就不变了。
细节:transfor方法为什么要进行两次循环?像hashMap一样一次链表循环不就可以了?
这里是一个小优化,第一次循环的目的是链表从后往前寻找rehash后index相同的连续节点,这样连续的节点可以直接一次断链就放到新的位置上,提升性能。(类似蜘蛛纸牌)