ConcurrentHashMap源码&底层数据结构分析
ConcurrentHashMap:线程安全的HashMap
1.7
JDK 1.7 中 的 ConcurrentHashMap 采用了分段锁的设计,只有在同一个分段内才存在竞态关系,不同的分段锁之间没有锁竞争。相比于对整个 Map 加锁,分段锁大大提高了高并发环境下的处理能力。
ConcurrentHashMap 底层是由一个 Segment 数组组成的,每个 Segment 元素包含一个 HashEntry 数组,而每个 HashEntry 元素都是一个链表结构的节点。
HashEntry 和 HashMap 非常类似,唯一的区别就是其中的核心数据 value 以及 next 都被 volatile 修饰,以此保证了多线程读写过程中对应变量的可见性。
put 的流程如下: 通过 key 的 hashcode 定位到 Segment 中对应的 HashEntry; 遍历该 HashEntry,如果不为空则判断传入的 key 和当前遍历的 key 是否相等,相等则覆盖旧的 value 值。 为空则需要新建一个 HashEntry 并加入到 Segment 中,在此之前,会先判断是否需要扩容。 最后解除获取当前 Segment 的锁。 get 方法的流程如下: 根据 key 的 hash 值通过位运算以及基础偏移量的相加获取具体的 key 偏移量 u; 通过偏移量 u 去 Segment 数组中获取 u 位置处的 Segment 对象; 在有对象的情况下,通过 for 循环链表的方式获取 Segment 对象的 HashEntry 值进行比较; 如果存在则返回对应 value 值,否则返回 null 值表示不存在;
1.8
JDK 1.8 的 ConcurrentHashMap 取消了 Segment 分段锁,采取 CAS 和 synchronized 来保证并发的安全性。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发问题。
将 JDK 1.7 中存放数据的 HashEntry 改为了 Node. Node 是最核心的内部类,它包装了 key-value 键值对,所有插入 ConcurrentHashMap 的数据都包装在这里面。它与 HashMap 中的定义相似,差别在于,它对 value 和 next 属性设置了volatile 同步锁(与 JDK 1.7 的 Segment 相同),不允许调用 setValue 方法直接改变 Node 的value 域,还增加了 find 方法辅助 map.get() 方法的实现。
put 方法的流程如下: 判断对应的 key 和 value 是否为空,为空则直接抛出异常; 判断 table 数组是否为空,空则进行初始化操作; 当 table 不为空时,判断在下标 i 的位置是否存在值,不存在则通过 CAS 方式直接在对应位置进行更新,更新成功则直接退出; 如果下标 i 的位置不为空,且正在准备扩容,则调用 helpTransfer() 方法帮忙 table 进行扩容; 如果未处于扩容状态,则进行 synchronized 加锁操作给头节点加锁,同时判断当前偏移处的值是否是前面判断时的值; 判断头节点是否为链表,需要通过链表的方式循环判断是否有与当前 key 相同的值,有则在允许覆盖的情况下进行覆盖,没有则新建一个 Node 值放在链表最后; 如果当前的 Node 节点为树节点,则进行树节点的相关操作; 当节点个数 binCount 长度超过8时,就对当前 Node 节点链表进行红黑树的转换; 最后根据 binCount 值,通过 addCount() 方法增加元素个数,同时检测是否需要进一步扩容; get 方法的流程如下: 对 key 进行 hash 取值,然后判断在 table 中的 hash 值计算偏移量后的位置是否有值; 有值则首先判断当前偏移处的 hash 值是否与传入的 key 的 hash 值相同; 相同则判断当前 key 是否就是传入的 key,如果是则直接取值返回; 否则判断当前的偏移处的 hash 值是否小于 0,如果 eh < 0,代表是红黑树,按照红黑树的方式 find 返回; 如果大于0,则通过链表的方式循环当前偏移处的 Node 对象,直到获取到有相同的 key 值或者链表结束为止; 获取到值则直接返回,否则返回 null 表示不存在;
ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
JDK1.8 之前:
首先将数据分为一段一段(Segment
)的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。 ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。 Segment
继承了 ReentrantLock,
所以 Segment
是一种可重入锁,扮演锁的角色。HashEntry
用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {}
ConcurrentHashMap
里包含一个 Segment
数组,Segment
的个数一旦初始化就不能改变。 Segment
数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。
Segment
的结构和 HashMap
类似,是一种数组和链表结构,一个 Segment
包含一个 HashEntry
数组,每个 HashEntry
是一个链表结构的元素,每个 Segment
守护着一个 HashEntry
数组里的元素,当对 HashEntry
数组的数据进行修改时,必须首先获得对应的 Segment
的锁。也就是说,对同一 Segment
的并发写入会被阻塞,不同 Segment
的写入是可以并发执行的。
Java 7 中 ConcurrentHashMap 的初始化逻辑。 (1)必要参数校验。 (2)校验并发级别 concurrencyLevel 大小,如果大于最大值(65536),重置为最大值。 (3)无参构造默认值是 16.寻找并发级别 concurrencyLevel 之上最近的 2 的幂次方值,作为初始化容量大小,默认是 16。 (4)记录 segmentShift 偏移量,这个值为【容量 = 2 的 N 次方】中的 N,在后面 Put 时计算位置时会用到。默认是 32 - sshift = 28. (5)记录 segmentMask,默认是 ssize - 1 = 16 -1 = 15. (6)初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。 ConcurrentHashMap 在 put一个数据时的处理流程: (1)计算要 put 的 key 的位置,获取指定位置的 Segment。 (2)如果指定位置的 Segment 为空,则初始化这个 Segment. (3)初始化 Segment 流程: 检查计算得到的位置的 Segment 是否为 null. 为 null 继续初始化,使用 Segment[0] 的容量和负载因子创建一个 HashEntry 数组。 再次检查计算得到的指定位置的 Segment 是否为 null. 使用创建的 HashEntry 数组初始化这个 Segment. 自旋判断计算得到的指定位置的 Segment 是否为 null,使用 CAS 在这个位置赋值为 Segment. (4)Segment.put 插入 key,value 值。 tryLock() 获取锁,获取不到使用 scanAndLockForPut 方法继续获取。 计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 。 遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。 如果这个位置上的 HashEntry 不存在: 如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接头插法插入。 如果这个位置上的 HashEntry 存在: 判断链表当前元素 key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值 不一致,获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。 如果当前容量大于扩容阀值,小于最大容量,进行扩容。 直接链表头插法插入。 如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.ConcurrentHashMap
的扩容:
只会扩容到原来的两倍。老数组里的数据移动到新的数组时,
位置要么不变,要么变为index+ oldSize
,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
ConcurrentHashMap 在get的处理流程:
(1)计算得到 key 的存放位置。
(2)遍历指定位置查找相同 key 的 value 值。
JDK1.8 之后
Java 8 几乎完全重写了 ConcurrentHashMap
,代码量从原来 Java 7 中的 1000 多行,变成了现在的 6000 多行。
ConcurrentHashMap
取消了 Segment
分段锁,采用 Node + CAS + synchronized
来保证并发安全。数据结构跟 HashMap
1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
Java 8 中,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
ConcurrentHashMap 的初始化是通过自旋和 CAS 操作完成的。 里面需要注意的是变量 sizeCtl ,它的值决定着当前的初始化状态。 -1 说明正在初始化 -N 说明有 N-1 个线程正在进行扩容 0 表示 table 初始化大小,如果 table 没有初始化 >0 表示 table 扩容的阈值,如果 table 已经初始化。 put流程: (1)根据 key 计算出 hashcode。 (2)判断是否需要进行初始化。 (3)即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。 (4)如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。如果都不满足,则利用 synchronized 锁写入数据。 (5)如果数量大于 TREEIFY_THRESHOLD 则要执行树化方法,在 treeifyBin 中会首先判断当前数组长度 ≥64 时才会将链表转换为红黑树 get 过程: (1)根据 hash 值计算位置。 (2)查找到指定位置,如果头节点就是要找的,直接返回它的 value. (3)如果头节点 hash 值小于 0 ,说明正在扩容或者是红黑树,查找。 (4)如果是链表,遍历查找。
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
- 线程安全实现方式:JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。JDK1.8 放弃了Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。 - Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
- 并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
- 底层数据结构:
- JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。 Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- JDK1.7 的
- 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本; Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
- 在 JDK1.7 的时候,
下面,我们再来看看底层数据结构的对比图。
ConcurrentHashMap
是由 Segment
数组结构和 HashEntry
数组结构组成。JDK1.7 Segment
数组中的每个元素包含一个 HashEntry
数组,每个 HashEntry
数组属于链表结构
JDK1.8 的 ConcurrentHashMap
不再是 Segment 数组 + HashEntry 数组 + 链表,而是 Node 数组 + 链表 / 红黑树。不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode
。当冲突链表达到一定长度时,链表会转换成红黑树。TreeNode
是存储红黑树节点,被TreeBin
包装。TreeBin
通过root
属性维护红黑树的根结点,因为红黑树在旋转的时候,根结点可能会被它原来的子节点替换掉,在这个时间点,如果有其他线程要写这棵红黑树就会发生线程不安全问题,所以在 ConcurrentHashMap
中TreeBin
通过waiter
属性维护当前使用这棵红黑树的线程,来防止其他线程的进入。
ConcurrentHashMap 能保证复合操作的原子性吗?
ConcurrentHashMap
是线程安全的,意味着它可以保证多个线程同时对它进行读写操作时,不会出现数据不一致的情况,也不会导致 JDK1.7 及之前版本的 HashMap
多线程操作导致死循环问题。但是,这并不意味着它可以保证所有的复合操作都是原子性的。
复合操作是指由多个基本操作(如put
、get
、remove
、containsKey
等)组成的操作,例如先判断某个键是否存在containsKey(key)
,然后根据结果进行插入或更新put(key, value)
。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
如何保证 ConcurrentHashMap
复合操作的原子性呢?
ConcurrentHashMap
提供了一些原子性的复合操作,如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge
等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。不建议使用加锁的同步机制,违背了使用 ConcurrentHashMap
的初衷。在使用 ConcurrentHashMap
的时候,尽量使用这些原子性的复合操作方法来保证原子性。
if (!map.containsKey(key)) { map.put(key, value); } ===> map.putIfAbsent(key, value);
ConcurrentHashMap
的 key 和 value 不能为 null
主要是为了避免模棱两可歧义性:
就拿#get(key)
方法获取value值说说歧义性:返回null有两种情况:
① map中没有这个key-value键值对,返回null
② map中有这个key-value键值对,但是value值为null,此时也返回null