金三银四面试必-ConcurrentHashMap底层
一、ConcurrentHashMap的存储结构
数组 + 链表 + 红黑树
正常put数据时,如果数组有位置,放数组上,因为查询效率最高。
如果put数据时,数组的位置上有数据,挂到下面的链表上,查询效率偏低
如果put数据时,发现链表很长,查询效率会受到很大的影响,此时会将链表转为红黑树,提升查询效率
虽然在JDK1.8中引入了红黑树的结构,但是用到红黑树的概率很低:
-
需要在链表长度大于等于8,并且数组长度大于等于64时,才会将链表转为红黑树
// 链表长度大于等于8,执行treeifyBin尝试做链表转红黑树操作 if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // treeifyBin内部判断,如果数组长度小于64,先执行扩容 if ((n = tab.length) < MIN_TREEIFY_CAPACITY) tryPresize(n << 1);
-
因为HashMap有扩容机制,让元素个数达到0.75时,会将数组长度扩大到原来的一倍,这样链表长度达到8的概率就会比较低,因为每次扩容会尽量的打散数据,不放在一个索引位置上。
// 而且ConcurrentHashMap的负载因子是不允许修改的,就是0.75…… private static final float LOAD_FACTOR = 0.75f;
-
基于源码的注释,可以看到,链表长度达到8 的概率很低,在0.000……6,所以红黑树出现的几率并不高
* The main disadvantage of per-bin locks is that other update * operations on other nodes in a bin list protected by the same * lock can stall, for example when user equals() or mapping * functions take a long time. However, statistically, under * random hash codes, this is not a common problem. Ideally, the * frequency of nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average, given the resizing threshold * of 0.75, although with a large variance because of resizing * granularity. Ignoring variance, the expected occurrences of * list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The * first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006
(泊松分布~)
为什么负载因子是0.75?
答:你可以从为什么负载因为不设置为0.5和1这个角度去回答。
如果是0.5:虽然可以尽可能的避免Hash冲突,数据尽可能都放在数组上了,但是浪费空间。
如果是1.0:虽然数组利用率很高,但是没法规避Hash冲突问题,这样会导致查询效率很低,而且Hash冲突概率很高
如果死扣这,就从泊松分布的概率学这去聊。
二、ConcurrentHashMap初始化数组的安全策略
HashMap这东西是线程不安全。如果有并发,更推荐使用ConcurrentHashMap,多线程写数据时是线程安全的。
HashMap或者ConcurrentHashMap,在new出来之后,数组的初始化是懒加载。也就是在第一个put数据时,才会将数组初始化!这导致在并发操作下,初始化数组会存在线程安全问题!
当执行putVal方法时,如果数组为null或者数组长度为0,此时需要执行initTable初始化数组。
为了可以看懂initTable方法,需要先对一个属性有掌握: sizeCtl
当sizeCtl为-1时,代表正在初始化!
当sizeCtl小于-1时,代表正在扩容!
当sizeCtl大于等于0时,没啥特殊的,代表没初始化,或者是下次扩容的阈值。
// 查看初始化过程
private final Node<K,V>[] initTable() {
// 声明2变量~做临时存储
Node<K,V>[] tab; int sc;
// 给tab复制,当前数组,
// 如果数组为null,或者数组长度为0,
// 如果满足判断,代表数组还没初始化,进来~~~
while ((tab = table) == null || tab.length == 0) {
// 将sizeCtl赋值给sc
// 如果sc 小于 0 ,代表数组正在初始化
if ((sc = sizeCtl) < 0)
// 别让CPU调度我,赶紧去调度初始化的那个线程,抓紧完成初始化,我好去干活!!
Thread.yield();
// 走到else if,说明没有线程在做初始化。
// 基于CAS,尝试将sizeCtl从原值改为-1.
// 如果CAS成功,代表当前线程可以去初始化了。
// 如果CAS失败,说明有并发,操作失败,重新走while循环
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 再次判断数组是否已经初始化。
if ((tab = table) == null || tab.length == 0) {
// 确认要初始化的数组长度。
// 如果你设置了,用你的,没设置,默认16.
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// new数组!
Node[] nt = new Node[n];
// 将初始化好的数组,赋值到成员变量table
table = tab = nt;
// 16 - (16 >>> 2) = 12
sc = n - (n >>> 2);
}
} finally {
// 将sizeCtl赋值为下次扩容的阈值。
sizeCtl = sc;
}
break;
}
}
return tab;
}
三、ConcurrentHashMap写数据的索引决策
如何基于key计算出当前key-value要存放的数组索引位置?而且如何尽可能的避免Hash冲突。
基于源码可以看到两行核心
// 基于key的hashcode计算出一个int类型的hash值
int hash = spread(key.hashCode());
// 基于数组长度n和上面计算的hash值,最终确定数据要存放的索引位置,判断是否为null
table[(n - 1) & hash] == null
1、分析为什么基于(n - 1) & hash确定索引位置
2、分析spread方法做了什么事情
咱们可以看到key2,key3,key4其实都是不同的hash值,但是低位都是一样的,导致数组长度不是很长时,最终计算出来的索引位置都是一样的,这样会导致Hash冲突,数据都挂到链表上了。
如果可以让hash值的高位也参与到(n - 1) & hash的运算中,这样就可以尽可能的打散数据存放在数组上了。
spread方法做的事情:
(h ^ (h >>> 16))
这样一来,高位可以在跟数组长度 - 1做&运算之前,让高位也参与到计算hash值的运算中
如果依赖就可以避免某些key的低位一样,但是高位不一样,最终却放到了同一个索引位置的问题,尽可能的避免Hash冲突的出现
四、ConcurrentHashMap写数据的安全策略
前面说过,HashMap是线程不安全,可能会因为并发问题,导致数据丢失。
ConcurrentHashMap是如何规避这个问题的,并且效率还没有太大的影响!
除了ConcurrentHashMap是线程安全的,还有一个远古的集合Hashtable也可以保证线程安全。
但是Hashtable一锁就锁全局,成本太套。
ConcurrentHashMap是基于数组的索引进行锁定……
源码中有两处操作,是分析这个问题的核心点。
// 数据要放到数组上的话,如何保证线程安全
// f是数组上指定索引的那个数据~
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 省略部分代码…………
// 数据要挂载链表上或者添加到红黑树时,如何保证线程安全
else {
synchronized (f) {
// 阿巴阿巴~~
}
}
如果数组元素为null,直接基于CAS的方式,尝试将数据插入到数组上。
如果数组指定索引位置有值,直接基于sync锁住这个索引位置,尝试将数据插入到数组上。
五、ConcurrentHashMap计数器的并发性和安全策略
每次对ConcurrentHashMap进行写操作时,每次写入成功后,会做一个计数的操作。
就是记录当前元素的个数。put方法,+1。remove方法,-1。
为了保证++操作的原子性,这里直接使用JUC下封装好Atmoic就ok了,他底层基于CAS实现,可以保证每次自增或者是自减线程是安全的!
Atomic中用的是do-while循环配合上CAS实现的。
在ConcurrentHashMap中,没有采用AtmoicInteger这种原子了,而是使用了LongAdder,基于分段锁的方式去做的处理。
ConcurrentHashMap没有引用LongAdder,而是基于LongAdder源码稍作修改,在addCount方法中实现的。
private final void addCount(long x, int check) {
CounterCell[] as; long b, s;
if ((as = counterCells) != null ||
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
CounterCell a; long v; int m;
boolean uncontended = true;
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();
}
// 省略扩容判断代码~~~
}
在执行size方法计算元素个数时,将BaseCount的值与CounterCell数组中的值求和,最终返回。
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
size会出现无法计算出当前准确的size,可能有并发的线程还没有添加进来。
这种情况是正常滴,ConcurrentHashMap就是保证你的查询效率得高,没有保证强一致!
并发编程- 三大特性、锁、阻塞队列、线程池、并发集合、并发工具、CompletableFuture
JVM - …………
MySQL - …………
Spring源码 - ……
Redis - 数据结构底层
MQ - ………………
场景问题 - 知识储备,常见的案例处理……
训练营第二次