ConcurrentHashMap底层

文章详细解析了ConcurrentHashMap的存储结构,包括数组+链表+红黑树的组合,以及何时会将链表转换为红黑树。同时,文章介绍了其线程安全的初始化策略,如何在并发环境中保证数组初始化的安全。此外,还讨论了ConcurrentHashMap的索引决策和写数据的安全策略,确保并发写入时的数据一致性。最后提到了计数器的并发性和安全策略,使用LongAdder保证计数操作的线程安全。
摘要由CSDN通过智能技术生成

一、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

在这里插入图片描述

二、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 - ………………

场景问题 - 知识储备,常见的案例处理……

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

water-之

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值