一、ConcurrentHashMap 的 JDK7/JDK8 区别
同样是线程安全,相较于 HashTable 是使用 synchronized 关键字加锁的原理(就是对对象加锁),ConcurrentHashMap 类是 Java 并发包java.util.concurrent中提供的一个线程安全且高效的 HashMap 实现。
1️⃣整体结构
JDK7:Segment + HashEntry + Unsafe
JDK8: 移除 Segment,使锁的粒度更小,Synchronized + CAS + Node + Unsafe
2️⃣put()
JDK7:先定位 Segment,再定位桶,put 全程加锁,没有获取锁的线程提前找桶的位置,并最多自旋 64 次获取锁,超过则挂起。
JDK8:由于移除了 Segment,类似 HashMap,可以直接定位到桶,拿到 first 节点后进行判断:①为空则 CAS 插入;②为 -1 则说明在扩容,则跟着一起扩容;③ else 则加锁 put(类似 JDK7)
3️⃣get()
基本类似,由于 value 声明为 volatile,保证了修改的可见性,因此不需要加锁。
4️⃣resize()
JDK7:跟 HashMap 步骤一样,只不过是搬到单线程中执行,避免了 HashMap 在 JDK7 中扩容时死循环的问题,保证线程安全。
JDK8:支持并发扩容,HashMap 扩容在 JDK8 中由头插改为尾插(为了避免死循环问题),ConcurrentHashmap 也是,迁移也是从尾部开始,扩容前在桶的头部放置一个 hash 值为 -1 的节点,这样别的线程访问时就能判断是否该桶已经被其他线程处理过了。
5️⃣size()
JDK7:很经典的思路:计算两次,如果不变则返回计算结果,若不一致,则锁住所有的 Segment 求和。
JDK8:用 baseCount 来存储当前的节点个数,这就涉及到 baseCount 并发环境下修改的问题。
二、ConcurrentHashMap 的锁机制
JDK7 ConcurrentHashMap 的分段锁机制
对整个数组进行分段(每段都是由若干个 hashEntry 对象组成的链表),每个分段都有一个 Segment 分段锁(继承 ReentrantLock 分段锁),每个 Segment 分段锁只会锁住它锁守护的那一段数据,多线程访问不同数据段的数据,就不会存在竞争,从而提高了并发的访问率。
JDK8 ConcurrentHashMap 的分段锁机制
采用 Node+CAS+Synchronized 实现线程安全,取消了 segment 分段锁,直接使用 Table 数组存储键值对(与 JDK8 的 HashMap 一样),主要是使用 Synchronized+CAS 的方法来进行并发控制。在 put() 的时候如果 CAS 失败就说明存在竞争,会进行自旋。
为什么 JDK8 ConcurrentHashMap 使用内置锁 synchronized 来代替重入锁 ReentrantLock
- 粒度降低了;
- 官方对 synchronized 进行了优化和升级,使得 synchronized 不那么“重”了,而且基于 JVM 的 synchronized 优化空间更大,更加自然。
- 在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存。
三、Segment
JDK7 的 HashMap 在高并发下会出现链表环,从而导致程序出现死循环。可以使用 HashTable、Collections.syncronizedMap 替代,但是二者性能都很差。因为在执行读写操作时都是将整个集合加锁,导致多个线程无法同时读写集合。因此,高并发下的 HashMap 出现的问题就需要 ConcurrentHashMap 来解决了。
【JDK7 的 ConcurrentHashMap】中有一个 Segment 的概念。Segment 本身就相当于一个 HashMap 对象。同 HashMap 一样,Segment 包含一个 HashEntry 数组,数组中的每一个 HashEntry 既是一个键值对,也是一个链表的头节点。单一的 Segment 结构如下: ConcurrentHashMap 中有 2 的 n 次方个 Segment 对象,共同保存在一个名为 segments 的数组当中。因此整个 ConcurrentHashMap 的结构如下:可以说,ConcurrentHashMap 是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。这样的二级结构,和数据库的水平拆分有些相似。
1️⃣ConcurrentHashMap 的优势
采取了锁分段技术,每一个 segment 就好比一个自治区,读写操作高度自治,segment 之间互不影响。
由此可见,ConcurrentHashMap 中每个 segment 各自持有一把锁。在保证线程安全的同时降低了锁的粒度,让并发操作效率更高。
2️⃣Concurrent 的读写过程
Get方法:
- 为输入的 Key 做 Hash 运算,得到 hash 值。(为了实现 segment 均匀分布,进行了两次 Hash)
- 通过 hash 值,定位到对应的 segment 对象
- 再次通过 hash 值,定位到 segment 当中数组的具体位置。
Put方法:
- 为输入的 Key 做 Hash 运算,得到 hash 值。
- 通过 hash 值,定位到对应的 segment 对象。
- 获取可重入锁。
- 再次通过 hash 值,定位到 segment 当中数组的具体位置。
- 插入或覆盖 HashEntry 对象。
- 释放锁。
从步骤可以看出,ConcurrentHashMap 在读写时均需要二次定位。首先定位到 segment,之后定位到 segment 内的具体数组下标。
四、每个 segment 都各自加锁,在调用 size() 的时候,怎么解决一致性的问题
1️⃣调用 size() 是统计 ConcurrentHashMap 的总元素数量,需要把各个 segment 内部的元素数量汇总起来。但是,如果在统计 segment 元素数量的过程中,已统计过的 segment 瞬间插入新的元素,此时该如何?2️⃣ConcurrentHashMap 的 size() 是一个嵌套循环,大体逻辑如下:
- 遍历所有的 segment。
- 把 segment 的元素数量累加起来。
- 把 segment 的修改次数累加起来。
- 判断所有 segment 的总修改次数是否大于上次的总修改次数。如果大于,说明统计过程中有修改,重新统计,尝试次数 +1;如果不是说明没有修改,统计结束。
- 如果尝试次数超过阈值,则对每一个 segment 加锁,再重新统计。
- 再次判断所有 segment 的总修改次数是否大于上次的总修改次数。由于已经加锁,次数一定和上次相等。
- 释放锁,统计结束。
为什么这样设计呢?这种思想和乐观锁悲观锁的思想如出一辙。为了尽量不锁住所有的 segment,首先乐观地假设 size 过程中不会有修改。当尝试一定次数,才转为悲观锁,锁住所有 segment 保证强一致性。
五、ConcurrentHashMap(线程安全)
- 底层采用分段的数组+链表实现
- 通过把整个 map 分为 n 个 segment,保证线程安全,效率提升 n 倍,默认提升 16 倍。(读操作不加锁,由于 HashEntry 的 value 变量是 volatile 的,也能保证读取到最新的值。)
- Hashtable 的 synchronized 是针对整张 Hash 表的,即每次锁住整张表让线程独占,ConcurrentHashMap 允许多个修改操作并发进行,其关键在于使用了锁分离技术。
- 有些方法需要跨段,比如 size() 和 containsValue(),它们可能需要锁定整个表而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。
- 扩容:段内扩容(段内元素超过该段对应 HashEntry 数组长度的 75% 触发扩容,不会对整个 map 进行扩容),插入前检测是否需要扩容,避免无效扩容。
从类图可看出在存储结构中 ConcurrentHashMap 比 HashMap 多出了一个类 Segment,而 Segment 是一个可重入锁。ConcurrentHashMap 是使用了锁分段技术来保证线程安全的。
锁分段技术:
首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据仍能被其他线程访问。
ConcurrentHashMap 提供了与 Hashtable 和 SynchronizedMap 不同的锁机制。Hashtable 中采用的锁机制是一次锁住整个 hash 表,从而在同一时刻只能由一个线程对其进行操作;而 ConcurrentHashMap 中则是一次锁住一个桶。
ConcurrentHashMap 默认将 hash 表分为 16 个桶,诸如 get、put、remove 等常用操作只锁住当前需要用到的桶。这样,原来只能一个线程进入,现在同时能有 16 个写线程执行,并发性能的提升是显而易见的。
六、问题
如何在很短的时间内将大量数据插入到 ConcurrentHashMap,换句话说,就是提高 ConcurrentHashMap 的插入效率。-----尽量散列均匀和避免加锁
七、ConcurrentHashMap 的 key/value 不能为 null
二义性判断的前提:HashMap 的正确使用场景是在单线程下使用,ConcurrentHashMap 的使用场景为多线程。
ConcurrentHashMap 源码中进行 put() 的时候,如果 key 为 null 或者 value 为 null,会抛出NullPointerException。为什么这样设计呢?
if (key == null || value == null) throw new NullPointerException();
如果 ConcurrentHashMap 中存在一个 key 对应的 value 是 null,那么当调用 map.get(key) 的时候,必然会返回 null,此时该 null 就有两个意思:
- 该 key 从来没有在 map 中映射过,也就是不存在此 key。
- 该 key 是真实存在的,只是在设置 key 的 value 值的时候,设置为 null 了。
这个二义性在非线程安全的 HashMap 中可以通过map.containsKey(key)
来判断,如果返回 true,说明 key 存在只是对应的 value 值为空。如果返回 false,说明此 key 没有在 map 中映射过。这就是 HashMap 允许键值为 null 的原因,但是 ConcurrentHashMap 用这个是判断不了二义性的。为什么 ConcurrentHashMap 判断不了?
此时如果有甲乙两个线程,甲线程调用ConcurrentHashMap.get(key)
返回 null,但是不知道是因为 key 没有在 map 中映射还是本身存的 value 值就是 null。假设有一个 key 没有在 map 中映射过,也就是 map 中不存在这个 key,此时调用ConcurrentHashMap.containsKey(key)
去做判断,期望的返回结果是 false。但是恰好在甲线程 get(key) 之后,调用 constainsKey(key) 之前乙线程执行了ConcurrentHashMap.put(key,null)
,那么当甲线程执行完 containsKey(key) 之后得到的结果是 true,与预期的结果就不相符了。
至于 ConcurrentHashMap 中的 key 为什么也不能为 null 的问题,作者 Doug Lea 认为 map 中允许键值为 null 是一种不合理的设计,HashMap 虽然可以判断二义性,但是 Doug Lea 仍然觉得这样设计是不合理的。
八、ConcurrentHashMap 的 get() 不需要加锁
get() 可以无锁是由于 Node 的元素 val 和指针 next 是用 volatile 修饰的,在多线程环境下线程甲修改结点的 val 或者新增节点的时候是对线程乙是可见的。
九、ConcurrentHashMap 的并发度是什么?
程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时,ConcurrentHashMap 会使用大于等于该值的最小2幂指数作为实际并发度(假如用户设置并发度为17,实际并发度则为32)
十、ConcurrentHashMap 简单介绍以及put()和get()的工作流程是怎样的?
️1️⃣重要的常量:
private transient volatile int sizeCtl;
当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容;
当为 0 时,表示 table 还没有初始化;
当为其他正数时,表示初始化或者下一次进行扩容的大小。
2️⃣数据结构:
Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据;
TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储结构,用于红黑树中存储数据;
TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
3️⃣存储对象时(put方法):
- 如果没有初始化,就调用 initTable() 来进行初始化。
- 如果没有 hash 冲突就直接 CAS 无锁插入。
- 如果需要扩容,就先进行扩容,扩容为原来的两倍。
- 如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入。
- 如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一次进入循环。
- 如果添加成功就调用 addCount() 统计 size,并且检查是否需要扩容。
注意:在并发情况下ConcurrentHashMap会调用多个工作线程一起帮助扩容,这样效率会更高。
4️⃣扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。
helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
5️⃣获取对象时(get方法):
- 计算 hash 值,定位到该 table 索引位置,如果头节点符合条件则直接返回key对应的value。
2.如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find(),查找该结点,匹配就返回;
3.以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。
注意:其实get()的流程跟HashMap基本是一样的。put()的流程只是比HashMap多了一些保证线程安全的操作而已。
十一、为什么 ConcurrentHashMap 比 HashTable 效率要高?
ConcurrentHashMap的效率要高于HashTable,因为HashTable是使用一把锁锁住整个链表结构从而实现线程安全,多个线程竞争一把锁,容易阻塞。而ConcurrentHashMap的锁粒度更低:
①JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。
②JDK 1.8 中使用 CAS(无锁算法) + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry