前言
本文主要描述ConcurrentHashMap在1.7和1.8中底层的的数据结构和相关方法的实现。
1. ConcurrentHashMap的使用背景和前置知识
1.1 使用背景
在多线程的生产环境下,HashTable虽然是线程安全的,但它的底层方法都是通过synchronized封装的,这样在竞争激烈的情况下,效率十分低下。而对于HashMap而言,由于它是线程不安全的,所以在并发的情况下容易造成死循环,让CPU的使用率达到100%(死循环的原因是因为它在put操作上会让hashMap里面的Entry链表产生环形数据结构),所以这时候,高并发环环境下,线程安全,效率又相当较高的容器ConcurrentHashMap就应允而生了。
1.2 前置知识
- Hash : 散列,哈希,把任意长度的输入通过一个算法(散列),变换成为固定长度的输出,这个输出就是散列值。属于压缩映射,容易产生哈希冲突。
- Hash算法:生活中比较常用的就是直接取余法(md4,md5,sha其实底层就是一种hash算法,也可以称之为摘要算法,该过程是不可逆的。)
- 解决哈希冲突的方法:开放寻址(把含有哈希冲突的元素通过另外一种算法放到其他位置上),再散列(换一种哈希算法进行再一次散列),链地址法(把含义哈希冲突的元素用一个链表进行连接,HashMap就是使用该方式解决的。)
- 位运算:
<< 有符号左移(第31位不变(即符号位不变),其余移位后,依次补0)
有符号右移 >> (正数高位补0,负数高位补1),无符号右移 >>> (无论是正数还是负数,高位都补0)
取模操作 a % (2 ^ n) 等价于 a & (2 ^ n - 1)
其余关于与或非等运算可参考位运算
2. ConcurrentHashMap 在JDK1.7中的实现
2.1 内部数据结构
1.它的底层是由一个Segment数组实现的,这个数组又刚好继承ReentrantLock(可重入锁),所以大家又把它称之为分段锁。
2.在Segment数组下面又存储了一个table数组,每个数组用链表连接多个HashEntry (在1.8后才有红黑树和链表的转变),这个HashEntry存放我们的hash, key ,value, 指针next; 其中value和指针next又是由volatile修饰的(保证了内存的可见性),那么我们再改变值的时候,我们get() 方法能马上感知到。而对于哈希冲突的解决上, 它是使用了链地址法解决。
(这是1.8的截图,不过就是类的名字不同而已)
2.2 相关的初始化(构造方法)
initialCapacity : 初始化容量大小,默认16
loadFactory:扩容因子,默认为0.75, 当一个Segment存储的元素大于initialCapacity * loadFactory的时候,该Segment会进行一次扩容。
concurrencyLevel :并发度,默认为16,并发度可以理解为程序运行时能够更新 ConcurrentHashMap且不产生锁竞争的最大线程数,实际上就是 ConcurrentHashMap中分段锁的个数,即Segment[] 的数组长度,如果并发度设置的过小,会带来严重的锁竞争问题,如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中, CPU cache命中率会下降,从而引起程序性能下降。
构造方法中的其他疑惑(记住上面的几个重要参数即可)
- 当我们设置并发度的时候,系统会做一次检查,当Segment的大小小于该并发度的时候,它会进行左移。(假设我们设置并发度为17,由于数组的大小都为2的幂,所以初始化时大小为32)
while(ssize < concurrencyLevel){
++sshift;
ssize << = 1;
}
- 初始化Segment数组,并实际填充Segment数组的第0个元素。(其余元素等填充时再初始化)
. - 确保每个Segment中table数组的大小一定为2的幂(其中cap为table的容量),初始化的三个参数取默认值时,table数组大小为2。(其余等填充元素的时候,再初始化)
- segmentShift 表示取高几位,segmentMask 表示与元素hash进行与操作的值(与操作等于hash和它的长度求余)
this.segmentShift = 32 - sshift;
this.segmentMask = ssize - 1;
2.3 get() 方法
因为我们元素的值存与HashEntry中,所以要进行两次定位,一次是Segment的定位,一次是table的定位。
- 定位Segment: key的hashcode值进行再散列(是通过Wang/Jenkins算法)后取高位和 Segment的长度取模
// 外部类方法
public V get(Object key) {
int hash = hash(key.hashCode()); //进行一次再散列
return segmentFor(hash).get(key, hash); // 第一次hash 确定段的位置
}
//以下方法是在Segment对象中的方法;
//确定段之后在段中再次hash,找出所属链表的头结点。
final Segment<K,V> segmentFor(int hash) {
return segments[(hash >>> segmentShift) & segmentMask]; //取高位和 Segment的长度取模
}
- 定位table: key的hashcode进行再散列的值直接和table的长度取模(这里不需要取高位)
- 依次扫描链表,要么找到元素,要么返回null
V get(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry<K,V> e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null)
return v;
return readValueUnderLock(e); // recheck
}
e = e.next;
}
}
return null;
}
这里值的注意的是get()方法是不需要加锁的,这里和上面所说的volatile修饰的value和指针next有关,这里可以保持一致性,却不能是实时的,这在某种程度上说明了它的弱一致性。
2.4 put() 方法 : (需要对Segment数组进行加锁)
这里依旧需要定位两次,一次是定位Segment数组,一次是定位table数组,不过由于是放数据,我们需要考虑线程安全,需要加锁。需要考虑table的容量,考虑扩容。
- 首先定位segment, 同样是取key的hash值进行再散列,用高位与segment的长度取模。不过需要注意的是这个segment数组在 容器初始化时,只有第一个值不为空,此时需要使用ensureSegment() 方法负责填充这个segment数组。
- 对数组segment进行加锁
- 定位所在table数组,同样是取key的hash值进行再散列,整个和table的的长度取模。
- put() 放元素的时候,若hash值相同时,key值不同,这时他们会放入同一个table数组中,使用链表进行连接。若hash值相同,key值也相同时,它会覆盖原来的元素。
下图为hash值相同,key值也相同则进行覆盖的情况:
下图为数组中没有找到相应元素,无序覆盖的情况
对于扩容操作,我们下面再叙述
2.5 扩容操作
数组Segment是不会扩容的,它只会扩容下面的table数组,而且每次扩容都是将数组翻倍(即容量左移1位)
扩容时都是2的整数倍,主要是为了可以快速定位和减少重排的次数
2.6 size()
JDK1.7中是进行两次不加锁数量的统计,一致则直接返回结果。不一致,则重新加锁再次统计。
补充说明:ConcurrentHashMap时一种弱一致性的容器,因为它在get() 和containsKey() 方法上都是没有加锁的,有可能在拿值时发生了改变引起了不一致。
2.7 面试常问:ConcurrentHashMap实现原理是怎样的或者问ConcurrentHashMap如何在保证高并发下线程安全问题的同时实现了性能提升?
ConcurrentHashMap 运行多个修改操作并发运行,其关键在于使用了锁分离技术,它使用多个锁来控制hash表的不同部分的修改,内部使用(Segment) 来表示这些不同部分,每个段其实就是一个小的table数组,只要多个修改操作发生在不同的段上,他们就可以并发进行。
3. ConcurrentHashMap 在JDK1.8中的实现
3.1 内部的数据结构
与1.7相比重大的变化:
- 取消了segment数组,直接使用Node数组保存数据,锁的粒度更小了,减小并发冲突的概率
- 数据存储的形式使用了链表+红黑树(而非像1.7那样只使用链表);链表查询的时间复杂度为 O(n), 红黑树则为O(log n), 性能得到了很大的提升。(当节点的个数超过8个的时候,链表会转化成红黑树;当节点个数小于6个的时候,红黑树会转化成链表)
- 对于Node的数组(和1.7的table是一致的)
3.2 初始化(即构造函数)
- initialCapacity(容量), loadFactor(扩容因子),concurrencyLevel(并发度);这几个参数和1.7的值是一致的;
- 可以看出它只是简单地对属性赋初值,并没有给实际数组Node填充数据(填充是在put操作上完成的)
3.3 put() 操作
- 定位:由于只有一个Node数组,我们只需定位1次(和1.7一样,对key取hash后进行再散列和当前node数组的长度取模)
- 初始化数组:
- 放数据
3.3.1 如何初始化数组?
这里有一个关键变量sizeCtl(它取不同值表示不同含义)
- 负数: 表示进行初始化或者扩容,-1表示正在初始化,-N, 表示有N-1个线程正在进行扩容。
- 0: 0表示还没有初始化
- 正数:初始化或者是下一次进行扩容的阈值。
3.3.2 如何放数据?
【1】 链表中没有值的时候,直接添加元素
【2】链表中有值的时候
- hash 和key值相同的时候,覆盖原来的值。(首先要锁住链表)
- hash值相同, key值不同的情况,构建出新节点,往链表后面添加
- 当构成的是树,而不是链表的时候,按树形式进行添加。
关于TreeBin的解读,它是实际存放在table下面的数据,而它里面有一个TreeNode表示红黑树的根结点。
3.4 get() 方法
- 还是需要取key的hash值进行再散列后和Node数组的长度取模进行定位
- 匹配时,先检查数组的头元素是否匹配,若匹配直接返回;不匹配则分为红黑树查找匹配和链表查找匹配。
3.5 扩容操作
- 它在进行put()操作的时候,会通过helptransfer() 进行帮助扩容,不过底层调用的也是transfer() 方法。
- 扩容其实也是数组的长度翻倍(扩容的条件依旧是达到 容量* 扩容因子(0.75) );不过与1.7不同的是,它会进行节点数的检查,若节点数小于6了,它还是会变回链表的。
helpTransfer() 还是调用了transfer() 方法
3.6. size() 方法
估计的是大概的数量,不是精确的数量
和jdk1.7一样,它依然是一种弱一致性的容器。
总结
兜兜转转,还是坚持写完了这篇快1万字的长文。它可能还存在许多不足,还希望阅读的人能在评论区多与我交流指正。刚好是晚上,祝所有的程序员晚安,有个好梦。