Java并发系列七--ConcurrentHashMap

回顾HashMap
既然说到HashMap了,那么我们就先来简单总结一下HashMap的重点。
1.基本结构
HashMap存储的是存在映射关系的键值对,存储在被称为哈希表(数组+链表/红黑树)
的数据结构中。通过计算key的hashCode值来确定键值对在数组中的位置,假如产生碰撞,则使用链表或红黑树。

需要注意的是,key最好使用不可变类型的对象,否则当对象本身产生变化,重新计算key的hashcode时会与之前的不一样,导致查找错误。

由这一点可知,在存储键值对时,我们希望的情况是尽量避免碰撞。那么如何尽量避免碰撞?核心在于元素的分部策略和动态扩容。

2.分步策略
分步策略方面的优化主要为三个方向:
•HashMap底层数组的长度始终保持为2的次幂
•将哈希值的高位参与运算
•通过与操作来等价取模操作。

3.动态扩容
动态扩容方面,由于底层数组的长度始终为2的次幂,也就是说每次扩容,长度值都会扩大一倍,数组长度length的二进制表示在高位会多出1bit。

而扩容时,该length值将会参与位于操作来确定元素所在数组中的新位置。所以,原数组中的元素所在位置要么保持不动,要么就是移动2次幂个位置。

以上三点都是关于HashMap本身设计特点,不在本文的主要讨论范围内。如果还不太熟悉,建议先了解HashMap的原理。

但是,HashMap美中不足的是:它不是线程安全的。主要体现在两个方面:
•扩容时出现著名的环形链表异常,此问题在JDK1.8版本被解决。
•并发下脏读脏写。

所以,程序员们就想要一种与HashMap功能同样强大,但又能保证读写线程安全的集合容器,这就是本文的主角——ConcurrentHashMap

HashTable
有的读者可能会有这样的疑惑: 既然HashMap有线程安全问题,那我每次进行get/put操作时,都用锁进行控制不就好了?太对了,HashTable就是这么做的,可以看到他的源码里面简单粗暴,给put/get操作都加上了sychronized


但这样就会导致效率低下,在多线程环境下,当锁住map,进行读写操作时,其他想要操作的线程都会被阻塞。所以现在基本上都不再推荐使用HashTable

 有的读者可能又有疑惑了:ConcurrentHashMap难道就没有这个问题吗?它的内部应该也是用的锁吧。

ConcurrentHashMap
这就是本文要讲的重点,看看ConcurrentHashMap是如何在保证线程安全的情况下,并且做到高效的。

在JDK1.7和1.8中,ConcurrentHashMap的实现方式发生了很大的变化。有人可能觉得既然都已经更新了,那1.7就没有看的必要了。我认为读源码的目的是为了学习其中的思想,并尝试在今后的开发中进行运用。在1.7版本中,ConcurrentHashMap的分段锁思想比较经典,值得学习,本文主要从1.7版本说起。

HashTable之所以性能差是因为在每个方法上都加上了sychronized,这就相当于只用一把锁,锁住了整个数组资源。而ConcurrentHashMap用到了分段锁,每把锁只锁数组中的一段数据,这样就能大大减少锁的竞争。概念上很简单,那么具体是如何实现的呢?首先来看这么一张数据结构示意图:


可以看到,ConcurrentHashMap内部维护了一个segment数组,该数组的每个元素是HashEntry数组,看到HashEntry数组的这个结构是不是很熟悉,和HashMap中的哈希表如出一辙。

 

如果你读懂了这张图,基本上也就明白了ConcurrentHashMap是如何存储数据的,不过仅仅了解这些还不够,ConcurrentHashMap究竟通过哪些设计保证其线程安全,我们需要进一步深挖。细节都藏在源码里。

这里再次声明一下,ConcurrentHashMap内部都很多和HashMap一样的设计和技巧,一旦遇到,本文不再详细介绍。

源码
在JDK1.7版本中,ConcurrentHashMap的源码并不长,首先来看ConcurrentHashMap类的继承情况。

 继承情况
ConcurrentHashMap继承了Abstract抽象类,实现了ConcurrentMap接口。

AbstractMap类内部是一些Map通用方法的声明以及一些公共方法实现,本文不做深究。
ConcurrentMap接口中声明了四个方法,是对Map本身的增删改查,只不过要求实现类保证这些操作的线程安全,我们之后会看ConcurrentHashMap具体是如何实现的。

接下来就来看ConcurrentHashMap的内部实现。

 

静态变量

 以上各个静态变量都被final修饰了,而且都是基本数据类型,所以是作为常量来使用的。结合这张结构图,一起理解各个变量的定义。

属性
在没有看源码前,根据上述结构图,大致可以猜到最核心的属性应该有支持泛型的Segment数组(其元素为HashEntry数组)。核心内部类应该就是Segment和HashEntry。在源码中也确实如此。

 除此之外,segmentMask和segmentShift这两个属性我们暂时还不明白它们的作用,后续用到时会详细讲到。

内部类


HashEntry的结构不复杂,它拥有hash,key,value三个属性值,并且可以通过next引用来构建链表,整体上和HashMap中的内部类Node比较相似。但是不难发现26-35行有一段被static修饰的代码,这段代码是什么含义呢? 

简单介绍一下Unsafe这个类,正如它的名字一样,Unsafe对象用于执行一些不安全的,较为底层的操作,比如直接访问系统资源。因为使用它的风险较高并且场景较少,所以我们在日常的业务代码中几乎看不到对Unsafe的使用,但是对于一些追求高效,并且有能力保证安全的下层组件来说,使用Unsafe是家常便饭。

第30行中objectFieldOffset方法返回的是“指定成员属性在内存地址相对于此对象的内存地址的偏移量”,这句听上去比较拗口。在这里,使用该方法获取next属性的相对内存偏移量,然后方便在第10行中调用putOrderedObject来对next赋值,而putOrderedObject下层是一个CAS调用。可以这么理解,这里直接使用Unsafe对象获取next的内存偏移量,是为了更方面地使用CAS对next进行赋值。如果这段话你不是很明白,忽略也不会影响后续理解。

整体上,HashEntry结构清晰,易于理解。下面我们来看看相对复杂一点的Segment。

Segment
继承关系


Segment继承自ReentrantLock,这里分段锁的味道就体现出来了。每个Segment对象就是一把锁,一个Segment对象内部存在一个HashEntry数组,也就是说,HashEntry数组中的数据同步依赖同一把锁。不同HashEntry数组的读写互不干扰,这就形成了所谓的分段锁。 

我们可以大胆猜想:假设Segment数组的长度为n,那么相较于Hashtable,理论上ConcurrentHashMap的性能就要提升n倍以上。有的读者可能会存在疑惑:ConcurrentHashMap用n把相互独立的锁替换Hashtable全局一把锁,那照理说性能提升最多也是n倍,为什么要说n倍以上呢?

因为相较于HashTable中使用的synchronized,ConcurrentHashMap对锁本身也做了优化。具体是怎么优化的,我们下文会讲到。

 MAX_SCAN_RETRIES:指定的重试次数。在多线程进行put操作时,只有一个线程能够成功获得锁进行写操作,那么此时其他线程也不必死等,可以通过多次tryLock进行重试,并做一些其他的工作,这就体现出了效率的提升,后面会讲到。
table:之前提到的HashEntry数组
count:HashEntry数组中元素个数
modCount:HashEntry数组修改次数
threshold:触发扩容的阈值
loadFactor:负载因子

上述属性也比较容易理解,基本和HashMap中同名属性的意义一样。


方法基本上就是增删改查还有扩容,由于篇幅原因这里没有展示方法体内容,在讲解到具体方法时,再来看方法内部的具体逻辑。

 

但有两个方法名比较陌生:scanAndLockForPut和scanAndLock.之前我们说到这样一个常见场景,当A线程正在修改HashEntry数组(属性名为table)的某个桶,此时B线程也想要修改这个桶,但是A线程持有了独占锁,所以B线程只能等待或重试,若只是干等或不断重试,可能会是一种浪费,所以有一种优化思路就是让B在重试的过程中预先完成一些后续将会用到的准备工作。
scanAndLockForPut和scanAndLock方法的逻辑就实现了这种优化,下文我们会按照自顶向下的流程详解这两个方法。

方法
至此为止,开胃菜吃完了,我们已经介绍了如下内容:
ConcurrentHashMap的属性(其中segmentMask和segmentShift还未介绍)
核心内部类HashEntry和Segment(其中segment的方法还未详解)
接下来就是正餐,看一看ConcurrentHashMap究竟如何实现线程安全的put操作,至于get replace remove操作,相较于put更加简单,篇幅原因本文不再赘述。相信你如果能够理解put的设计后,其他都能通过举一反三的方式理解。


构造方法:
首先看一看ConcurrentHashMap的构造方法,这将帮助你对它有一个更加直观的认识


三个参数initialCapacity,loadFactor,concurrencyLevel三个值的含义在开头介绍过,这三个值采用默认值16,0.75,16 

1. 3-4行。对参数进行简单的校验,如果不满足条件则抛出异常。
2. 8-13行。出现了两个局部变量sshift,ssize。 ssize就是segment数组的长度,初始值为1,当ssize小于concurrencyLevel时,sshift自增1,ssize左移一位,相当于扩大两倍。也就是说,当concurrencyLevel为16时,ssize最终也是16,如果concurrencyLevel为17,那么ssize最终为32。为什么要这么设计呢?这是为了控制segment数组的长度始终为2的次幂,为什么要控制其为2的次幂?这是为了在计算元素索引时进行优化,和HashMap中的设计方式一样。
3.14-15行 我们假设concurrencyLevel为16,此时,sshift为4,ssize为16。那么segmentShift为28,segmentMask为15。ssize是segment数组长度并且总是2的次幂,segmentMask为ssize减1,二进制下为1111,这被称为掩码。在这里,segmentMask的二进制序列每一位总是1.掩码用于与key的哈希值进行位与操作来代替取模,计算出索引值,以此以为key所在的桶。这个操作是不是很熟悉,在HashMap中也是用到了相同的设计。
4.16-23行。segment数组的长度确定了,接下来需要确定segment数组的每个元素,即HashEntry数组的长度。变量cap即为计算后的HashEntry的数组长度,相同地,cap也一定是2的次幂
5.25-30行,确定了Segment与HashEntry的相关参数,接下来进行初始化。并且向Segment数组中加入了第一个元素。
万事俱备,只欠东风,准备阶段的内容都已经理解了话。接下来我们就来看ConcurrentHashMap最经典的put操作。

 put操作主要有两个方法:put和putIfAbsent,不难发现除了最后一行,其他都一样。有的读者可能要说了:写的什么垃圾代码,懂不懂封装啊。(手动狗头)我理解这是为了屏蔽调用方的理解难度,这是闲话不表,继续看代码。主要就来讲put方法。

1.第5行。首先对key进行哈希,hash方法内部就是一系列的数学运算,细节这里就不介绍了。
2.第6行。接下来计算变量j的操作,首先移位,然后和掩码进行位与运算,其间的含义和HashMap中如出一辙。这里就不在赘述。
3.7-9行,通过索引和ensureSegment方法来取出目标Segment对象。在介绍构造函数的篇幅中我们提过,构造函数中只为Segment数组第0个元素赋值。而ensureSegment内部会对第j个元素是否存在进行判断,若不存在,则使用CAS进行初始化保证取出的s对象不为null。ensureSegment内部的逻辑这里不深究,但是这种懒加载的思想值得学习。

第10行。取出Segment对象s后,我们知道s内部存在一个HashEntry类型的数组table,插入的元素究竟如何线程安全地存入table,如何解决并发问题,就要继续看这里调用的put方法。

private put
该方法相对比较复杂与核心。


先来看这几个形参:Key hash value onlyIfAbsent
前三个参数很易于理解,因为要插入新的HashEntry,那必定要构造该对象,这三个参数都是HashEntry的构造方法所需要的。最后一个onlyIfAbsent指定插入条件,如果当前key已经存在,那么只有当onlyIfAbsent为false时才会覆盖。

 1.2-3行
首先就是一个三目运算符,终于见到了心心念念的tryLock()。因为插入是一个并发操作,这里通过ReentrantLock的性质来进行控制。如果当前线程获得了锁,那么将node值置为null,等待后续的初始化;否则执行scanAndLockForPut方法。

这里有的读者可能会有疑问:为什么不直接调用ReentrantLock的lock方法,若第一时间没获得锁那么久等待直到获取。我认为这样做也不是不行,但显而易见在这里等待的时间浪费了,作者Doug Lee提供了一种性能更高解决方法,也是精华所在。就是当某线程若没有第一时间获得锁,将会执行scanAndLockForPut方法,进行一些预处理工作,这样就减少了时间上的浪费,该方法我们下文会细讲。

2.4-22行
我们来看如果线程trlLock获得了锁的情况。首先计算index(length-1就是获得掩码)。我们知道HashEntry数组table中每个元素都可能是HashEntry链表。entryAt通过index拿到HashEntry链表的头结点。接下来通过头节点去遍历链表,如果发现key已存在,则根据onlyIfAbsent值判断是否应该覆盖value,然后退出;如果key不存在或头结点本身就是null,那么进入else块,执行插入新节点的逻辑。
3.23-35行
else块中的逻辑比较丰富,需要仔细来看。首先判断node是否为null,这个判断点略显奇怪,node只在最开始的三目运算中操作过,若当前线程抢到锁,那么node为null,而这里既然判断node是否为null,是不是也就是说若线程没抢到锁,执行scanAndLockForPut的结果,就是初始化node.这个可能性很大,我们暂不去证实,等到具体看scanAndLockForPut方法时再确定。

若node还是null,那么进行初始化,并插入链表,看到参数中有first,大致能猜到使用了头插法,看看HashEntry的构造方法中的逻辑,确认了一下果然。

 接下来判断HashEntry数组是否需要扩容,如果不需要扩容,那么将node放入HashEntry数组的相应位置,这里需要注意的是,我们刚才说将node插入链表使用的是头插法,所以这里node已经成为了头节点。最终释放锁。

该方法因为一开始就已经用锁进行了控制,所以内部不会并发问题,理解上应该不难。


scanAndLockForPut
接下来就是上文一直提到的scanAndLockForPut方法,在进入put方法时,如果当前线程没有拿到锁,那么将会面临什么命运呢?接下去看。

 上文我们已经做出了一个大胆的猜想:scanAndLockForPut中,线程预创建了node,并插入了链表。

反应比较快的读者在这里可能会产生如下疑惑:如果scanAndLockForPut方法中根据key value hash创建了node,但是后续返回的put方法中有逻辑会对当前key是否存在进行判断,如果已经存在,那么根本用不到这个预先创建的node,这样不是就浪费了,或者根本没有预先创建的必要吗?


如果能产生这个疑问,那么说明已经完全理解了。但实际上的预创建的逻辑要更加高明一些,在创建的过程中对key是否存在进行判断。

这是一种预创建的思想,当一部分线程无事可干的时候,不要让它们干等,而是让它们去做一些可以预先完成的任务。以后在工作遇到类似的场景时,完全可以借鉴这种思想。

在阅读该方法的逻辑时,我们需要知道其中逻辑都是处于并发状态下,因为获取锁的线程可能正在修改链表(增 删 改)

1 第6行
可以看到方法在第6行进入了一个while循环,当tryLock()为false,也就是说若当前线程未获得锁时,将会不断执行。

2 8-16行
retries值的初始值为-1,也就是说循环第一轮一定会进入这块逻辑。若e为null,也就是说链表的头结点为null,那么进入初始化node的逻辑;如果e不为null,那么遍历链表,一旦发现链表中存在相同key的node,就将retries置为0,表示不再进入这段可能初始化node的逻辑。

3.17-19行
若retries已经被置为>=0,说明因为存在相同的key不要创建node,或者node已经创建好了。这里就对retries开始自增,相当于自选,如果超过自选次数后还未获得锁,那么调用lock(),老老实实排队。

4.20-23行
如果当前retries&1 == 0 ,这是个什么操作?也是就说retries每自增两次,将会出现一次retries&1 == 0。 且此时出现了hash值一样的key的话,那么将会再次遍历链表检查是否需要创建node(因为也有可能目标key所在的node已经被其他线程删除了)。以此往复,直到获取锁或retries超过阈值。

这个方法因为出现在了并发环境下,所以需要考虑的情况比较多,有兴趣的读者不妨画出流程图来仔细品一品、


扩容
最后我们再提一下扩容,ConcurrentHashMap中的扩容仅针对HashEntry数组,Segment数组在初始化后无法再扩容。
源码中我们也看到,再调用put操作,会对是否需要rehash进行检查.扩容本身是很重要的知识点,但是由于HashEntry数组的扩容和HashMap中基本一样,所以就不赘述了。不同的是,HashEntry数组的扩容操作已经被外层put方法中获取的锁保护起来了,所以能保证线程安全。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值