死磕 Java 并发编程(8):CurrentHashMap 如何实现高效地线程安全?在 Java8 中有哪些设计实现的演进?
这篇文章一开始我以为会比较简单,但是在深入源码分析时,遇到了很大的阻碍,比前面我们分析AQS以及读写锁的源码要难理解的多,断断续续也写了4天了。 如果你看完还是没有理解的话,那我在这里表示深深的歉意,同时也欢迎你和我一起沟通。
本文是死磕Java并发编程系列文章的第 8 篇,主角就是 java 并发包中提供的 CurrentHashMap
这是一个线程安全且高效的HashMap ,也是面试的高频考点。本文主要围绕 ConcurrentHashMap 如何实现高效地线程安全?以及在Java8中它从设计实现上有哪些演进?
网上关于 HashMap 和 ConcurrentHashMap 的文章确实不少,不过目前的很多分析资料还是基于其早期版本,所以才想自己也写一篇,把细节说清楚说透,尤其像 Java8 中的 ConcurrentHashMap 的演进设计实现,大部分文章都说不清楚。希望能降低大家学习的成本,不希望大家看了一篇又一篇文章,最终还是模
模糊糊。
阅读前提:
本文会涉及源码分析,所以至少读者要熟悉它们的接口使用,同时,对于并发,读者至少要知道 CAS、ReentrantLock、UNSAFE 操作这几个基本的知识,文中不会对这些知识进行介绍。
为什么需要 ConcurrentHashMap?
在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下(它的实现就是将put、get、size等方法加上 synchronized 关键字),基于以上两个原因,便有了ConcurrentHashMap的登场机会。
可能有的同学对 HashMap 为什么会在并发中出现死循环从而导致 cpu 占用达到100% 不太了解,这里直接展示一段示例代码,运行它就会出现死循环。
死循环的概率还是非常低的,比较难以重现。为了提高出现概率,采用多次迭代测试。笔者在测试时 出现在 128次。
感兴趣的同学可以用 jstack 分析下,网上有很多教程,这里就不展开 排查过程了。原因就是:HashMap 在并发执行 put 操作时会引起死循环,是因为多线程会导致 HashMap 的 Entry 链表形成环形数据结构,一旦形成环形数据结构,Entry 的 next 节点永远不为空,就会产生死循环获 取 Entry 。从而导致CPU占用将近100%。
Java7中ConcurrentHashMap分析
首先,我这里强调,ConcurrentHashMap 的设计实现其实一直在演化,比如在 Java 8 中就发生了非常大的变化(Java 7 其实也有不少更新),所以,我这里将比较分析结构、实现机制等方面,对比不同版本的主要区别。
在 Java7 中的实现是基于:
分离锁,也就是将内部进行分段(Segment),里面则是 HashEntry 的数组,和 HashMap 类似,哈希相同的条目也是以链表形式存放。
HashEntry 内部使用 volatile 的 value 字段来保证可见性,也利用了不可变对象的机制以改进利用 Unsafe 提供的底层能力,比如 volatile access,去直接完成部分操作,以最优化性能,毕竟 Unsafe 中的很多操作都是 JVM intrinsic 优化过的。
具体实现可以理解为:ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。Segment是一种可重入锁(继承了ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry 则用于存储键值对数据。一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种 数组和链表结构。一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元 素,每个Segment 守护着一个 HashEntry 数组里的元素,当对 HashEntry 数组的数据进行修改时, 必须首先获得与它对应的Segment锁。
初始化
在构造的时候,Segment 的数量由所谓的 concurrentcyLevel 决定,默认是 16,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。也可以在相应构造函数直接指定。注意,Java 需要它是 2 的幂数值,如果输入是类似 15 这种非幂值,会被自动调整到 16 之类 2 的幂数值。并且一旦初始化后,它是不可以扩容的。
ConcurrentHashMap 初始化方法是通过 initialCapacity 、loadFactor 和 concurrencyLevel 等几个参数来初始化segment数组、段偏移量 segmentShift 、段掩码 segmentMask 和每个 segment 里的 HashEntry 数组来实现的。
下面结合源代码一起来看下,为方便理解,我直接注释在代码段里:
初始化完成,我们得到了一个 Segment 数组。这里之所以 segments 数组的长度必须是2的N次幂,主要是为了能通过按位与的散列算法来定位 segments 数组的索引。
注意:concurrencyLevel 的最大值是65535,这意味着 segments 数组的长度最大为65536, 对应的二进制是16位。
为了加深读者理解,下面来分析下,当我们用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:
Segment 数组长度为 16,不可以扩容
Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
这里初始化了 segment[0],其他位置还是 null,至于为什么要初始化 segment[0],后面的代码会介绍
当前段偏移量 segmentShift 的值为 32 - 4 = 28,段掩码 segmentMask 为 16 - 1 = 15,这两个值马上就会用到
get 操作
get 操作需要保证的是可见性,所以并没有什么同步逻辑。
计算 hash 值,找到 segment 数组中的具体位置
segment 中也是一个数组(HashEntry数组),根据 hash 找到数组中具体的位置
到这里是链表了,HashEntry 是链表中的元素,顺着链表进行查找即可
put操作
对于 put 操作,首先是通过二次哈希避免哈希冲突,然后以 Unsafe 调用方式,直接获取相应的 Segment,然后进行线程安全的 put 操作:
其核心逻辑实现在下面的内部方法中:
rehash:扩容操作
重复一下,segment 数组不能扩容,扩容是 segment 数组某个位置内部的数组 HashEntry<K,V>[] 进行扩容,扩容后,容量为原来的 2 倍。
首先,我们要回顾一下触发扩容的地方,put 的时候,如果判断该值的插入会导致该 segment 的元素个数超过阈值,那么先进行扩容,再插值,读者这个时候可以回去 put 方法看一眼。
该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的。
上面有两个挨着的 for 循环,第一个 for 有什么用呢?
这块代码我看的时候真的是很难理解,反复看了好几遍,主要原因还是对链表操作不太熟悉,这里为大家在解释下,帮助理解。这里需要进行第一个 for 循环,主要是因为扩容后,原来数组位置 i 的 HashEntry 是一个链表,那么这个链表的元素对应扩容后的数组位置必然是 i 或 i+oldCap。第一个循环就是为遍历当前位置 i 的链表找到最后一个在新数组中位置相同的节点 lastRun。
如果没有第一个 for 循环,也是可以工作的,但是,这个 for 循环下来,如果 lastRun 的后面还有比较多的节点,那么这次就是值得的。因为我们只需要克隆 lastRun 前面的节点,后面的一串节点跟着 lastRun 进行赋值就可以了,不需要做任何操作。
Doug Lea 大神这块的想法一般人可能是想不到的,毕竟作为并发包中的基础类 都是为了将并发性能做到极致的。但是也有最差的情况,就是找到的 lastRun 是链表的最后一个元素,或者排在倒数,那么这次遍历就显得多余了,而且浪费了性能。不过 Doug Lea 也说了,根据统计,如果使用默认的阈值,大约只有 1/6 的节点需要克隆。
size 操作
知道了 ConcurrentHashMap 通过分段锁实现高性能且线程安全的原理。试想,如果不进行同步,简单的计算所有 Segment 的总值,可能会因为并发 put,导致结果不准确,但是直接锁定所有 Segment 进行计算,就会变得非常昂贵。
所以,ConcurrentHashMap 的实现是通过重试机制(RETRIES_BEFORE_LOCK,指定重试次数 2),来试图获得可靠值。如果没有监控到发生变化(通过对比 Segment.modCount),就直接返回,否则获取锁进行操作。
Java8中ConcurrentHashMap分析
在 Java 8 和之后的版本中,ConcurrentHashMap 发生了哪些变化呢?
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
因为不再使用 Segment,初始化操作大大简化,修改为 lazy-load 形式,这样可以有效避免初始开销,解决了老版本很多人抱怨的这一点。
数据存储利用 volatile 来保证可见性。
使用 CAS 等操作,在特定场景进行无锁并发操作。
这里介绍一个最常问的问题:Java8 为什么使用红黑树呢?
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
注意,上图是示意图,主要是描述结构,不会达到这个状态的,因为这么多数据的时候早就扩容了。
Java7 中使用 HashEntry 来代表每个 HashMap 中的数据节点,Java8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。
先看看现在的数据存储内部实现,我们可以发现 Key 是 final 的,因为在生命周期中,一个条目的 Key 发生变化是不可能的;与此同时 val,则声明为 volatile,以保证可见性。
为了提高大家的阅读体验,我这里就不再介绍 get 方法和构造函数了,相对比较简单,相信你如果看懂了 Java7 的实现一定没有啥问题的。直接看并发的 put 是如何实现的。
put操作
put 的主流程看完了,但是至少留下了几个问题,第一个是初始化,第二个是扩容,第三个是帮助数据迁移,这些我们都会在后面进行一一介绍。
初始化数组:initTable
从上面的 put 操作可以看到,数组初始化是在 put 操作时进行的,采用的 lazy-load 形式。
这个比较简单,主要就是初始化一个合适大小的数组,然后会设置 sizeCtl。
初始化方法中的并发问题是通过对 sizeCtl 进行一个 CAS 操作来控制的。
链表转为红黑树:treeifyBin
这里需要注意:前面我们在 put 源码分析也说过,treeifyBin 不一定就会进行红黑树转换,也可能是仅仅做数组扩容。我们还是进行源码分析吧。
扩容:tryPresize
如果说 Java8 ConcurrentHashMap 的源码不简单,那么说的就是扩容操作和迁移操作。
这里的扩容也是做翻倍扩容的,扩容后数组容量为原来的 2 倍。
这个方法要完完全全看懂还需要看之后的 transfer 方法,读者应该提前知道这点。
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。
所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。
数据迁移:transfer
下面这个方法有点长,将原来的 tab 数组的元素迁移到新的 nextTab 数组中。
虽然我们之前说的 tryPresize 方法中多次调用 transfer 不涉及多线程,但是这个 transfer 方法可以在其他地方被调用,典型地,我们之前在说 put 方法的时候就说过了,请往上看 put 方法,是不是有个地方调用了 helpTransfer 方法,helpTransfer 方法会调用 transfer 方法的。
此方法支持多线程执行,外围调用此方法的时候,会保证第一个发起数据迁移的线程,nextTab 参数为 null,之后再调用此方法的时候,nextTab 不会为 null。
阅读源码之前,先要理解并发操作的机制。原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了,而 Doug Lea 使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。当然,这里说的第二个线程不是真的一定指代了第二个线程,也可以是同一个线程,这个读者应该能理解吧。其实就是将一个大的迁移任务分为了一个个任务包。
说到底,transfer 这个方法并没有实现所有的迁移任务,每次调用这个方法只实现了 transferIndex 往前 stride 个位置的迁移工作,其他的需要由外围来控制。
这个时候,再回去仔细看 tryPresize 方法可能就会更加清晰一些了。
总结
今天我从线程安全问题开始,分析为什么要使用ConcurrentHashMap,进而分析了 Java 7 和 Java 8 中 ConcurrentHashMap 是如何设计实现的,从源码层面说明白了具体的实现逻辑。其实仔细认真读懂后你会发现其实也不是太难。希望本文让你对 ConcurrentHashMap 面试相关问题轻松的应对,同时作为并发编程技巧对你在日常开发可以有所帮助。
笔者水平有限,文章难免会有纰漏,如有错误欢迎一起交流探讨,我会第一时间更正的。都看到这里了,码字不易,可爱的你记得 "点赞" 哦,我需要你的正向反馈。
(全文完)fighting!
参考资料:
周志明:《深入理解 Java 虚拟机》
方腾飞:《Java 并发编程的艺术》
https://www.javadoop.com/
版权声明: 本文为 InfoQ 作者【七哥爱编程】的原创文章。
原文链接:【http://xie.infoq.cn/article/62c72a8649c79dc463fd1321f】。文章转载请联系作者。
评论