阅读建议
1. 本篇文章仅用于个人笔记,因此有部分简略表达词汇(hm = HashMap, lhm = LinkedHashMap, chm = ConcurrentHashMap, ht = HashTable)
2. 因为写的比较简略,没有对一些源码出现的变量名做说明,建议已经对该集合有基本了解或对照源码做补充阅读,如果笔记有问题也欢迎讨论
3. 佬们有遇见过的相关面试问题也教教我。
源码分析
JDK1.7
- 存储结构:多个segment的组合体,每个segment中包含一个类似hm的hashEntry数组结构,因此可以在segment内部进行hm扩容,但是segment的个数一旦初始化就不能再改变。默认segment个数为16个,也就是说chm默认支持最多16个线程并发。
- 通过segment+ReentrantLock实现并发支持。
- 如果并发度设置过小,会带来严重的锁竞争问题;
- 如果并发度设置过大,原本位于同一个segment内的访问会扩散大不同的segment中,cpu cache命中率会下降,从而引起程序性能下降。
- 初始化:
- 检验并发级别大小:如果超过了1<<16则重置为1<<16。(端口数量最大是这个数,当然0号是定义成无效端口的。不过最大线程数是可以超过端口数的,所以这可能只是一个防止系统异常的兜底策略)
- 寻找并发级别之上最接近2的幂次方的数ssize,作为初始化容量大小。(每个hm的容量:c = initialCapacity/ssize, 容量cap = c之上最接近的2的幂次)
- 记录segmentShift偏移量,这个值为【2^N中的N】,默认是32 - sshift = 28。
- 记录段掩码segmentMask,默认为ssize-1。
- 初始化segment[0],默认大小是2,由于负载因子为0.75,插入第二个值的时候才会扩容。
- put方法
- 计算要put到的key的位置,获取指定位置的segment
- 这里是通过取hash的高N位(这个N就是SShift)和SMask(15就是1111)做与运算。
- 如果指定位置的segment为空,就初始化这个segment。(通过反复的确认null来确保每一步对segment状态的改变都不会出现hm中put的线程不安全问题。)
- 检查是否为null,为null才初始化。
- 自选判断指定位置的segment是否是null,使用CAS在这个位置赋值初始化segment。
- 使用创建的HashEntry数组初始化这个segment。
- 再次检查计算到的指定位置segment是否为null。
- 使用segment[0]的容量和负载因子创建HashEntry数组。
- 获取和初始化完segment后内部调用segment的put方法
- 由于segment继承了ReentrantLock,很容易可以通过加锁来保证线程安全,这里通过自旋获取锁加锁。
- 计算put到的index位置,然后遍历HashEntry数组尝试put
- 若entry元素不存在:
- 1. 如果当前容量大于扩容阈值,小于最大容量,进行扩容
- 2. 直接头插法插入
- 若entry元素存在:
- 1. 遍历判断当前链表元素的key和hash是否符合要求,一致则替换
- 2. 全都不一致
- 如果当前容量大于扩容阈值,小于最大容量,进行扩容
- 链表头插法插入
- 释放锁
- 若entry元素不存在:
- 计算要put到的key的位置,获取指定位置的segment
- 扩容rehash方法
- 针对HashEntry的扩容方法,可以参考hm的扩容只会扩容到原来的两倍。老数组里的数据移动到新的数组时,位置要么不变,要么变为 index+ oldSize,参数里的 node 会在扩容之后使用链表头插法插入到指定位置。
- 用两个for循环收尾
- 第一个是找到同样进入新位置的一批节点,直接接到新位置去。
- 第二个是把剩余节点通过头插法插入到指定位置链表。
- get方法
- 1. 一次hash定位到对应的segment位置。
- 2. 再一次hash定位到具体的元素位置。
- 有链表的话就要遍历链表找对应值了,这也是1.7的chm效率低下的一部分原因,所以它在1.8被优化掉了。
- 整个get方法没有用到加锁,因为HashEntry中的value属性是被volatile关键字修饰的,可以保证都看到最新的同一份内容,并且不会发生指令重排。
- 可以完成同一segment的并发读写和不同segment的并发写入
JDK1.8
- 存储结构:底层是Node数组+链表/红黑树,链表达到一定长度(8)时会转换成红黑树。
- 通过对Node节点的CAS+Synchronized关键字实现并发支持。
- 初始化:
- ConcurrentHashMap的构造函数仅用于设置初始参数,比如初始容量、负载因子和并发级别(不过初始化的并发级别并没有用到,只是说如果初始容量小于并发级别,会将容量初始为并发级别大小,因为是按Node节点数做并发的)。
- 而整个table的初始化是在向chm中插入第一个元素的时候发生的。比如调用put、computeIfAbsent、compute、merge方法并且检查table == null的时候。
- 初始化的并发控制主要用过sizeCtl属性完成:
- -1说明正在初始化,其他线程需要自旋等待。
- >0 表示 table 扩容的阈值,如果 table 已经初始化。
- 0 表示 table 初始化大小,如果 table 没有初始化。
- -N 说明 table 正在进行扩容,高 16 位表示扩容的标识戳,低 16 位减 1 为正在进行扩容的线程数。
- 初始化方法主要应用了关键属性sizeCtl 如果这个值<0,表示其他线程正在进行初始化,就放弃这个操作。在这也可以看出ConcurrentHashMap的初始化只能由一个线程完成。如果获得了初始化权限,就用CAS方法将sizeCtl置为-1,防止其他线程进入。初始化数组后,将sizeCtl的值改为0.75*n。
- put方法
- 根据 key 计算出 hashcode 。
- 判断是否需要进行初始化。
- hash & (n-1) 即为当前 key 定位出的 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败则自旋保证成功。
- 如果当前位置的 hashcode == MOVED == -1,则需要进行扩容。
- 如果都不满足,则利用 synchronized 锁写入数据。
- 如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树。
- 根据 key 计算出 hashcode 。
- get方法
- 根据计算出来的hashcode来定位,如果直接在桶上就直接返回桶值
- 如果桶里面是红黑树就按树的方式拿值
- 如果是链表就按链表方式
- 根据计算出来的hashcode来定位,如果直接在桶上就直接返回桶值
常见问题
- chm和ht的区别?
- jdk1.7:ht是用synchronized关键字锁住了整个类,而chm用了分段锁技术,其中的segment继承ReentrantLock,不像ht一样无论是put还是get都要做同步处理。chm支持segment数量级别的并发。
- jdk1.8:chm用的synchronized+CAS锁住每个Node节点,而不是锁整个类。