结论先行,细节在下面
jdk1.7是如何解决并发问题的以及完整流程
一.首先new一个concurrentHashMap
调用默认构造方法
二.初始化
初始化initialCapacity(默认是16,指一个segment内Entry的数量),loadFactor(默 认0.75f,负载因子),初始化concurrentLevel(默认是16,segment数量)。
1.校验传入的参数是否符合规定
2.计算concurrentLevel、segementMask(掩码)和segementShift(移位数)
3.计算每个segment中的Entry数组大小,默认且最小为2
4.此时你得到了一个segment对象,调用UNSAFE.putOrderedObject方法,利用CAS将 此segment对象放在segment数组下标为0的位置,其余15个位置为null
三.初始化完开始使用。先put一个键值对进去
1.判断value是否为空,为空直接报错
2.计算hash值。int j = (hash >>> segmentShift) & segmentMask
先用segementShift将32位的hash右移28位,剩4位,再与segmentMask(二进制码,具体数值为1111)进行与运算,得到j,此时segment[j]还是null,不像segment[0]已经初始化,那么调用ensureSegment(j)初始化segment[j]
3.上来第一步先 tryLock() ? null : scanAndLockForPut(key, hash, value);
如果tryLock失败,也就是没拿到独占锁,将调用scanAndLockForPut方法,这个方法大概是循环尝试tryLock(),尝试次数到一定后,将调用lock()进行阻塞,直到拿到锁
4.获取锁成功后,hash计算entry下标,int index = (tab.length - 1) & hash
5.遍历链表,有数据就覆盖,没数据就头插
6.判断是否需要扩容
7.释放锁
四.扩容
1.定义threshold = (int)(newCapacity * loadFactor),只要threshold小于map中实际存入的元素大小,就开始扩容;entry数组一次扩容成原来的两倍
2.用rehash方法,计算新的掩码segmentMask,然后遍历原数组,老套路,将原数组位置 i 处的链表拆分到 新数组位置 i 和 i+oldCap 两个位置(原理HashMap那里说过)
3.最后插入新节点
五.get方法
第一次计算hash定位segment,第二次hash定位entry,然后返回。
六.并发问题的解决
注意到,get没有加锁,put和remove都加上了独占锁,需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作,会发生什么
1.对于put
第一个问题是:初始化segment是用CAS将segment对象放入segment数组index为0的位置的;
第二个问题是:put进entry是头插,如果此时get操作已经遍历到链表中间,无影响。但是还需要保证put之后get要找的到刚被插入的头节点,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject;
第三个问题是:扩容也有并发。扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table,get操作会在旧table上进行,不影响,如果put先行,扩容后行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
jdk1.8是如何解决并发问题的以及完整流程
一.首先new一个concurrentHashMap
调用默认构造方法,需要注意的是,1.8摒弃了segment这个概念,引入了红黑树这个数据结构,加锁则采用CAS和synchronized实现
二.构造函数内部操作。
维护一个sizeCtl = (1.5 * initialCapacity + 1) 再向上取最近的2的倍数。比如initialCapacity = 10,则sizeCtl = 16。sizeCtl的使用场景很多。
构造函数只是计算值而已,初始化操作延迟到真正操作数据的时候。
三.put过程分析
1.key或value==null直接抛错误。
2.hash = spread(key.hashCode()),得到hash值,定义binCount记录链表长度。
3. if 数组为空,初始化数组(这里才真正初始化数组);如果已经初始化,找出该hash值对应的数组下标,得到第一个节点
else if 该位置尚未有任何节点,利用CAS将新节点放入。put逻辑基本结束。
else if hash == MOVED,说明在扩容,转而帮助其数据迁移。
else 此时节点存在,也不为空。
在这个 else 下,又有两个判断:
如果hash >= 0,说明是链表
如果节点f instanceof TreeBin,说明是红黑树
对应不同的插入逻辑
4.进行完以上判断,开始进入判断是否将链表转化成红黑树的阶段
if(binCount >= TREEIFY_THRESHOLD) 也就是第三步的第二小步定义的binCount记录着本链表的长度,大于等于8就转红黑树
四.真正对数组的初始化
initTable方法
初始化一个合适大小的数组,然后会设置 sizeCtl。
初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的
U.compareAndSwapInt(this, SIZECTL, sc, -1),将sizeCtl改成-1,代表抢到锁
接下来就是各种赋初值,比如数组长度什么的。
五.链表转红黑树
treeifyBin方法
treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容
如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容,而不是转化为红黑树。
如果需要转化,那么用synchronized加锁,将链表变成红黑树,然后返回头结点,设置 到数组相应的位置上。
六.扩容机制
tryPresize方法
这个方法的核心在于对 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)
所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚
总的来说,肯定是得把老数组的东西拷贝到新数组里面,然后引用指向新数组,这样就行了,怎么拷贝呢?用transfer方法
原理太复杂,大概意思就是将一个大数组分割成很多个小部分,可以令每个线程负责转移一部分数据,转移数据的时候,会锁头节点或者根节点,转移后一个位置,就会在那个位置放置一个特殊的节点,该节点hash值为-1,表示该位置已经转移
七.get 过程分析
计算hash,利用hash定位。
如果为null,返回null;
如果刚好是需要的,那就返回;
如果hash < 0,说明正扩容,用find方法找;
如果上面都不满足,说明是链表,直接往后遍历即可。
八.并发问题的解决
1.初始化时:在initTable方法内可以看到,通过CAS判断当前是否有其他线程在初始化,如果有,那么当前线程会被阻塞,一直CAS自旋等到数组初始化成功。
2.扩容时:将数组分割成若干份,允许多个线程一起扩容,一起转移数据,每个线程在负责自己那一part的数据转移时,会对头结点加锁。
3.插入时:位置为空时,CAS插入;不为空时,对头结点加锁,再插入。
上面是总结,速度过一遍;下面是细节,仔细看一遍
正式绪论
JDK1.7之前的ConcurrentHashMap使用分段锁机制实现,JDK1.8则使用数组+链表+红黑树数据结构和CAS原子操作实现ConcurrentHashMap;本文将分别介绍这两种方式的实现方案及其区别。
请带着这些问题学习。
为什么HashTable慢
Hashtable之所以效率低下主要是因为其实现使用了synchronized关键字对put等操作进行加锁,而synchronized关键字加锁是对整个对象进行加锁,也就是说在进行put等修改Hash表的操作时,锁住了整个Hash表,从而使得其表现的效率低下。
JDK1.7版本
在JDK1.5~1.7版本,Java使用了分段锁机制实现ConcurrentHashMap. 简而言之,ConcurrentHashMap在对象中保存了一个Segment数组,即将整个Hash表划分为多个分段;而每个Segment元素,即每个分段则类似于一个Hashtable;这样,在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可。因此,ConcurrentHashMap在多线程并发编程中可是实现多线程put操作。接下来分析JDK1.7版本中ConcurrentHashMap的实现原理。
segment
整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁。注意,行文中,我很多地方用了“槽”来代表一个 segment。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrentLevel:是一个int数值,命名为并发数,默认是16。也就是说,一个map中有16个segment,于是map支持16个线程并发写,只要他们分别操作这16个segment。
可以人为在初始化时设置成其他值,一旦指定,不可扩容。
segment的内部
在使用之前先初始化map,调用上图的方法,initialCapacity是初始容量,loadFactor是负载因子,concurrentLevel是并发数,也是segment的数量。
如果调用无参构造方法,那么我将得到:
segmentMask要等于数组长度减一,比如16 - 1 = 15,二进制码是1111,可以更好地保证散列的均匀性;
segmentShift是移位数,由于hash是32位的,它设为28的话,可以使hash无符号右移28位,剩下4个高位数,而这四位再和1111(也就是segmentMask)做一次与运算就可以转换为segment数组的下标,因为4位二进制数可以表示数字0~15,segment数组下标也是从0到15。
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 1. 计算 key 的 hash 值
int hash = hash(key);
// 2. 根据 hash 值找到 Segment 数组中的位置 j
// hash 是 32 位,无符号右移 segmentShift(28) 位,剩下高 4 位,
// 然后和 segmentMask(15) 做一次与操作,也就是说 j 是 hash 值的高 4 位,也就是槽的数组下标
int j = (hash >>> segmentShift) & segmentMask;
// 刚刚说了,初始化的时候初始化了 segment[0],但是其他位置还是 null,
// ensureSegment(j) 对 segment[j] 进行初始化
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
// 3. 插入新值到 槽 s 中
return s.put(key, hash, value, false);
}
这里主要是为了计算出segment的下标,也就是该存到哪个segment下。
之后会进入segment内部获取锁,然后正式插入数据。
PUT方法的细节
初始化槽: ensureSegment(int k)方法
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。
这里需要考虑并发,因为很可能会有多个线程同时进来初始化同一个槽 segment[k],不过只要有一个成功了就可以。
初始化第一个槽的原因
拿segment[0]这个最先被初始化且被操作的当做榜样,利用[0]去初始化[k]。
总的来说,ensureSegment(int k) 比较简单,对于并发操作使用 CAS 进行控制。
获取写入锁方法scanAndLockForPut(K key, int hash, V value)
在往某个 segment 中 put 的时候,首先会调用 node = tryLock() ? null : scanAndLockForPut(key, hash, value),也就是说先进行一次 tryLock() 快速获取该 segment 的独占锁,如果失败,那么进入到 scanAndLockForPut 这个方法来获取锁。
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。 这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该 segment 的独占锁,如果需要的话顺便实例化了一下 node。
扩容:rehash
扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍。
注意到,在put方法里,会判断该值插入后是否会导致超出阈值,超了就先扩容再插。
get方法
计算 hash 值,找到 segment 数组中的具体位置,或我们前面用的“槽”
槽中也是一个数组,根据 hash 找到数组中具体的位置
到这里是链表了,顺着链表进行查找即可
并发问题分析
JDK1.8版本
写在前面
在JDK1.7之前,ConcurrentHashMap是通过分段锁机制来实现的,所以其最大并发度受Segment的个数限制。因此,在JDK1.8中,ConcurrentHashMap的实现原理摒弃了这种设计,而是选择了与HashMap类似的数组+链表+红黑树的方式实现,而加锁则采用CAS和synchronized实现。
数据结构
构造函数
// 这构造函数里,什么都不干
public ConcurrentHashMap() {
}
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 = 【 (1.5 * initialCapacity + 1),然后向上取最近的 2 的 n 次方】。如 initialCapacity 为 10,那么得到 sizeCtl 为 16,如果 initialCapacity 为 11,得到 sizeCtl 为 32。
PUT方法
著作权归https://pdai.tech所有。
链接:https://www.pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentHashMap.html
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 得到 hash 值
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();
// 找该 hash 值对应的数组下标,得到第一个节点 f
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果数组该位置为空,
// 用一次 CAS 操作将这个新值放入其中即可,这个 put 操作差不多就结束了,可以拉到最后面了
// 如果 CAS 失败,那就是有并发操作,进到下一个循环就好了
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 { // 到这里就是说,f 是该位置的头节点,而且不为空
V oldVal = null;
// 获取数组该位置的头节点的监视器锁
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 头节点的 hash 值大于 0,说明是链表
// 用于累加,记录链表的长度
binCount = 1;
// 遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 如果发现了"相等"的 key,判断是否要进行值覆盖,然后也就可以 break 了
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) {
// 判断是否要将链表转换为红黑树,临界值和 HashMap 一样,也是 8
if (binCount >= TREEIFY_THRESHOLD)
// 这个方法和 HashMap 中稍微有一点点不同,那就是它不是一定会进行红黑树转换,
// 如果当前数组的长度小于 64,那么会选择进行数组扩容,而不是转换为红黑树
// 具体源码我们就不看了,扩容部分后面说
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//
addCount(1L, binCount);
return null;
}
初始化数组: initTable
这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。
初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 初始化的"功劳"被其他线程"抢去"了
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 一下,将 sizeCtl 设置为 -1,代表抢到了锁
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// DEFAULT_CAPACITY 默认初始容量是 16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组,长度为 16 或初始化时提供的长度
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将这个数组赋值给 table,table 是 volatile 的
table = tab = nt;
// 如果 n 为 16 的话,那么这里 sc = 12
// 其实就是 0.75 * n
sc = n - (n >>> 2);
}
} finally {
// 设置 sizeCtl 为 sc,我们就当是 12 吧
sizeCtl = sc;
}
break;
}
}
return tab;
}
数组转红黑树
前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。我们还是进行源码分析吧。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
// MIN_TREEIFY_CAPACITY 为 64
// 所以,如果数组长度小于 64 的时候,其实也就是 32 或者 16 或者更小的时候,会进行数组扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
// 后面我们再详细分析这个方法
tryPresize(n << 1);
// b 是头节点
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
如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。 这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。 这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。
著作权归https://pdai.tech所有。
链接:https://www.pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentHashMap.html
// 首先要说明的是,方法参数 size 传进来的时候就已经翻了倍了
private final void tryPresize(int size) {
// c: size 的 1.5 倍,再加 1,再往上取最近的 2 的 n 次方。
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node<K,V>[] tab = table; int n;
// 这个 if 分支和之前说的初始化数组的代码基本上是一样的,在这里,我们可以不用管这块代码
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); // 0.75 * n
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {
// 我没看懂 rs 的真正含义是什么,不过也关系不大
int rs = resizeStamp(n);
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;
// 2. 用 CAS 将 sizeCtl 加 1,然后执行 transfer 方法
// 此时 nextTab 不为 null
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
// 1. 将 sizeCtl 设置为 (rs << RESIZE_STAMP_SHIFT) + 2)
// 我是没看懂这个值真正的意义是什么? 不过可以计算出来的是,结果是一个比较大的负数
// 调用 transfer 方法,此时 nextTab 参数为 null
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。 所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。
transfer数据迁移方法
太麻烦了
get方法
get 方法从来都是最简单的,这里也不例外:
计算 hash 值 根据 hash 值找到数组对应位置: (n - 1) & h
根据该位置处结点性质进行相应查找
如果该位置为 null,那么直接返回 null 就可以了
如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
如果该位置节点的 hash 值小于 0,说明正在扩容,或者是红黑树,后面我们再介绍 find 方法
如果以上 3 条都不满足,那就是链表,进行遍历比对即可
两个版本的区别
参考资料
https://www.pdai.tech/md/java/thread/java-thread-x-juc-collection-ConcurrentHashMap.html