JUC---Jdk1.7和jJdk1.8中ConcurrentHashMap的实现

前言

本文主要描述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命中率会下降,从而引起程序性能下降。

构造方法中的其他疑惑(记住上面的几个重要参数即可)

  1. 当我们设置并发度的时候,系统会做一次检查,当Segment的大小小于该并发度的时候,它会进行左移。(假设我们设置并发度为17,由于数组的大小都为2的幂,所以初始化时大小为32)
while(ssize < concurrencyLevel){
	++sshift;
	ssize << = 1;
}
  1. 初始化Segment数组,并实际填充Segment数组的第0个元素。(其余元素等填充时再初始化)
    . 在这里插入图片描述
  2. 确保每个Segment中table数组的大小一定为2的幂(其中cap为table的容量),初始化的三个参数取默认值时,table数组大小为2。(其余等填充元素的时候,再初始化)
    在这里插入图片描述
  3. 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万字的长文。它可能还存在许多不足,还希望阅读的人能在评论区多与我交流指正。刚好是晚上,祝所有的程序员晚安,有个好梦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值