ConcurrentHashMap分析-整体概述

694 阅读11分钟

前言

前几天和朋友聊天,在聊到 Map 上我们都产生很大的兴趣。其中,ConcurrentHashMap 是自己不是很明白的,遂写此文,查缺补漏。

Map 可以说是我们在在日常工作中接触最频繁的 Java 类之一了,说第一都不为过。但是我们都知道常用的 HashMap 在并发场景下有很多缺陷,如线程安全问题,扩容死循环等,虽然死循环在 Java8 已经修复了,但线程安全问题还是难以解决。可能你会想到 HashTable 这样的线程安全 key-value 集合。但是效率却很低,可能不适用于现在这个整天拿高并发说话的大环境。那么真的没有什么即线程安全,又高效的 key-value 集合吗?有,就是本篇的主角 ConcurrentHashMap。提前说一下:该篇文章没有源码分析,因为通过和朋友交流发现,他们更细希望在短时间学会如何去面试,所以这篇文章会以概述的形式来整体讲解 ConcurrentHashMap,如果想看源码分析,下一篇就是。在讲述的过程中会穿插各种面试题,自认为整理的不错,如果有错误还望多多指点。

正文

ConcurrentHashMap 的结构

ConcurrentHashMap 整体数据结构采用和 HashMap 一致的数组 + 链表 + 红黑树。相较于 Java7 的分段锁设计,Java8 采用了 synchronized + cas 的方式保证了 ConcurrentHashMap 在并发场景下进行增删等操作。我们都知道 HashMap 是可以在初始化时设置大小和阈值的,而 ConcurrentHashMap 大神 Doug Lee 直接将几乎所有的属性给设置成 final 修饰,不可以修改,我们不用去考虑大小和阈值等因素,直接用就好了。

ConcurrentHashMap 沿用了和 HashMap 一致的默认大小为 16,加载因子为 0.75,扩容后的散列表大小为原来的 2 倍等性质,存储结构依然是 Node 结构,里面有 key,value,指向下一个 Node 的指针 next节点和 hash 值 。next 解决的是当元素存储发生冲突形成链表时一个元素指向下一个节点使用的。

恶心的 sizeCtl

sizeCtl,即 sizeControl,该变量贯穿了整个 ConcurrentHashMap。为什么说它恶心呢,因为它不但有各种值,而且各种值在不同的方法里又会分为各种情况。那么我们就简单了解一下它各个值的含义,具体体现会在具体方法里带出来。

  1. sizeCtl = 0,即默认值,表示创建 table 数组时使用 DEFAULT_CAPCITY 为大小。
  2. sizeCtl < 0
    • -1 时:表示当前 table 正在初始化(有线程在创建 table 数组) 当前线程需要自旋等待。
    • 其他负数:表示当前 table 数组正在进行扩容,高 16 位表示:扩容的标识戳,低 16 位表示当前参与并发扩容线程数量(1 + nThread).
  3. sizeCtl > 0
    • 如果table 未初始化,表示初始化小
    • 如果 table 已经初始化,表示下次扩容时的阈值(capcity * 0.75)

怎么样,恶心到你了吗,接下来我们进入具体方法看看这些值在什么情况下会执行什么操作。

put 添加方法

首先,ConcurrentHashMap 和 HashMap 一样是延迟加载的,即 new 出来时不分配空间,等到真正用时,分配大小。那么当我们运行到 put 方法中发现当前 map 为空,那么我们会进入 initTable 方法区进行初始化。初始化时校验 sizeCtl 值,发现小于 0,那么大概率是 -1,此时可能其他线程在进行创建 table 过程,当前线程进入自旋等待,并会短暂释放它所占用的 cpu,让当前线程去重新竞争 CPU 资源。为了让 CPU 资源给其他更饥饿的线程去使用。如果不小于 0 呢会走下一个判断,尝试用CAS 的方式将 sizeCtl 设置为 -1。如果设置成功说明当前线程执行的初始化,开始创建散列表数组大小。如果不成功则退出。

初始化就概述完了,那么如果已经初始化过了呢?那么我们肯定要计算要插入元素的 hash 值并计算它在当前散列表的位置下标。如果计算出下标发现该桶位值为 null,说明当前桶位没有值,直接插入就好;否则该桶位有值。那么就要判断这个值的类型。如果桶位头节点的 hash 值为 MOVE(一个标识)。那么说明当前节点为 FWD 节点(迁移完的元素会被标记为 FWD 节点),说明当前 map 正处于扩容中,那么该线程就被拉过来干活,帮助扩容,就是扩容迁移是分段迁移的,一个线程可能领取一个段元素去迁移。(举个例子,你去朋友家蹭饭,没想他们在包饺子,那么你只能帮忙去包饺子。)

说了桶位空值和正在初始化等特殊情况,那么接下来就是正常情况。当前桶位有值,那么就是冲突了。冲突了无非两种情况:一,形成链表;二 形成了红黑树。这里就体现出来安全特效。ConcurrentHashMap 会用 sync 锁住当前桶位,保证在当前桶位下操作是同步的。先说链表:如果发现链表中的值与其他值都不一样那么就追加到链表末尾,如果一直就替换并返回旧值。接下来是红黑树,如果当前桶位头节点是 treeBin 类型的,那么就走增加红黑树节点的理论。(感觉面试的时候也不会太较真,如果真想了解可以看我下一篇关于源码的讲解)。

那么,那么常见的面试题,并发 map 是如何保证写数据安全呢?

通过上面描述,我们可以当计算出要添加的元素的位置后,如果该位置在散列表中没有值则通过 cas 的方式添加。如果有值,则通过 sync 锁住桶位头节点,保证该桶位同步操作。

扩容

其实 ConcurrentHashMap 也好,HashMap 也罢,他们真正的难点都是在添加元素和扩容上,这有这两处涉及的场景很多。向 get 什么无非就是找找元素,找不到拉倒。所以接下来要开始啃扩容了。扩容啃完难点几乎没有了。但是很少啊,有面试官会揪着这里不放,除非校招(还是可能)。因为我觉得你把上面的东西说了面试官就差不多觉得你看过了,没必要继续为了死知识探讨下去。把这时间花费在场景题和算法题岂不美哉(手动狗头)。但是呢,既然都做专题了,那就做到底,接下来就好好聊一下,扩容的恶心之处。

并发 map 的扩容里面有堪称史上最长自旋代码,恶心程度不言而喻。我们可以拆开逐步分析,有想了解的可以看我下一篇源码分析(为了源码我写的当天只睡了 3 小时)。

首先扩容我们需要计算出步长 stride,意思是数据迁移时,如果一个一个迁移很慢,那么我们一个区间一个区间去迁移更快更容易。这个区间大小就是步长。如果当前线程是触发本次扩容的线程,那么他就会创建一个大于原散列表数组两倍的新的数组。将新数组赋值给全局变量 nextTable,以便后续协助线程知道应该将数据迁移到哪。并记录整体数据迁移进度ferIndex。(并发 map 是从下标大号元素开始迁移,所以 transferIndex = 原数组长度)。迁移完的元素会被标记为 FWD 节点。FWD 里面有一个指向新表的字段,还提供了查询新桶位的 find 方法。如果当前线程不是触发本次扩容的线程,那么他就是协助线程。那么 ConcurrentHashMap 会给协助线程分配迁移任务,任务就是刚刚提到的步长,知道自己应该迁移哪块,迁移到哪去(nextTable)。如果迁移到一个空节点,那么只需要通过 CAS 的方式将其设置为 FWD 节点即可。如果迁移的桶位有值,那么就通过 sync 的方式锁定桶位,如果该桶位有链表,那么通过高低位的方式重新定义节点在新表中的位置,是元素分散。如果是红黑树,那么就遍历 treebin 代理的双向链表,重新计算元素在新数组的位置。具体实现可以参看下一篇源码分析。

由此我们了解了怎么扩容,如何安全扩容,多线程怎么扩容(一个触发扩容,其他协助迁移数据)。

get 查询方法

查询就很简单了,就是通过要查询的元素的 key 计算出其 hash 值,然后通过这个值去桶位找。如果当前桶位值为 null,直接返回 null;如果不为 null,查看当前桶位值是否和要找的元素一致,是则返回;不是呢我们要看其桶位元素 hash 是否小于0。小于 0 要么是 -1 表示正在扩容呢,该元素已经被迁移走了,调用 fwd 内部类的 find 方法 ;要么为 -2,在红黑树里,调用红黑树的 find 方法。最后还有一种情况:链表。链表就是遍历啦,找到返回找不到返回 null。

刁端小问题

再扩容过程中再来写请求怎么处理?

如果写操作访问的桶位还没有被迁移,那么先拿到桶位的锁,然后正常插入就 OK 了。如果写操作访问的桶位头节点为 FWD 节点,说明当前正在扩容呢。作为并发 map ,当然扩容速度越快越好。而 ConcurrentHashMap 的扩容方式是拉非扩容线程去干活,给他们分配任务。(反正我在扩容你也写不成,那就来干活吧) 根据全局变量 transferIndex 去规划当前任务区间,比如下标 【20,25】之间的元素你来搬运 。 迁移完再根据全局 transferIndex 去分配下一批任务。知道当前线程再也分配不到任务的时候,此时扩容工作基本完成。

扩容期间,扩容工作线程如何维护 sizeCtl 低 16 位的呢?

每个线程在执行扩容任务前,都会更新 sizeCtl 的低 16 位,让低 16 位 + 1,记录一下来过的线程。如果线程分配不到任务退出前呢,还会更新一次 sizeCtl,让其低 16 位减 1。

Node.hash 为负数时都是什么情况

  1. 当前节点为 FWD 节点,那么 hash 值为 -1
  2. 当前节点是属于红黑树类型的代理 treebin 节点,hash 值为 -2

当桶位升级成红黑树,且当前红黑树上有读线程访问,再来写请求怎么办?

TreeBin 有一个 state 属性,每个读线程在读数据之前,都会用 CAS 的方式将 state 的值 +4,读完数据之后,再用 CAS 的方式将 state 的值 -4。在写线程写数据到红黑树结构之前会检查 state 字段。看 state 是否为 0。如果为 0 ,表示这颗树上没有读线程访问,写线程就用 CAS 的方式将 state 值设置为 1。(表示我加锁了,别的线程别来了,来也没用)其他线程就不能操作树了。如果不是 0,说明有线程在访问这颗树,写操作无法进行。写线程会把自己线程 Thread 引用暴露到 TreeBin 对象内,再让 state 的bit 位的第二位设置为 1。表示有写线程处于等待状态。然后写线程使用 LockSupport.park() 接口将自己挂起。

那什么时候唤醒呢?

读结束后会让 state - 4,然后读线程会检查 state 是不是 2,如果是 2 说明有写线程在挂起等待。(这里 bit 位的第二位设置为 1,转换成十进制是 2)如果 = 2 说明有一个写线程在等待,然后这个读线程就调用 unpark() 将写线程唤醒。

那么读线程来了,发现 state = 1,即有线程再写怎么办呢?

这个时候我们就要知道,treeBin 是一个红黑树 + 双向链表的结构。既然我不能访问红黑树,那我就去访问链表。

treebin.png

结语

ConcurrentHashMap 整体概述就完成了,我太累了,只睡了三个小时,源码文章还差 30%,要去肝了,今晚必须肝下来。以上都是面试中说说就行,我感觉已经很详细了,真的不会这么问。那么如果你有兴趣,觉得某些点没有讲得太清楚,可以去看我的源码分析文章,里面会有答案。

生命不息,内卷不止。