ConcurrentHashMap介绍
因为ConcurrentHashMap支持并发操作,所以源码理解起来并不是很容易,尤其是JDK1.8的源码加入红黑树,本文分别分析JDK1.8和JDK1.7源码
JDK1.8 中 ConcurrentHashMap 类取消了 Segment 分段锁,采用 CAS + synchronized 来保证并发安全,数据结构跟 jdk1.8 中 HashMap 结构类似,都是数组 + 链表(当链表长度大于8时,链表结构转为红黑二叉树)结构。
ConcurrentHashMap 中 synchronized 只锁定当前链表头节点或红黑二叉树的根节点,只要节点 hash 不冲突,就不会产生并发,相比 JDK1.7 的 ConcurrentHashMap 效率又提升了 N 倍!
重要属性
Node节点类: 和HashMap中的节点类似,只是其val变量和next指针都用volatile来修饰。且不允许调用setValue方法修改Node的value值。这个类是后面三个类的基类。
TreeNode: 树节点类,当链表长度过长的时候,会转换为TreeNode。但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对红黑树的包装。而且TreeNode在ConcurrentHashMap继承自Node类,而并非HashMap中的继承自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针,这样做的目的是方便基于TreeBin的访问。
TreeBin: 头节点,这个类并不负责包装用户的key、value信息,而是直接放了一个名为root的TreeNode节点,这个是他所包装的红黑树的根节点,也就是说在实际的ConcurrentHashMap“数组”中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别。另外这个类还带有了读写锁。HashMap桶中存储的是TreeNode结点,这里的根本原因是==并发过程中,有可能因为红黑树的调整,树的形状会发生变化,这样的话,桶中的第一个元素就变了,而使用TreeBin包装的话,就不会出现这种情况。 这种类型的节点, hash值为-2,从下面的构造函数中就可以看出来。这样我们通过hash值是否等于-2就可以判断桶中的节点是否是红黑树。

构造方法
实现的懒加载,在构造方法中仅仅计算了table的大小,
JDK1.7中的ConcurrentHashMap:
ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
其示意图如下(图片来源于网络):
ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。
具体到每个 Segment 内部,其实每个 Segment 很像HashMap,不过它要保证线程安全,所以处理起来要麻烦些。
JDK1.8中的ConcurrentHashMap:
Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
其示意图如下(图片来源于网络):
结构上和 Java8 的 HashMap 基本上一样,不过它要保证线程安全性,所以在源码上确实要复杂一些。
ConcurrentHashMap源码分析
JDK1.7源码分析
概念
Segment: 可重入锁,继承ReentrantLock 也称之为桶或者槽,ConcurrentHashMap包含一个Segment数组,每个Segment包含一个HashEntry数组,当修改HashEntry数组采用开链法处理冲突,所以它的每个HashEntry元素又是链表结构的元素。
initialCapacity:初始容量,这个值指的是整个 ConcurrentHashMap 的初始容量,实际操作的时候需要平均分给每个 Segment。
loadFactor:负载因子,之前我们说了,Segment 数组不可以扩容,所以这个负载因子是给每个 Segment 内部使用的。
concurrencyLevel:并发数,并行级别,Segment数,默认为16
HashEntry:主要存储键值对 可以叫节点,源码如下:
构造函数
其他构造函数基本上都会调用该方法进行初始化,只是初始化值不一样,如无参构造,源码如下:
无参构造初始化完成后:
- Segment 数组长度为 16,不可以扩容
- Segment[i] 的默认大小为 2,负载因子是 0.75,得出初始阈值为 1.5,也就是以后插入第一个元素不会触发扩容,插入第二个会进行第一次扩容
- 这里初始化了 segment[0],其他位置还是 null
- 当前 segmentShift 的值为 32 - 4 = 28,segmentMask 为 16 - 1 = 15,姑且把它们简单翻译为移位数和掩码,这两个值马上就会用到
put过程分析
我们先看 put 的主流程,这个put不难,主要是return中put方法使用
下面就是Segment中的put操作了,Segment内部由数组+链表组成,源码如下(接上面):
整体流程还是比较简单的,大概有如下几步:
- 调用tryLock尝试加锁,如果加锁成功,则node为null,否则scanAndLockForPut自旋等待并且寻找对应key的节点是否存在
- 获取到锁后,找到对应的HashEntry first, 向后遍历寻找是否有和要找的key equals的HashEntry。
- 如果找到了,则按照是否是putIfAbsent判断是否替换旧的value值
- 如果没有找到(循环到null),则创建一个节点或使用scanAndLockForPut中创建的节点作为新的头结点,判断是否需要rehash, 并设置到HashEntry数组对应的位置。
- 最后解锁,返回key原来对应的value值。
到这里 put 操作就结束了,接下来,我们说一说其中几步关键的操作。
初始化槽:ensureSegment()方法
这个函数的目的就是找到对应的segment,通过CAS来解决并发问题
获取写入锁:scanAndLockForPut()
在Segment中put时,首先会调用
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
也就是先通过tryLock()快速获取该segment的独占锁,如果获取失败,在通过scanAndLockForPut()方法来获取锁,
scanAndLockForPut中的处理为不断尝试获取锁的过程中遍历链表,如果最终没有找到和key equals的HashEntry则创建一个HashEntry并在获取到锁后返回它,如果自旋超过一定次数则使用lock进行加锁等待。
其源码如下:
这个方法有两个出口,一个是 tryLock() 成功了,循环终止,另一个就是重试次数超过了 MAX_SCAN_RETRIES,进到 lock() 方法,此方法会阻塞等待,直到成功拿到独占锁。
方法过程大概如下:
- entryForHash进行一次volatile读, 然后在循环中尝试tryLock
- tryLock不成功的情况下,如遍历链表,有一个retries变量初始时为-1, 当遍历到最后一个节点时还没有找到则创建一个HashEntry并且将retries置为0,如果找到了对应的key的Entry也将retries置为0。
- MAX_SCAN_RETRIES在类初始化时根据系统的CPU处理器的数量决定,如果CPU大于1则是64,否则是1。
- 创建好HashEntry或已经匹配到HashEntry后则进入下一个else if,其中++retries判断如果大于MAX_SCAN_RETRIES就是用lock()方法中断当前的spin lock。
- 如果不大于MAX_SCAN_RETRIES, 则判断retryies是否为奇数,这个其实是给单处理应用的,并且判断当前的HashEntry的头节点是否和循环开始时的相等,如果不相等说明有其他线程进行了修改,需要重新遍历链表。这里的 (retries & 1) 确认容易让人不解,因为多处理器时64的话retries也会出现2这种偶数的可能,但是最终加锁后还是会进行重新读取HashEntry链表的,所以这里如果有读取到stale数据最终是能够发现的。那这个方法的意义在于什么呢?首先通过读取HashEntry帮助将对应的数据加载到CPU缓存行中并且帮助出发JIT编译来加热代码,其次如果创建了HashEntry并且最终却是没有对应的key的话节省了创建HashEntry的时间,这两个用途都能够减少之后加锁的时间。Doug lea对(reties &1)这个用法也进行了回复http://altair.cs.oswego.edu/pipermail/concurrency-interest/2014-August/012881.html
扩容: rehash
这里的扩容是 segment 数组某个位置内部的数组 HashEntry[] 进行扩容,扩容后,容量为原来的 2 倍
segment 数组不能扩容,源码如下:
这里并不一定遍历所有的链表元素,因为当后面的节点进过运算在新数据中的hash一样的话,只需要把这一组的头结点插入,后面的节点就会被带入其中。
所以,下面的for循环操作的是链表中lastRun节点之前的节点
for (HashEntry<K,V> p = e; p != lastRun; p = p.next)
get过程分析
get过程大概可以分为以下几步:
- 计算hash值
- 找到segment中对应HashEntry链表
- 遍历链表
计算size
contains系统方法
containsKey 判断是否包含key值对应的数据(节点)
containsValue 判断是否包含value值对应的数据
和size()方法有点类似
JDK1.8源码分析
概述
1.8中采取的是和HashMap中类似的单数组链表策略,并且在链表长度超过一定大小时转换成红黑树。
- 每个数组元素称为为一个bin或bucket,里面存放的是Node。
- Node代表了一个键值对,并通过next指向下一个节点。Node有一些子类,TreeNode是用平衡树组织的节点, ForwardingNode用于在resize的时候放在Node的头结点,只有table发生扩容的时候,ForwardingNode才会发挥作用
- Node<K,V>[] table是内部数据的bin数组, table的大小。
- sizeCtl用于控制table的初始化和resize。
- sizeCtl为负数的时候表示正在进行初始化或者resize, -1表示正在初始化或者扩容, 当table为null的时候存储的是初始时的table的大小或者0表示默认值。所以,如果某个线程想要初始化table或者对table扩容,需要去竞争sizeCtl这个共享变量,获得变量的线程才有许可去进行接下来的操作,没能获得的线程将会一直自旋来尝试获得这个共享变量,所以获得sizeCtl这个变量的线程在完成工作之后需要设置回来,使得其他的线程可以走出自旋进行接下来的操作
- -1 代表table正在初始化
- -N 表示有N-1个线程正在进行扩容操作
- 其余情况:
- (1)如果table未初始化,表示table需要初始化的大小。
- (2)如果table初始化完成,表示table的容量,默认是table大小的0.75倍
- table:默认为null,初始化发生在第一次插入操作,默认大小为16的数组,用来存储Node节点数据,扩容时大小总是2的幂次方。
- nextTable:默认为null,扩容时新生成的数组,其大小为原数组的两倍。
构造函数
put过程分析
大致可以分为以下步骤:
- 根据 key 计算出 hash 值;
- 判断是否需要进行初始化;
- 定位到 Node,拿到首节点 f,判断首节点 f:
- 如果为 null ,则通过 CAS 的方式尝试添加;
- 如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
- 如果都不满足 ,说明桶下标有冲突,用synchronized 锁住 f 节点,判断是链表(f.hash >0)还是红黑树(f.hash <0),遍历插入;
- 当在链表长度达到 8 的时候,数组扩容或者将链表转换为红黑树。
源代码如下:
put方法中有使用到initTable()、helpTransfer()、treeifyBin(),addCount()这几个方法,分别来看看源码:
初始化initTable
sizeCtl默认为0,如果ConcurrentHashMap实例化时有传参数,sizeCtl会是一个2的幂次方的值。所以执行第一次put操作的线程会执行Unsafe.compareAndSwapInt方法修改sizeCtl为-1,有且只有一个线程能够修改成功,其它线程通过Thread.yield()让出CPU时间片等待table初始化完成。
链表转红黑树: treeifyBin
该方法的核心思想是:检查一下table的长度是否大于MIN_TREEIFY_CAPACITY(默认64),如果不大于就调用tryPresize方法将table扩容两倍,就不降链表转化为树了,反之则将table[i]的链表转化为树
扩容:tryPresize
这个方法的核心在于 sizeCtl 值的操作,首先将其设置为一个负数,然后执行 transfer(tab, null),再下一个循环将 sizeCtl 加 1,并执行 transfer(tab, nt),之后可能是继续 sizeCtl 加 1,并执行 transfer(tab, nt)。
所以,可能的操作就是执行 1 次 transfer(tab, null) + 多次 transfer(tab, nt),这里怎么结束循环的需要看完 transfer 源码才清楚。
数据迁移:transfer
数据迁移的核心思想就是将一个大的迁移任务分为了一个个任务包,比如原数组长度为 n,所以我们有 n 个迁移任务,让每个线程每次负责一个小任务是最简单的,每做完一个任务再检测是否有其他没做完的任务,帮助迁移就可以了。源码中使用了一个 stride,简单理解就是步长,每个线程每次负责迁移其中的一部分,如每次迁移 16 个小任务。所以,我们就需要一个全局的调度者来安排哪个线程执行哪几个任务,这个就是属性 transferIndex 的作用。
第一个发起数据迁移的线程会将 transferIndex 指向原数组最后的位置,然后从后往前的 stride 个任务属于第一个线程,然后将 transferIndex 指向新的位置,再往前的 stride 个任务属于第二个线程,依此类推。
这个方法需要多花时间去理解,不是很好懂
addCount()
JDK1.8中的get方法
get方法很简单,大概思路如下:
1.首先计算hash值,定位到该table索引位置,如果是首节点符合就返回
2.如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
- 判断table是否为空,如果为空,直接返回null。
- 计算key的hash值,并获取指定table中指定位置的Node节点,通过遍历链表或则树结构找到对应的节点,返回value值。
此方法的大部分内容都很简单,只有正好碰到扩容的情况,ForwardingNode.find(int h, Object k) 稍微复杂一些
JDK1.8中size计算流程

JDK1.8中get方法为什么不需要加锁
get操作可以无锁是由于Node的元素val和指针next是用volatile修饰的,在多线程环境下线程A修改结点的val或者新增节点的时候是对线程B可见的。
只能保证提供的原子性读写操作是线程安全的
特别注意的是:ConcurrentHashMap只能保证提供的原子性读写操作是线程安全的
比如有一个场景,一个篮子里面有900个水果,需要装满1000个水果,然后有10个工人把还差的100个水果装进去,ConcurrentHashMap就是这个篮子的本身,可以确保多个工人在装东西进去,不会相互影响干扰,但无法确保工人A看到还需装100个水果但是还未装的时候,工人B就看不到篮子中的水果数量,更值得注意的是,你往这个篮子装 100 个水果的操作不是原子性的,在别人看来可能会有一个瞬间篮子里有964个水果,还需要补36个水果。
先展示错误的代码,如下:
JDK1.7 与 JDK1.8 中ConcurrentHashMap 的区别?
- 数据结构:取消了 Segment 分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
- 保证线程安全机制:JDK1.7 采用 Segment 的分段锁机制实现线程安全,其中 Segment 继承自 ReentrantLock 。JDK1.8 采用CAS+synchronized保证线程安全。
- 锁的粒度:JDK1.7 是对需要进行数据操作的 Segment 加锁,JDK1.8 调整为对每个数组元素加锁(Node)。
- 链表转化为红黑树:定位节点的 hash 算法简化会带来弊端,hash 冲突加剧,因此在链表节点数量大于 8(且数据总量大于等于 64)时,会将链表转化为红黑树进行存储。
- 查询时间复杂度:从 JDK1.7的遍历链表O(n), JDK1.8 变成遍历红黑树O(logN)
ConcurrentHashMap 的并发度是什么?
并发度可以理解为程序运行时能够同时更新 ConccurentHashMap且不产生锁竞争的最大线程数。在JDK1.7中,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度,默认是16,这个值可以在构造函数中设置。
如果自己设置了并发度,ConcurrentHashMap 会使用大于等于该值的最小的2的幂指数作为实际并发度,也就是比如你设置的值是17,那么实际并发度是32。
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。
在JDK1.8中,已经摒弃了Segment的概念,选择了Node数组+链表+红黑树结构,并发度大小依赖于数组的大小。
参考
JDK1.7ConcurrentHashMap源码分析
Java7/8 中的 HashMap 和 ConcurrentHashMap 全解析
深入浅出ConcurrentHashMap1.8
《Java源码分析》:ConcurrentHashMap JDK1.8