ConcuurentHashMap阅读笔记
文章目录
问题
1、ConcurrentHashMap 与 HashMap的数据结构是否一样?
- 是的,一样的,只是在node上增加了synchronized锁,且采用分段锁的思想
2、HashMap 在多线程环境下何时会发生并发安全问题?
- 修改操作会出现数据异常
3、ConcurrentHashMap 是怎么解决并发问题的?
- 通过对节点加锁的方式来实现的
4、ConcurrentHashMap 使用了哪些锁?
- Synchronized 和 Condition
5、ConcurrentHashMap的扩容是怎么进行的?
- 后序有空的话补充
6、ConcurrentHashMap 是否是强一致性的?
- 不是,只是最终一致性
7、ConcurrentHashMap 不能解决哪些问题?
- 后序有空的话补充
8、ConcurrentHashMap 中有哪些不常见的技术值得学习?
- 分段锁(第一次接触会让你发现分治思想的神奇)
一、简介
ConcurrentHashMap 是HashMap的线程安全版本,内部也是使用(数组+链表+红黑树)的结构来存储元素的。
相比于同样线程安全的hashTable来说,效率等各个方法都有极大的提高。
分段锁:是一种锁的设计思路,它细化了锁的粒度,主要运用在ConcurrentHashMap中,实现高效的并发操作,当操作不需要更新整个数组时,就只锁数组中的一项就可以了。
二、继承关系图
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RwEIF5Zo-1578390921773)(C:\Users\86134\AppData\Local\Temp\1578363493264.png)]
三、存储结构
数组+链表/红黑树,桶的节点大于等于8则树化
四、源码分析
内部类
Node(链表节点)
TreeNode(树节点)
属性
/** 元素存储表(key,value 的node数组) */
transient volatile Node<K,V>[] table;
/**
-1,表示有线程正在进行初始化操作
-(1 + nThread),表示有n个线程正在一起扩容
0,默认值,后续在真正初始化的时候使用默认容量
> 0,初始化或扩容完成后下一次的扩容吗门槛。
*/
private transient volatile int sizeCtl;
/**
这个的思想跟LongAdder类是一模一样的
把数组的大小存储根据不同的线程存储到不同的段上(也就是分段的原理)
并且把有一个baseCount,优先更新baseCount,失败则更新不同线程对应的段
这样就可以保证最小化的冲突
*/
private transient volatile CounterCell[] counterCells;
/** counterCells是否在初始化或扩容 */
private transient volatile int cellsBusy;
/** 用于最初的锁,如果更新失败,才会进入线程分段表 */
private transient volatile long baseCount;
构造
- 构造方法与
HashMap
对比是可以发现,没有HashMap
中搞得threshold
和loadFactor
,而改用sizeCtl
来控制,而且只存储了容量在里面- -1,表示有线程正在进行初始化操作
- -(1 + nThread),表示有n个线程正在一起扩容
- 0,默认值,后续在真正初始化的时候使用默认容量
- > 0,初始化或扩容完成后下一次的扩容吗门槛。
/** 构造方法一:无参构造 */
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;
}
/** 构造方法三:初始化容量为16,并初始化map元素集合 */
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;// 默认16
// foreach 遍历entrySet 执行putVal方法
putAll(m);
}
/** 构造方法四:无参构造 */
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
/** 构造方法五:无参构造 */
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
// 参数效验
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 保证初始容量最低不能低于并发级别
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
// 根据参数加载因子loadFactory 来确定容量大小,然后再推算
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
主要方法
1、put
-
如果桶数组未初始化,则初始化;
-
如果待插入的元素所在的桶为null,则尝试把此元素直接插入到桶的第一个位置;
-
如果正在扩容,则当前线程一起加入到扩容的过程中;
-
如果待插入的元素所在的桶不为空且不在迁移,则锁住这个桶(分段锁);
-
如果当前桶中元素以链表方式存储,则在链表中个寻找该元素或插入元素;
-
如果当前桶中元素以红黑树方式存储,则在红黑树中寻找该元素或插入元素;
-
如果元素存在,直接返回旧值;
-
如果元素不存在,整个Map的元素个数加1,并检查是否需要扩容
树化:当桶的数量(binCount)大于等8,则开始树化,如果本身是树,永远不会大于8,固定是2
锁主要有:自选锁、CAS、synchronized、分段锁设计(桶)
/** 外部调用方法 */
public V put(K key, V value) {
return putVal(key, value, false);
}
/** 元素添加具体方法*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 和 value 都不可以为null,不然抛 NullPointerException 异常
if (key == null || value == null) throw new NullPointerException();
// 计算key的hash值
int hash = spread(key.hashCode());
// 要插入的元素所在桶的元素个数
int binCount = 0;
// 死循环,结合CAS使用(如果cas失败,则会重新取整个桶进行下面的流程)
for (Node<K,V>[] tab = table;;) {
// f : 当前索引i在tab数组中对应的桶
// n : 桶的数量
// i : 通过hash位运算计算出的索引值
// fh: 当前桶的hash值
Node<K,V> f; int n, i, fh;
// 如果桶数组没有初始化或桶数量为0,则初始化桶数组
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)))
// 如果CAS插入元素时,发现已有元素了,则进入下一次循环,重新操作
// 如果CAS插入元素成功,则break跳出循环,流程结束
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
// 如果要插入的元素所在的桶的第一个元素的hash是MOVED
// 则当前线程帮忙一起迁移元素
tab = helpTransfer(tab, f);
else {
// 通过上面的流程,说明桶数组已初始化、当前桶也不为null、当前桶不在迁移
// 那么就开始锁住当前桶,然后开始进行逻辑
V oldVal = null;
synchronized (f) {
// 重新获取当前key对应的桶,然后和上面获取的桶进行比较
// 如果不相等,则重新循环(此时binCount 还是为0,没有变化的)
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;//桶中元素的数量,每次+1
for (Node<K,V> e = f;; ++binCount) {
// 当前循环的中节点的key
K ek;
// 每次都需要判断的hash是否和插入元素的hash一致
// 用 == 或用equals比较为true则找到了节点
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 节点中存在和插入元素相同的key
// 取出节点的值赋值给oldVal
oldVal = e.val;
if (!onlyIfAbsent)
// 赋予新值(onlyIfAbset=false)
e.val = value;
// 退出循环(进入if (binCount != 0)判断)
break;
}
// 当前节点的key 和 插入元素的key不一致,则向下寻找
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;
// 桶中元素个数赋值为2
binCount = 2;
// 调用红黑树的插入方法插入新元素
// 如果成功返回null
// 否则返回寻找到的节点
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
// 节点中存在和插入元素相同的key
// 赋予新值(onlyIfAbset=false)
oldVal = p.val;
if (!onlyIfAbsent)
// 赋予新值(onlyIfAbset=false)
p.val = value;
}
}
}
}
//如果binCount 不为0,说明元素插入成功,或找到元素并进行了修改
if (binCount != 0) {
// 如果插入节点是位置大于等于8 则开始树化
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
// 如果插入的元素已经存在了,则直接返回 旧值 给调用方
if (oldVal != null)
return oldVal;
// 说明元素是新加元素,则开始进行元素数量+1 操作
break;
}
}
}
// 成功插入元素,元素个数加1,(是否要扩容在这里)
addCount(1L, binCount);
// 成功插入元素,返回null
return null;
}
/** 桶数组初始化 */
private final Node<K,V>[] initTable()
/** 当前线程帮忙扩容 */
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f)
/** 红黑树的方式插入元素,插入成功返回null,否则返回找到的节点 */
final TreeNode<K,V> putTreeVal(int h, K k, V v)
/** 计算元素数量 */
private final void addCount(long x, int check)
2、initTable
- 使用CAS控制只有一个线程初始化桶数组
- sizeCtl在初始化后存储的是扩容门槛
- 扩容门槛是写死的,是桶数组大小的0.75倍,桶数组大小即map的容量,也就是最多存储多少个元素
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
// 判断是否未初始化
while ((tab = table) == null || tab.length == 0) {
// 小于0,说明正在初始化或扩容,就释放cpu资源
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 如果把sizeCtl原子更新为-1,则当前线程进行初始化
// 如果原子更新失败则说明有其它线程先一步进入初始化了,则进入下一次循环
// 如果下一次循环时还没初始化完成,则sizeCtl<0,让出cpu资源
// 如果下一次循环更新完毕了,则table.length!=0退出循环
try {
// 再次检查table是否为null,防止ABA问题
if ((tab = table) == null || tab.length == 0) {
// 如果sc等于0,则使用默认16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 新建数组
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 把新建数组赋值给table
table = tab = nt;
// n - (n >>> 2) = n - n/4 = 0.75n
// 由此可见,装在因子和扩容门槛都是写死了的。
// 这有是没有threshold和loadFactor属性的原因
sc = n - (n >>> 2);
}
} finally {
// 把sc赋值给sizeCtl,这也是存储的扩容门槛
sizeCtl = sc;
}
break;
}
}
return tab;
}
3、addCount
- 元素个数的存储方式类似于Striped64类,存储在不同的线段上,减少不同线程更新size会的冲突
- 计算元素个数把这些段的值及baseCount相加算出总的元素个数
- 正常情况下sizeCtl存储着扩容的门槛,扩容门槛为容量的0.75倍
- 扩容时sizeCtl高位16存储扩容邮戳(resizeStamp),低16位存储扩容线程数加1(1 + nThread)
- 其他线程添加元素后如果发生扩容,也会加入到扩容的行列中来。
private final void addCount(long x, int check) {
// as :线程锁表counterCells
// b :baseCount的值
// s : 元素个数总计(第一次是baseCount更新后的值,其实这时候线程分段表本身就是null,所以他也能代表是元素个数)
CounterCell[] as; long b, s;
// 如果
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// 如果counterCell不为null,
// 或者CAS更新baseCount失败,所以直接加入到counterCells
// a : 当前线程hash对应的CounterCell
// v : 对应CounterCell的value
// m :counterCells的数量
CounterCell a; long v; int m;
boolean uncontended = true;// 默认无竞争
// 如果as表未null
// 或者长度为 0
// 或者当前线程所在的段为null
// 或者当前线程的段上加锁失败
if (as == null || (m = as.length - 1) < 0 ||
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended =
U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 强制增加数量(无论如何数量是一定要加上)
// 和Striped64.longAccumulate添加值是一样的
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
// 计算元素个数
s = sumCount();
}
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
// 如果元素达到了扩容门槛,则进行扩容
// 注意,正常情况下sizeCtl存储的是扩容门槛,即容量的0.75倍
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)
// 扩容完成了,退出循环
// 正常应该只会触发nextTable == null 这个条件,
// 其他条件没看出来何时触发
break;
// 扩容未完成。则当前线程加入迁移元素中
// 并把扩容线程加 1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt);
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
// 这里是触发扩容的那个线程进入的地方
// sizeCtl 的高16位存储着rs这个扩容邮戳
// sizeCtl 的低16位存储扩容线程数加 1,即(1 + nThread)
// 《所以官方说的扩容时sizeCtl的值为 -(1 + nThread)是错误的》
// 进入迁移元素
transfer(tab, null);
// 重新计算元素个数
s = sumCount();
}
}
}
4、helpTransfer
- 线程添加元素时发现正在扩容且当前元素所在的桶元素已经迁移完成了,则协助迁移其它桶的元素
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
// 如果桶数组不为null,并且当前桶第一个元素为ForwardingNode类型,并且nextTab不为空
// 说明当前桶已经迁移完毕了,才去帮忙迁移其它桶的元素
// 扩容时会把旧桶的第一个元素置为ForwardingNode,并让其nextTab指向新桶数组
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
// sizeCtl < 0,说明正在扩容
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 扩容线程数加 1
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
// 当前线程帮忙迁移元素
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
5、transfer
- 扩容时,容量变为两倍,并把部分元素迁移到其他桶中
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 将 length / 8 然后除以CPU核心数,如果得到的结果小于 16,那么就是用个16
// 这里的目的是让每个CPU处理的桶一样多,避免出现转移任务不均匀的现象,如果桶较少的话,默认一个CPU(一个线程)处理16个桶
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 新的 table 尚未初始化
if (nextTab == null) { // initiating
try {
// 扩容两倍
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
// 更新
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
// 扩容两倍失败(n << 1 溢出变为负数了),直接用 int 最大值
sizeCtl = Integer.MAX_VALUE;
return;// 结束
}
// 更新成员变量
nextTable = nextTab;
// 更新转移下标,就是 老的 tab 的 length
transferIndex = n;
}
int nextn = nextTab.length;
// 创建一个标识类用于占位,当其他线程扫描到这个类的时候就会跳过
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
// 数组一层层推进的标识符
boolean advance = true;
// 扩容结束的表示符,true表示完成
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
// 每一个线程进入这里,先获取自己需要处理桶区间,第一次进入因为--i,会直接跳到else if 中的,对nextIndex进行赋值操作
// 这里设置了一个i = -1
// 如果当前线程可以向后推进;这个循环就是控制 i 递减。同时每个线程都会进入这里取得自己需要转移的桶区间
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;
}
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; // 更新table
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//再次循环检查一次表
}
}
// 获取老tab i下标位置的变量,如果是 null ,就是用fwd占位
else if ((f = tabAt(tab, i)) == null)
// 如果写进fwd,则推进
advance = casTabAt(tab, i, null, fwd);
// 如果当前位置不是null。且hash值为 -1,
// 说明其他线程已处理过这个桶,继续推进
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
// 锁住首节点
synchronized (f) {
// 二次判断地址偏移量锁指向位置是否与f对象相等
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
// fg > 0 为链表数据转移
if (fh >= 0) {
// 首节点的hash
int runBit = fh & n;
Node<K,V> lastRun = f;//最后一个节点
// 这个地方跟hashMap不同,hashMap是直接推进到链表尾
// 这个地方的处理在于想保留链表后所有hash值计算相同的点,
// 这些点可以重复利用,不用重新new
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
// 如果runBit == 0 。说明低位重用
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)
// 注意创建node接地那的最后一个参数ln指代的是next
// 也就是说,我们不再是从头到尾节点,而是从节点开始向头节点走
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 讲遍历好的链表一放入i中
setTabAt(nextTab, i, ln);
// 讲遍历好的链表二放入i+n中
setTabAt(nextTab, i + n, hn);
// 讲fwd占位放入旧表中
setTabAt(tab, i, fwd);
// 向前推进
advance = true;
}
// 如果是红黑树
else if (f instanceof TreeBin) {
// 如果第一个节点是树节点
// 也是一样,分化成两颗树
// 也是根据hash & n 为0 放在低位树中
// 不为0 放在高位树中
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> lo = null, loTail = null;
TreeNode<K,V> hi = null, hiTail = null;
// 遍历整棵树,根据hash&n是否为0分化成两棵树
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;
}
}
// 如果分化的树中元素个数小于等于6,则讲红黑树转换成链表
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);
// 高位数的位置是原位置加n
setTabAt(nextTab, i + n, hn);
// 标记该桶已迁移
setTabAt(tab, i, fwd);
// avvance 为true,返回上面进行--i操作
advance = true;
}
}
}
}
}
}
//参考阅读:https://www.jianshu.com/p/aaf769fdbd20
补充
累死了。还补充个毛。耗损精力啊。。