ConcurrentHashMap的底层实现

本文详细解析了ConcurrentHashMap的数据结构(数组+链表+红黑树),包括其存储逻辑、红黑树转换时机、扩容策略、DCL操作、散列算法、并发写数据机制和计数器实现。重点强调了如何优化性能和处理并发问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1. ConcurrentHashMap的前置知识

HashMap的存储结构为 数组 + 链表 + 红黑树

一般在项目中使用ConcurrentHashMap基本都是用作于缓存

用法

ConcurrentHashMap concurrentHashMap = new ConcurrentHashMap<>();
concurrentHashMap.put(key,value);

数组结构时的存储逻辑:

1.将key和value封装成一个Node对象。
2.再次基于key确认当前的Node需要放到数组的哪个位置,

当链表过长时,查询效率会降低,时间复杂度是On,为了提升效率,在JDK1.8中,就搞了个红黑树,提升查询

效率,解决链表查询慢的问题

在这里插入图片描述

2. ConcurrentHashMap的红黑树转换时机

红黑树的出现,本身是为了解决链表过长导致的查询效率降低的问题。

所以链表没那么长的时候,就不需要红黑树了。因为红黑树的维护成本也是很高的。

在ConcurrentHashMap中,有一个常量

static final int TREEIFY_THRESHOLD = 8;

这个属性就表示链表长度大于8的时候,需要将链表转换为红黑树。

但是并不是说,链表长度到8,就立即转换红黑树。

当存放数据时,binCount链表长度大于等于8时,会走treeifyBin的方法

if (binCount >= TREEIFY_THRESHOLD)
    treeifyBin(tab, i);

在treeifyBin的逻辑中,并不是立即转红黑树,而是先判断数组的长度是否小于64,如果小于64,需要先对数组做扩容操作,长度为原来的二倍

//  static final int MIN_TREEIFY_CAPACITY = 64;
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
    tryPresize(n << 1);

这么做的原因是,链表转红黑树是为了提升查询效率,如果因为数组太短,然后链表又太长的情况,数据可能出现了大量的hash冲突。那就先扩容数组,将之前出现大量hash冲突的数据打散。

链表转红黑树:链表长度大于等于8,数组长度大于等于64,才会做链表转红黑树的操作

为啥链表长度到 8 才转换,7行么?9可以不?

首先,因为源码写的8。但是理论上,7也ok,9也没问题。

链表为8是作者基于泊松分布计算出来的一个结果,当负载因子(扩容的契机)为0.75,再加上链表为8,以及散列算法种种,计算出,链表长度为8的概率是0.00000006。这个概率比较低,所以一般情况下,不会出现链表过长的问题。

如果你设置为7,链表长度为7的概率是0.00000094,也没啥问题。如果设置为9,只会比8的0.00000006还要低。

在这里插入图片描述

链表转红黑树是8,红黑树转链表是6,这个6是怎么个事?7行么?

static final int UNTREEIFY_THRESHOLD = 6;

其实,功能维度来说,7也是ok的,但是如果业务中频繁的增删数据,但是链表总是在7~8之间反复横跳,那就会造成代码不断的转红黑树,变回链表,再转红黑树,再变回链表………… 为了规避这个问题,7这个数值作为一个中间值,规避这种频繁的反复横跳的问题。

3. ConcurrentHashMap的扩容时机

扩容就是构建一个全新的数组,将之前老数组中的数据全部迁移到新数组上。

在这里插入图片描述

首先,源码中提供了一个负载因子:0.75。

指的是,当前ConcurrentHashMap中的元素个数,大于等于数组长度*0.75,那就会触发扩容操作。

//  size >= table.size * 0.75
private static final float LOAD_FACTOR = 0.75f;

为啥负载因子是0.75呢?

首先,可以不是0.75,在功能上不会有影响。0.75只是一个取中的值。

设置为0.5也ok,设置为1也ok,但是0.5和1都有点极端。

0.5会导致数组空间的利用率很低,至少有一半的空间都浪费了。

1会导致出现大量hash冲突,大量的数据挂到链表甚至红黑树上,影响查询效率。

那取中把,0.75~

4. ConcurrentHashMap的DCL操作(初始化数组)

啥是DCL?Double Check Lock,双重校验锁。

一般大家都是在单例模式的懒汉机制中学到的DCL。

ConcurrentHashMap中的数组是全局唯一的,也就是在ConcurrentHashMap对象中是单例的,而且这个数组不会在new ConcurrentHashMap时构建,而是第一次put数据时,才会构建。

为了保证数组的线程安全,ConcurrentHashMap就做了一个DCl操作来保证线程安全。

其中,优先了解一个核心属性,sizeCtl。当sizeCtl == -1时,代表数组正在初始化。

private transient volatile int sizeCtl;

比如,A,B两个线程要来初始化数组,谁优先将sizeCtl修改为-1,谁去初始化数组。

修改sizeCtl的操作为了保证线程安全,使用CAS实现。

这里就是初始化数组的触发判断和具体的方法。

if (tab == null || (n = tab.length) == 0)
    tab = initTable();

这里是initTable初始化数组的过程。

// 这个是初始化数组的操作。   
private final Node<K,V>[] initTable() {
    // tab是数组   sc是sizeCtl
    Node<K,V>[] tab; int sc;
    // 第一个判断,如果数组还未初始化,进入到while循环
    while ((tab = table) == null || tab.length == 0) {
        // 查看数组是否正在初始化,如果正在初始化,进入if
        if ((sc = sizeCtl) == -1)
            // 线程让步,让出CPU的时间片,稍等一内内。
            Thread.yield(); 
        // 数组还没线程去初始化,那就基于CAS尝试将sizeCtl修改为-1,成功返回true
        else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
            try {
                // 当前线程准备初始化数组
                // 第而个判断,如果数组还未初始化,开始初始化
                if ((tab = table) == null || tab.length == 0) {
                    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
                    // 初始化数组
                    Node[] nt = new Node[n];
                    table = tab = nt;
                    sc = n - (n >>> 2);
                }
            } finally {
                sizeCtl = sc;
            }
            break;
        }
    }
    return tab;
}

5. ConcurrentHashMap的散列算法

(1) 怎么基于key确定的位置

基于key计算索引位置的过程,就这一行代码

tabAt(tab, i = (n - 1) & hash)
// 上述代码一整理
table[(n - 1) & hash]
// 最终计算位置的内容只有
(n - 1) & hash

计算的方式

(n - 1) & hash = 数据存储的索引位置
n:数组长度
hash:key.hashCode()
当前数组长度为16,key=name
00000000 00000000 00000000 00010000 = n   16数组长度

00000000 00000000 00000000 00001111 = n - 1
&
01010101 10001101 00101001 00101010 = name.hashCode
=
00000000 00000000 00000000 00001010 = 10索引位置

存在一个问题: 数组长度如果不是2的n次幂,会发生什么?ConcurrentHashMap中有个特点,数组的长度一定是2的n次幂。如果不是会造成啥问题?

比如数组长度是17
00000000 00000000 00000000 00010001 = n   17数组长度

00000000 00000000 00000000 00010000 = n - 1
&
01010101 10001101 00101001 00101010 = name.hashCode
如果这么运算,数据的存放位置,只能是两个,要么0索引,要么16索引。大量的hash冲突就会出现。

(2) 怎么打散数据

所谓的散列算法,就是让高位也参与到计算索引位置的运算当中

h ^ (h >>> 16) 将高位右移

key1=name  key2=age
00000000 00000000 00000000 00010000 = n   16数组长度

00000000 00000000 00000000 00001111 = n - 1

01010101 00000000 00101001 00101010 = name.hashCode

01011111 11111111 00101001 00101010 = age.hashCode
会发现,name和age的hash值必然不一样,但是因为低位一样,导致计算的索引位置是一样的。
因为高位没有参与到运算中。

而计算的hash,其实并不是简单的hashCode,是经过了这个运算得出的。
h ^ (h >>> 16)      h是hashCode的值。

01010101 00000000 00101001 00101010 = name.hashCode
^
00000000 00000000 01010101 00000000 = name.hashCode >>> 16
=
...............................1010 = name的hash

01011111 11111111 00101001 00101010 = age.hashCode
^
00000000 00000000 01011111 11111111 = age.hashCode >>> 16
=
...............................0101 = age的hash


6. ConcurrentHashMap的写数据的并发安全

如果数据要添加到数组上,用CAS,CAS可以将数组某一个索引位置从NULL,修改为某一个值。

// 将tab数组的i索引位置从null,修改为当前new的Node,成功返回true,结束方法。
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))

如果数据要添加到链表/红黑树上,用synchronized锁住当前索引位置上的数据。

(JDK1.8的ConcurrentHashMap中,每个索引位置都有独立的一把锁。)

// f就是数组具体的索引位置的数据
synchronized (f) {
    // 添加数据到链表/红黑树
}    

总结: 没有hash冲突,CAS写数据。 有hash冲突,synchronized锁住头数据。

7. ConcurrentHashMap的计数器实现机制

所谓的计数器,每次写入一个数据+1,删除一个数据-1。

计算器就是在记录当前ConcurrentHashMap中的元素个数。

在JUC包下,有Atmoic原子类帮咱们实现了基于CAS的++,–操作。

比如AtomicInteger,AtomicLong之类的。

但是,在并发比较大的时候,这种Atomic存在很大的问题。CAS不会挂起线程的,这里的Atomic实现,是基于do-while做的,如果并发大,失败次数会很多,但是CPU依然要继续的调度,会造成浪费CPU资源的问题。。。

处理方案很简单,分段即可。

ConcurrentHashMap利用了LongAdder的实现机制,搞了一堆的CounterCell对象,每个CounterCell中都有一个可以存储元素个数的value。

这样一来,每个线程都可以存储到不同的位置,避免了多次CAS失败导致的浪费CPU资源的问题。

在这里插入图片描述

8. ConcurrentHashMap的size实现策略

size就是获取当前ConcurrentHashMap中的元素总数。

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;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Hunter灬ZH

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

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

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

打赏作者

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

抵扣说明:

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

余额充值