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