前言
在进入主题前,先用一段描述性的话,来帮助理解put的整体思路:
需求: A盒子[容量不知],N物品。 现在让N物品放入到A盒子中;
过程:
1. 首先要先有一个N物品;
2. 在给N物品打上在A盒子的位置坐标,方便我后面获取【类似去超市,储物柜道理】;
3. 在看看有没有A盒子。如没有,就去得造一个A盒子,放物品的人,没告诉需要多大容量的盒子,那么默认造一个2次幂的容量的盒子。 注意:这时可能不是我一个人的N物品向往盒子里放入,如果有人在我之前已经在造盒子,那我别浪费功夫,我就等他造好直接用就行了。
4. A盒子造好后,就看看第二步中,N物品在A盒子的坐标位置上有没有物品已经存放了? 没有,那么直接放入,那这个需求也就完成了;
5. 但可能N物品在A盒子的坐标位置上已经有N+1物品存放,那么就锁定N+1物品这列,先一个一个去对比,N+1的物品与目前要放的N物品是不是一致的,是,则替换。 反则按照不同的规则【源码细讲】将其放在其后方即可;
6. 放完N物品后,要检查下这个A盒子的容量是不是快满了,如果达到这个盒子的百分之75,那索性就给你扩大一些。
7. 那还要在想,如果A盒子的东西很多很多,我一个人扩大完体积后,还得在搬运会不会太慢了? 答案是:肯定得,那我就加个标志,告诉后面在想放东西进来的人,我现在在为人民服务,把A盒子容量扩大,你们看见后,来一起搭把手,速度把A盒子扩好后,在用第四,五步的方法放入物品;
带着问题看源码:
经过上面蹩脚的描述,大家脑海有这个画面了吧。 那么现在就要用计算机的语言造出来,带着这么几个问题去看:
- 为什么在没有指定容量的时,默认容量时16呢?
- 为什么要用链表?
- 为什么有了链表后,还需要红黑树?
- 为什么链表长度达到8,才转红黑树?
- 为什么扩容时,新的容量时原容量的2倍? 可以查阅JUC1.8-ConcurrentHashMap源码学习-为什么每次扩容是原来两倍?
咳咳,这个就是本人在阅读过程中,脑海里缠绕的问题。问题比较多,部分问题,会在后期讲扩容时,回答。
ConcurrentHashMap主要数据结构:
/** 获取当前机器cpu的数量 */
NCPU = Runtime.getRuntime().availableProcessors();
/** 扩容时,储存临时tables,容量:原容量的2倍 */
volatile Node<K,V>[] nextTable;
/** 容量大小,通过CAS更新 */
volatile long baseCount;
/**
*控制线程扩容和初始化的重重重要参数,有一些参数值:
*0: 默认值;
*-1:table 正在初始化;
*-N:N-1个线程在协助扩容;
*基于初始化情况:
* 完成时:table的容量,默认是容量*0.75 。实际table的公式:n - (n >>> 2)
*未完成时:table需要初始化的大小;
*/
volatile int sizeCtl;
/*
* 用来返回节点数组的指定位置的节点的原子操作
*/
@SuppressWarnings("unchecked")
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
/*
* cas原子操作,在指定位置设定值
*/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
/*
* 原子操作,在指定位置设定值
*/
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
putVal()方法解析阅读:
final V putVal(K key, V value, boolean onlyIfAbsent) {
//非空判断
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
//无限循环
for (Node<K,V>[] tab = table;;) {
Node<K,V> f;//存放hashcode一致的node
int n, //当前table的长度
i, //table的index值
fh; //f节点的hash值
if (tab == null || (n = tab.length) == 0) //没有tables
//初始化2次幂的table
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { //给Node<K,V> f赋值 ---注意这点使用位运算(n-1)&hash 相对于hash%n取余. 并且与为什么容量是2次幂有莫大关系,后面分析.
//如果没有数据,则使用cas无加锁方式,插入
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}else if ((fh = f.hash) == MOVED) //hash为-1 扩容标志,说明是一个ForwardingNode,后面跟上的新的tab
//扩容后,并赋予tab;
tab = helpTransfer(tab, f);
else {//通过(n-1)&hash取余后,发现该index位置有值,俗称hash碰撞了. 那么来看看是怎么运用链表和数处理
V oldVal = null;
synchronized (f) {//锁住hash碰撞的这一列f节点[与之前区别,之前是锁一段tab,现在只是锁某一列,锁的颗粒变小了],防止增加链表的时候导致链表成环
if (tabAt(tab, i) == f) {//在次检查对应的index位置节点没有改变
if (fh >= 0) {// f节点的hash值不小于0时
//链表的初始化长度为1
binCount = 1;
//++binCount 死循环到链表尾部,将值插入
for (Node<K,V> e = f;; ++binCount) {
K ek;
//判断hash值与key是否相等,是则替换并跳出;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
//前一步判断不相等,那么判断当前node的next节点为空,则放入,否者将nextnode替换成当前循环node循环比对
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) {//f节点的hash小于0 并且是数结构
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) { //这块会做俩件事,通过binCount判断是否到转数阀值8,且总体容量超过64,才会转成红黑树. 如果达到转数阀值8,总容量没有到64,咋会进行扩容
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//计算估计容量数,超过容量阀值,进行扩容
addCount(1L, binCount);
return null;
}
套用最开始描述话,在来理解下,具体过程:
- 对key与val的NPE异常判断------->【有个N物品】;
- 计算key的hashcode------->【N物品打上在A盒子的位置坐标】;
- 进入tab无限循环中;
- tab为空的情况下,开始初始化tab,并且是以默认容量16进行,具体里面的逻辑也到扩容在讲------->【造A盒子】;
- 根据(n - 1) & hash计算在tab的下标,如下标为空,利用cas放置,跳出循环。
- 如果tab对应下标不为空,且该Node的hashcode为-1,那么判断该tab正在扩容。此时当前线程就得协助去扩容,helpTransfer也到扩容在讲;
- 如果tab对应下标不为空并且也非没有扩容标志,那么锁住当前链表或者数,保证了同时只有一个线程修改链表,防止出现链表成环。
- 冲突的这列的第一位node的hash不为负数,进行链表操作,查看hash以及key是否相等,是替换,反正添加在其链表尾部; 以上不满足则视为红黑树结构,进行数相关put操作
- 链表的会通过binCount记录链表长度,如果超过8,那么开始转数操作treeifyBin,这个方法在转数前,还会对总体容量是否达到64进行判断,不满足,则先扩容,满足才开始转数。
- 最后才是进行容器阀值的判断,也就是总容量的0.75.,不满足扩容,满足则put方法结束。
相信看完整体流程会发现与HashMap很类似,那么在这里我们看这几个重点方法:
initTable------tab初始化
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; //声明node数组
int sc; //声明sizeCtl控制标识
//当table = 空进入
while ((tab = table) == null || tab.length == 0) {
//sizeCtl<0 说明有其他线程正在初始化,把线程挂起来. 对于tables初始化,只能有一个线程进行
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
//利用cas将sizeCtl变成-1,表示线程正要进行初始化. 在此之前sizeCtl可能为 0 为正数
try {
if ((tab = table) == null || tab.length == 0) {
//如果当前sizeCtl 大于 0 那么容量为sizeCtl , 否则使用默认容量即16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); //相当于0.75*n ,计算下次扩容的阀值
}
} finally {
sizeCtl = sc;//设置初始化后,下一次扩容阀值
}
break;
}
}
return tab;
}
划重点:
- 当构建除ConcurrentHashMap(Map<? extends K, ? extends V> m)构建方法时,那么tab都是未初始化,都是会等到put、computeIfAbsent、compute、merge等方法的时候,才会进行实际吃实话,调用时机是检查table==null;
- 回答**“为什么在没有指定容量的时,默认容量时16呢?”**, 这个问题,是在没看源码之前才会有16这个数字疑问,但实际问题应该是 为什么容量都是的2的次幂呢?
答:因为这个公式 : (n - 1) & hash , 前面我们有提过,这个公式相当于取余, 那么取余也就单纯为了得到在tab的取模公式, 而且用位运算效率是高于 hash % n这种方式。 好,原因很简单,我们来证明,这个公式与容量时2次幂有啥关系呢?--------------请看盘者另一篇文章JUC1.8-ConcurrentHashMap源码学习-容量是2次幂,专门说明;
helpTransfer------tab协助扩容
可以看下https://www.cnblogs.com/stateis0/p/9062085.html 博客,写的非常详细;
总结
整体来说,咱们的并发map的put操作,不能说全懂,也应该有所窥视了。
与1.7的最大区别,就是锁的颗粒更新小了,以及最好性能的扩容,还能进行协助;
那么在来解答下前面有几个问题:
为什么要用链表?
答: 为解决key的hashcode碰撞后, 将碰撞的值,用链表的方式储存,俗称拉链法;
为什么有了链表后,还需要红黑树?
答: 因为链表天生不是适合遍历查询, 但当一定数据量后,链表长度也会N长,新添加值还要一一与链表的值进行核对后,在放置链表尾部。 所以此时一定得要一个高效查询的数据结构存在,so这是用空间换时间的做法;
为什么链表长度达到8,才转红黑树?
答: 其实这个问题,源码注释已经给了答案:
简单来说,hashcode在理想的情况下,在桶中的分布是遵循泊松有图可看出,桶长度的k的变化规则,桶超过8的概率很小,因此这个8也是遵循非常严谨的。