ConcurrentHashMap 并发之美

1、前言

她如暴风雨中的一叶扁舟,在高并发的大风大浪下疾驰而过,眼看就要被湮灭,却又在绝境中绝处逢生html

编写一套即稳定高效、且支持并发的代码,不说难如登天,却也绝非易事。java

一直有小伙伴向我咨询关于ConcurrentHashMap(后文简写为CHM)的问题,经常抱怨说:其余源码懂就是懂了,不懂就是不懂,惟独CHM总给人一种似懂非懂的感受,感受抓住了精髓,却又若即若离。其实,之因此有这种感受,并不难理解,由于本质上CHM是一套支持高并发的代码,同一个方法、同一个返回值,在不一样的线程或不一样并发场景都须要完美运行,之因此感受似懂非懂,多是由于只抓住了某一类场景。区别于其余源码,咱们读CHM时,也必定让本身学会分身。node

本文在介绍CHM原理时,会更多的以分身的角度去看她,我会尽可能抛弃逐行读源码的方式,并抱着为CHM找bug的心态去读她(不存在完美的代码,CHM也不例外)git

2、概述

本文介绍的CHM版本基于JDK1.8,源码洋洋洒洒共有6000+行代码,本文着重介绍put(初始化、累加器、扩容)、get方法github

建议没有读过源码的同窗先看一遍源码,而后带着问题来读,这样更容易读懂并吃透她数组

3、总体介绍

3.一、模型介绍

咱们首先把1.8版本的CHM数据结构介绍下,让你们对她有个宏观认识
缓存

  • 说明:此示意图仅为展现CHM数据结构,并不是真实场景,例如数据个数若是超过数组长度的3/4,会自动进行扩容;还有某节点下hash冲突严重,致使链表树化的时,数组长度至少要扩容至64

名词约定

分桶: 如上图所示,CHM的Node数组长度为16,咱们把每个数组元素及其相关节点称为一个分桶,可见一个分桶的数据结构能够是链表形式的,也能够是红黑树或者null数据结构

结构简述

在没有指定参数的状况下,CHM 会默认建立一个长度为 16 的 Node 数组,随着数据 put 进来,CHM 经过 key 计算其 hash(正数) 值,而后对数据长度取模,确认其将要插入的分桶后经过尾插法将新数据插入链表尾部,当链表长度超过8,CHM 会将其转换为红黑树,为以后的查询、插入等提速,红黑树的数据结构为 TreeBin,hash值固定为-2;当因发生节点删除致使红黑树总长度低于6时,便从新转换为链表。一旦数量超过 Node 数组长度的 3/4,CHM 便会发生扩容。多线程

class Node<K,V> implements Map.Entry<K,V> {
   final int hash;	// hash值,正常节点的hash值都为正数
   final K key;	// map的key值
   volatile V val;	// map的value值
   volatile Node<K,V> next;	// 当前节点的下一个,如没有则为null
}

以上是 CHM 的操做梗概,不少细节都没展开来讲,你们先有个宏观概念便可,另红黑树的操做本文不会展开来讲,因本文主要侧重点为并发,而操做红黑树时通常都挂有synchronized锁,那多线程并发的场景便不会涉及,读者若是有兴趣可自行google、百度;或者参考本人的github工程git@github.com:xijiu/share.git,里面有关于红黑树、B树、B+树等详细用例,值得一提的是用例会直接在控制台打印树信息,方便调试、学习并发

3.二、宏观认识

put方法的流程以下图所示,其中涉及几个关键步骤:table初始化扩容数据写入总数累加。其实总体来看的话,流程很简单,没有初始化时,执行初始化,须要扩容时,帮助扩容,而后将数据写入,最后记录map总数。接下来咱们逐个分析

注:本文中,橙色线表示执行时不加任何锁;蓝色表示CAS操做;绿色表示synchronized

3.三、初始化

变量说明

table 成员变量,volatile修饰,定义为 Node<K,V>[] table,初始默认值为null;Node的数据结构简单明晰,为map存储数据的主要数据结构,读者可自行参看jdk源码,此处再也不赘述

sizeCtl int 类型的成员变量,volatile修饰,保证内存可见性,主要用来标记map扩容的阈值;例如map新建立时,table的长度为16,那么siteCtl=leng*3/4=12,即达到该阈值后,map就须要进行扩容;siteCtl 的初始默认值为 0。不过在table初始化或者扩容时,sizeCtl 会复用

  • -1 table初始化时,会将其经过CAS操做置为-1,用来标记初始化加锁成功
  • ≈ -2147024894 很大的一个负数,逼近int最小值,扩容时用到,主要用来标记参与扩容线程数量以及控制最大扩容并发线程。具体计算公式为((Integer.numberOfLeadingZeros(n) | (1 << 15)) << 16) + 2,其低4位及高4位都有设计理念,在讲到扩容部分时会详细介绍

质疑

Ⅰ、问:最后直接将 sizeCtl 修改成12时,是否存在漏洞?设想场景:当线程 A 执行到此处,并完成了对 table 的初始化操做,但还未对 sizeCtl 进行赋值。新的请求进来后,发现table不为null,那么便执行赋值操做(初始化线程还未执行完毕),在后续的扩容判断时,sizeCtl 的值一直为-1,致使CHM异常

答:其实这个问题质量很高,的确存在描述的状况,不过即使真的出现,也不会致使CHM异常,在扩容阶段有个关键判断(sc >>> RESIZE_STAMP_SHIFT) != rs会将扩容操做拦截,在讲到扩容部分时,会详细说明。因此在初始化线程 A 已经完成对table的初始化,但还未执行 sizeCtl 初始化就被hang住后,其余线程是能够正常插入数据,但却不会触发扩容,直到线程 A 执行完毕 (注:上述分析的案例发生的几率极低,但即使是再小的概率也会有可能触发,此处可见 Doug 老爷子编码之严谨)

3.四、数据插入

变量说明

Node 及 hashCode 其实节点类型与hashCode一一对应

  • 一、null,即table新建后,尚未内容加入分桶
  • 二、List Node,hashCode >= 0;即桶内的链表长度没有超过8
  • 三、Tree Node,hashCode == -2;红黑树
  • 四、FWD Node,hashCode == -1;标记转移节点
  • 五、ReservationNode,hashCode == -3;在computeIfAbsent()等方法使用到,本文再也不展开

质疑

Ⅰ、问:[点1] 若是当前分桶 f 若是为空,那么会新建 Node 节点并将其插入,若是2个线程同时进入,不会致使数据丢失吗?

答:不会。由于CAS操做确保了赋值成功时,f 节点必须为null,若是2个线程同时进入当前操做,必定会有一个失败,进而重试。此处有一个小点,即 CAS 失败后,程序从新轮训,new Node的操做岂不是白白浪费了空间?的确是这样,不过也不太好避免;除非是为其添加剧量级synchronized锁,在锁内开辟空间,不过这样又会影响性能,相似场景的操做后文还会涉及

Ⅱ、问:[点1] 若是在执行当前操做时,map发生了扩容,而成员变量 table 已经指向了新数组;而此处会将新建的 node 节点赋值给老的 table,岂不是致使了当前数据的丢失?

答:不会。一样仍是CAS的功劳,扩容时若是发现 f 节点为null,会经过CAS操做将其修改成 ForwardingNode 节点,无论是当前操做仍是扩容,失败的话都会触发重试

Ⅲ、问:[点2] 若是在进行赋值操做时,map触发了扩容,成员变量table已经指向了新的数组,那此处添加的新节点岂不是要丢失?

答:不会。由于在扩容时,也须要对分桶加锁,也就是在分桶粒度看的话,添加新节点与扩容是互斥的关系,正在进行添加操做的过程当中,当前分桶的扩容是没法进行的

Ⅳ、问:[点2] 不管是List Node仍是Tree Node,虽然有synchronized加持,但在进行最终赋值操做时,都没有CAS控制,会不会致使最终数据的不一致?

答:不会。其实要回答这个问题,首先要分析Node涉及写操做的变动场景。以下:a、正常向分桶添加、修改数据;b、扩容;c、table初始化;d、节点删除。而table初始化必定发生在当前操做以前,不然当前线程会先执行初始化操做,其余a、b、d在操做伊始都会对桶添加同步锁synchronized,保证了修改操做的同步执行

3.五、累加器

总体思想

相信不少同窗直观感觉是:不就作个多线程计数器累加么,至于搞这么复杂?直接使用AtomicInteger不香吗?其实此处做者为了提速仍是用心了良苦。累加器的核心思想与LongAdder是一致的,其本质仍是想尽力避免冲突,从而提升吞吐。与扩容不一样,在并发比较大的场景下,累加器很快就能达到stable状态,缘由是counterCells数组的长度超过了CPU核数时,便不会继续增加。

为何使用LongAdder而不是AtomicInteger?首先二者实现累加的机理是不一致的,AtomicInteger只有一个并发点,好处是每次累加完,均可以拿到最新的数值;弊端是多CPU下,冲突严重。LongAdder则根据使用场景动态增长并发点,带来的最大收益即是提升了写入的吞吐,但由于冲突点变多,每次统计最新值时,煞费周章。二者谈不上好坏,或谁取代谁,都要视你的应用场景而定。而CHM的size()方法的更偏向写多读少,故采用LongAdder的处理方式。本节后有关于二者的对比实验

变量说明

baseCount 定义为private volatile long baseCount; CHM的成员变量,累加时若是出现冲突,会将压力打散

counterCells 定义为private volatile long baseCount; CHM的成员变量,map的总数即是由baseCount及counterCells联合存储的,定义为:

@sun.misc.Contended (解决缓存行伪共享问题)
static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

质疑

Ⅰ、问:[点1] 既然要进行CAS控制,能够不要cellBusy == 0counterCells == as这2个判断吗?

答:能够。由于在CAS加锁成功后,还会进行double check,查看counterCells是否已经被初始化。可是直接进行CAS加锁操做会影响效率,试想若是counterCells已经被另一个线程初始化完毕,若是有这2个判断,就能够直接跳出本次循环,不然还要进行CAS抢锁

Ⅱ、问:[点2] 会有counterCells != as的场景吗?

答:会,例如2个线程都发现counterCells == null,都进来初始化,具体场景可参见上述流程图

Ⅲ、问:[点3] 若是执行cas期间发生counterCells扩容咋办?

答:其实累加器的扩容不一样于map中table数组的扩容,table的扩容是会新建Node对象,而累加器的扩容则不会新建对象,而是直接复用已建立的CounterCell对象,且数组的下标都不会发生变化,因此即使是在执行CAS期间发生了扩容,也不会影响总体计数的准确性

Ⅳ、问:[点4] Doug 老爷子是否是写漏了?竟然在CAS锁外直接建立对象,若是CAS失败,这个new操做岂不是无谓之举,影响性能?

答:其实看到这里第一反应就是不够严谨,在加锁前执行这个操做容易形成 r 的无谓牺牲;但再一仔细琢磨,做者此举是有深意的,主要为如下二点:一、new操做跟分支判断等语句是很耗时的操做,放在锁外,可减小当前线程对锁的占用;二、counterCells数组不一样于table数组,其最大值max介于
CPU <= max < 2*CPU。在并发较大的状况下,很快就能达到stable状态,不会一直上涨。因此这块为了性能的提高,仍是煞费苦心的

Ⅴ、问:[点5] 全部进入累加主逻辑的线程,在累加结束后,所有都直接返回了,也就是再也不参与后续的扩容逻辑,若是刚好本次累加后,总体长度达到阈值而又不扩容,岂不是形成CHM过载?

答:又是一个精妙的细节!的确是这样,也就是CHM不严格保证在长度达到阈值后,立刻进行扩容。为何这样设计呢?其实主要仍是为了不频繁的调用sumCount()方法,由于计算总长度的方法采用的是LongAdder分散法,每次统计长度相对来讲是比较耗时的,而能进入累加主逻辑的话,代表如今并发比较大,在大并发下每一个进入的流量都计算长度是得不偿失的,因此此处牺牲了及时进行CHM扩容的代价,换取了累加的高性能;而其余协助扩容的线程仅是判断分桶 f hashChode == -1才会协助扩容,一样也不会调用sumCount()方法

LongAdderAtomicLong写入性能对比,将目标值从1多线程累加至10亿,分别统计2个并发类的耗时。原本打算将CHM中计数器累加部分的代码抠出来作性能对比,但其本质上是LongAdder的思想,因此咱们直接抓其精要

并发数 1 2 3 4
AtomicLong 6311 19375 21209 27508
LongAdder 11003 5252 3647 2900

注:仅测试写入性能,单位(ms)。测试用例 git@github.com:xijiu/share.git

3.六、扩容

总体思想

多线程协助扩容是CHM最难最重要的部分,同时也是存在bug的部分

具体实现思路咱们可先打个比方:比如咱们有100块砖头须要从A搬至B,可是每人每次只能搬运10块,路途花费5分钟,假如某人完成一次任务后,发现A地还有剩余砖块,那么他还将持续工做,直至A地没有剩余砖块,他的工做才算结束。每一个人进入场地前首选须要领取一张工做许可证,而管理员手中共有20张许可证,即最多容许20人同时工做。当有人开始归还许可证时,并不表明全部的砖块已经从A搬运至了B,由于虽然此时A地已经没有砖头,但并不表明全部的砖头都已搬运至B,可能有些砖头正在路上,因此只有最后一张许可证归还时,才表示全部的工做已经作完

而体如今CHM上的话,则是由transferIndex字段控制,例如map中table的长度为16,步幅为4,transferIndex的初始值为16,每一个线程进入后对其进行CAS加锁操做(transferIndex = transferIndex - 4),若是加锁成功话,当前线程便获取了转移此4个节点的惟一权限,转移完毕后,如 transferIndex > 0,当前线程还会尝试对transferIndex进行加锁并转移,直至transferIndex == 0;因此本例中transferIndex存在的5个状态:1六、十二、八、四、0

  • 链表转移

    如上图所示,对节点6进行扩容,分桶内的数据只会对应新table中的2个分桶,即桶6跟桶22,而后分别将以前的数据拷贝一份,并造成2个list,而后挂在新table的对应分桶下。此处为何要新建而不是直接引用?主要是为了保证get方法的吞吐,即使是在扩容阶段,get也不受影响

  • 红黑树转移

    其主要思想与链表转移相似,惟一不一样是,红黑树拆分后可能变成2个红黑树、或者1个树1个链表、或者2个链表

质疑

Ⅰ、问:[点1] 第一个进入扩容的线程,在抢到锁至为nextTable赋值是有一点gap的,假设某个后续线程在执行时,正好处于这个gap,那nextTable == null就会成立,这样岂不是会致使当前线程误觉得扩容已经结束,而后直接返回了么?这是不是一个bug?

答:的确是问题描述的这种状况,不过是不是bug值得商榷。由于首先协助扩容并非功能上强依赖的,即使是只有一个线程在扩容,其余线程一直在等待也不会对总体功能有影响;其次这个gap存在的时间相比较整个扩容来讲仍是比较短的,若是某个线程正好处于这个gap对总体性能的影响可控

Ⅱ、问:[点1] (sc >>> 16) != rs这个表达式何时会成立?直观看代码,好像(sc >>> 16)恒等于 rs 呀?

答:好问题,其实要回答这个问题还要看结合后续的扩容逻辑来看,在扩容结束后,最后一个线程会给成员变量赋新值,赋值的顺序为:

nextTable = null;
table = nextTab;
sizeCtl = n * 2 * 0.75;

可见,他们没法作到原子操做,而是有前后顺序;设想当程序已经为table赋了新值,而sizeCtl还未被赋值时(此时sizeCtl为一个很大的负数),某个线程处理新数据添加并判断是否要扩容时,便命中了此判断,由于此时sizeCtl的高16位标记的仍是旧的table长度,因此此判断仍是很是严谨的。让我不由想到了不朽名著《红楼梦》的“草蛇灰线,伏脉千里”啊,叹叹!

Ⅲ、问:[点2] 此表达式在什么场景下会成立?前面会对 transferIndex 进行CAS加锁,按理说这个表达式永远不会成立?

答:仅当前的逻辑,此表达式确实永远不会成立。但是最后一个负责扩容的线程会对全部的节点进行一遍double check,来确保全部的节点的hash值都为-1,即全部节点都完成转移

Ⅳ、问:[点2] 既然每一个线程都按照严格的加锁顺序将CHM已经转移完毕,为何最后一个线程还要执行double check?

答:若是你读源码也注意到了这点,那么恭喜你,你发现了CHM的另外一个bug!的确,最后一个线程再次double check是彻底没有必要的,doug 本人已经实锤,是前一个版本遗留的,会在下个版本中删去;其实我本人读到这儿时,纠结了很长时间,一直不明白做者此举用意,心想是否是上下文有些漏读的信息,致使浪费了很多时间哈。此优化具体可参看: http://cs.oswego.edu/pipermail/concurrency-interest/2020-July/017171.html

Ⅴ、问:[点1] 流程图中标注在计算最大线程时存在bug,为何CHM真正跑起来时历来没有遇到过?

答:CHM这个控制最大参与扩容并发线程树的bug,源码是

if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
	sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
   	transferIndex <= 0)

此处其实为想获取正常参与扩容的线程数,应修改成sc == (rs << 16) + 1 || sc == (rs << 16) + MAX_RESIZERS,之因此咱们实际生产过程当中不多碰到,是由于首先须要线程数达到MAX_RESIZERS65536个,才有可能出问题。此bug地址 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8214427

3.七、get方法

get方法相对简单,先上源码

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

其实也就是直接获取值,是链表或红黑树,就直接寻找,若是分桶为空,也就直接返回空;能作到这么潇洒,仍是得力于volatile关键字以及CHM在扩容时对数据进行复制新建

4、总结

文中的流程图算是比较重要的信息,CHM的功能、并发、知识点全都涵盖在里面,建议读者一边看图一边参照源码,这样更能加深印象,也更容易吃透CHM

原本想作个知识点总结的,结果发现赫赫有名的CHM仅仅用到了CAS、volatile、循环以及分支判断,让咱们不由对 doug 肃然起敬,他留给咱们的东西太美了

相关文章
相关标签/搜索