HashMap线程不安全怎么办?
我们都知道HashMap结合了数组与链表(红黑树)的特性,可以快速的进行取值存值。然而他最大的弊端便是不能在多线程环境下使用(会造成值覆盖与循环链表)。
映射到我们的代码中,最常见的就是我们不能写static Map map = new HashMap();这样的语法,供多个线程使用。那么,在多线程环境下,我们该如何选择key-value键值对格式的数据结构呢?
Hashtable,为所有数据添加重量级锁
JDK的命名bug
Hashtable这个命名就很有意思,并没有遵从我们的驼峰形式书写。其中的table是小写,这个点大家留意一下即可。
Hashtable加锁原理
HashTable和HashMap的实现原理几乎一样,差别无非是
1. HashTable不允许key和value为null;
2. HashTable是线程安全的。
但是HashTable线程安全的策略实现代价太大了,简单粗暴。get/put所有相关操作都是synchronized的,这相当于个整个Hash表加了一把大锁,多线程访问时,只要以后一个线程访问或操作该对象,那其他线程只能阻塞,相当于所有的操作串行化,在竞争激烈的多线程场景性能就会表现的非常差。
因此,Hashtable即使解决了线程不安全的问题,但是由于我们要制造一个多线程下使用的HashMap有这两个目的。
- 为了处理线程与线程之间的共享数据问题。(满足)
- 为了处理高并发问题。(不满足)
因此为整个HashMap添加一个重量级锁而实现的Hashtable很明显很难达到我们满意的效果。
此时,就诞生了今天的主角,ConcurrentHashMap。除了处理数据之间的共享问题,我们的JDK开发工程师在对其在高并发环境下处理问
题的效率问题中,下了大工夫。
ConcurrentHashMap1.7的诞生,"分段锁"思想的迸发
在我们抛出Hashtable存在的问题后,我们该如何解决这"一把大锁"的问题呢?
HashTbale性能差主要是由于所有操作都需要竞争同一把锁,而如果并发容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据就不会存在锁竞争了,这样可以有效的提高并发效率。这就是ConcurrentHashMap所采用的”分段”思想。
从这些设计上,博主当时感触颇深。这不正如一个王朝的发展么?
记得我们的军神"韩信"和刘邦在一次聊天中这么说过:
刘邦问他:“你觉得我可以带兵多少?”
韩信:“最多十万。”
刘邦不解的问:“那你呢?”
韩信自豪地说:“越多越好,多多益善嘛!
刘邦半开玩笑半认真的说:“那我不是打不过你?”
韩信说:“不,主公是驾驭将军的人才,不是驾驭士兵的,而将士们是专门训练士兵的。”
韩信点兵,多多宜善说的就是这个道理。
看到ConcurrentHashMap1.7的设计时,是不是有一种大道同归的气概呢?
实际上这便是一种责任分摊的理念。既然我身为皇帝,无法管理每一个我手下的小兵,那么我就将队伍划分。并且每一批队伍都有一个带队的将领(Segment)。每一个将领同时只能管教自己的一个兵(Entry)。但是,不同将领之间,是可以并行管教自己小兵的(Entry)。ConcurrentHashMap1.7中默认创建16个Segment。也就是说,当理想情况下,16个Segment在同时操作时,其执行效率将是Hashtable的16倍。
我们可以得到结论,每一个Segment就是一个Hashtable。可以说,ConcurrentHashMap1.7是一个二级哈希表。在一个总的哈希表下面,有若干个子哈希表。用更加专业的词汇来形容ConcurrentHashMap1.7的实现方向就是:减小锁的粒度。
从源码深入执行细节
上面的叙述中,让我们从全局的角度认识了ConcurrentHashMap1.7的核心思想。下面让我们走进源码,从底层揭开它神秘的面纱。
Segement的数据结构
ConcurrentHashMap采用了非常精妙的”分段锁”策略,ConcurrentHashMap的主干是Segment数组。
Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。
这里我们就验证了每一个Segment分管着一批小兵,并且每一个Segment本身使用的依旧是重量级锁来实现线程安全的。一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,并发环境下,对于不同Segment的数据进行操作时不用考虑锁竞争的(就按默认的ConcurrentLeve为16来讲,理论上就允许16个线程并发执行)。
所以,对于同一个Segment的操作才需要考虑线程同步。不同的Segment则无需考虑。
Segment类似于Hashtable,一个Segment维护着一个HashEntry数组(数组+链表形式)
HashEntry的数据结构
HashEntry是目前为止我们提到最小的逻辑单元了。实时上,我们在代码里,也就是根据这个HashEntry的key,访问它的value的。一个ConcurrentHashMap1.7维护多个Segment锁,一个Segment维护一系列HashEntry(数组+链表)的元素。如果对HashMap足够了解的话,那么我们在了解可ConcurrentHashMap1.7数据结构后,操作的中心自然也就放在了对它的K上。
构造方法的三个参数解读
在ConcurrentHashMap1.7中,共存在五个构造方法。本文仅列举这个典型的三参构造方法去理解它。绝大多数场景下,我们都默认使用其无参构造即可。ConcurrentHashMap1.7是提供了多个构造方法,让我们在不同的业务场景下DIY一个更加适合我们的ConcurrentHashMap的。
initialCapacity(初始化容量)
初始容量大小 ,默认16。(也就是我们一个Segment下的HashEntry个数默认为16)。
initialCapacity的计算方式与我们的HashMap散列算法是一致的。无论我们设置多大的值,其实际运算时,一定取的是第一个大于我们设置值的2的次幂的值。这个值是根据Hash散列算法的与运算规则而设定的。
initialCapacity有什么意义呢?我们需要配合下一个参数loadFactor一起来看。
loadFactor(负载/扩容因子)
为了节约我们的内存,以及加快我们的访问效率。扩容这个概念在HashMap与ConcurrentHashMap中这个概念都是必不可少的。
扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity* loadFactor时,该Segment会进行一次扩容。
concurrencyLevel (并发度)
由上面两个参数,我们决定了一个Segment中的HashEntry的扩容时机。那么我们真正意义上限制我们极限并发数的参数一定是Segement数组的个数。这时我们就可以设置concurrencyLevel 来提高我们的Segement个数。
需要注意的是,Segement数组的大小也不是由concurrencyLevel一板砖敲定的。和initialCapacity的计算方式是一致,取的是第一个大于我们设置的concurrencyLevel值的2的次幂的值。
诶?那我们有了这个参数,其实不无限调大,并发程度就越高么?这里官方的描述是这样的:
如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降。(文档的说法是根据你并发的线程数量决定,太多会导性能降低)。
因此,这个值一般的大小设置往往要从我们的数据量大小,以及机器的性能方面综合考虑。在经过压测后才能得出最优结论。
get操作
源码我们要领会其思想与精神即可。我们对比HashMap与ConcurrentHashMap1.7时,得到的结论就是多了一个Segement(将领)来进行管理,那么我们的get操作自然也很清晰明了了。就是比HashMap多一次对Key的Hash运算。先定位到Segment,再继续把HashEntry当做HashMap来get数据就好了。
get操作流程
流程如下:
1.为输入的Key做Hash运算,得到hash值。
2.通过hash值使用与运算的,定位到对应的Segment对象。(使用了散列值的高位部分)。
3.再次通过hash值,定位到Segment当中数组的具体位置。(使用了散列值的全部)。
不用加锁
我们都说,Segement是一个将军,其麾下的每一个小兵都需要一对一的培训。但是我们发现get操作全程都没有锁的影子,甚至连乐观锁的见不到。
其实这也不难理解。因为在get过程中,每一个Key经过两次Hash运算后,得到的hash值再经过链表的遍历后都是一定是唯一的。因此get操作就不需要加锁的。并且如上图所示,每一个HashEntry都被volatile所修饰(屏蔽缓存),因此每一次拿到的值一定是最新值。这样无论写多写少,只要是读的场景下,ConcurrentHashMap1.7性能一定是非常彪悍的。
put操作
ConcurrentHashMap1.7延迟加载机制
我们知道HashMap的延迟加载机制。
HashMap map = new HashMap(); // 伪初始化
map.put("键","值"); // 真初始化
在new HashMap的时候,我们的HashMap中的数组长度依旧为0。在执行put方法时,我们的数组长度才真正意义上进行了初始化。
ConcurrentHashMap1.7依旧延续了这个思路。
Segment的初始化
ConcurrentHashMap初始化的时候会初始化第一个槽segement[0],对于其他槽,在插入第一个值的时候在进行初始化。通过找到的hash值,开始初始化相应的segment。
ensureSegment方法考虑了多并发情况,多个线程同时进入初始化同一个槽segment[j],通过CAS判断至但只要有一个成功就可以了。成功获取到Segment对象锁的,返回Segment对象。
初始化完毕后,开始执行Segment的put操作。由于Segment两两之间互斥,所以put操作需要加锁确保唯一性。
为什么初始化的时候已经CAS了,put还需要CAS呢?
可以看到,在初始化完毕后,我们实际put元素时,依旧需要CAS加锁。那么初始化加锁了,put元素还需要加锁么?
初始化保证了多个线程同时初始化同一个Segment的唯一性。
而put操作是在初始化完毕后,多个不同线程同时计算出的hash值都属于一个Segment,此时依然会出现线程不安全问题。那么此时就需要对不同线程,相同Segment的并发数据进行唯一性处理。
简而言之,他们加锁的目的是不一样的,都需要保证同时只有一个线程执行。
为什么要用CAS乐观锁加锁,而不是使用Synchronized悲观锁呢?
这里,我们先描述一下CAS与Synchronized锁的使用场景区别。
Synchronized关键字是基于阻塞的锁机制。也就是说当一个线程拥有锁的时候,访问统一资源的其他线程需要等待,直到该线程释放锁。当这个成功获取到锁资源的线程执行完毕后,其余等待中的线程将被再次唤醒争抢锁。
这就牵扯到了Synchronized的一个缺陷,就是一个线程执行完毕,到另外一个线程开始执行时,总是要经过系统CPU调度,进行上下文切换的。并且只有等一个任务彻底结束后,下一个任务才会重新开始执行。这也就是我们悲观锁带来的问题。
CAS又被称为乐观锁。其核心理念就是先做,再判断做的对不对。做的对了执行,否则继续尝试执行。以程序的角度来讲,就是一旦一个线程拿锁成功,其他的线程并不进入到阻塞状态,而是频繁的做自旋操作,直到操作成功为止。
这样做的好处也相当明显。就是我们在可以在执行这个任务的时候,其余任务同时保持执行状态。因此对于我们任务而言,就变成了关注结果形式。只要我这个任务搞定,就立刻可以接受下一个任务的执行结果(因为其他的线程也没闲着,一直在重复执行着)。
介绍完CAS后,我们就要思考什么场景下可以使用CAS机制呢?
- 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
可见,我们的JDK开发人员在ConcurrentHashMap1.7的实现中,必要考虑到了减小锁粒度后。对同一个Segment的并发量也会大幅下降,从而采用效率理论上更高的CAS实现锁机制。
rehash扩容操作
这里扩容是对Segment扩容呢?还是对Table数组进行扩容呢?
答案自然是Table数组。我们之前详细说了Segment的设置只能通过ConcurrentHashMap的构造函数来设置。因为要考虑到数据量大小,以及机器的性能方面的问题,因此需要我们人为的去设定。一般不设置就是16。(从这里我们也能发现,ConcurrentHashMap1.7的并发度设置的比较死)
哦~玩来玩去,半天还是玩一群加了锁的HashMap呗?
于是再次继续执行走我们的HashMap扩容老套路:
- 生成一个比当前数组长一倍的新数组(2的次幂)。
- 遍历原来的旧的table数组,把每一个数组元素也就是Node链表迁移到新的数组里面。
- 迁移完毕之后,把新数组的引用直接替换旧的。
为了避免让所有的节点都进行复制操作:由于扩容是基于2的次幂来操作。假设扩容前HashEntry对应到Segment中的数组为i,数组的容量为capacity,那么扩容后该HashEntry对应到新数组中的index只可能是i或者i+capacity。因此很多HashEntry节点在扩容前后index可以保持不变。
假设原来table长度为4,那么元素在table中的分布是这样的。
扩容后table长度变为了8,元素在table的分布变成了
可以看到hash值为34和56的下标保持不变。而15,23,77的下标都是在原来的下标基础上+4即可。可以快速定位和减少重排次数。
整个rehash过程是不需要加锁的,为啥?因为锁早早加载了Segment中,只要能进Segment里面来,那必然只有一个线程。自然是想咋玩儿咋玩儿了,也不用考虑那个头疼的HashMap死循环问题了。
put操作流程
流程如下:
- 为输入的Key做Hash运算,得到hash值。
- 通过hash值,定位到对应的Segment对象。
- 判断是否Segment对象是否初始化。若未初始化,获取可重复所先对其进行初始化。
- 初始化成功后,再次获取可重入锁。并再次通过hash值,定位到Segment当中数组的具体位置。
- 插入或覆盖HashEntry对象,若为插入,还需判断是否需要需要扩容。
- 释放锁。
remove操作
与put方法类似,都是在操作前需要拿到锁,以保证操作的线程安全。
ConcurrentHashMap1.8的再次升级,"分段锁"的二次进化
我们都知道,一个技术的发展,往往是建立在原先技术的基础之上。当我们的ConcurrentHashMap1.8闪亮登场时,我们却发现,好像这次的衍变不是建立在1.7版本之上,而是重新从最原始的HashMap的基础上重新优化,直接在每个Table数组上加了把锁。
改进1:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table来保存数据(取消了原先设计的segment,再次恢复到只需要维护一个Hash表的状态)。但采用了对数组元素作为锁,从而实现缩小锁的粒度,进一步减少并发冲突的概率,并大量使用了采用CAS+synchronized来保证并发安全性。
改进2:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。
红黑树的优化意义
对于hash算法来说,最核心的能力在于将key在 hash之后能均匀的分布在数组中。
如果hash之后的散列很均匀,那么table数组中的每个队列的长度主要为0或1.但是实际情况并非总是如此理想。虽然,ConcurrentHashMap类默认的加载因子为0.75,但是在数据量过大或者运气不佳的情况下,还会存在一些队列长度过长的情况。如果还是采用单向列表的方式,那么查询某个节点的时间复杂度为O(n)。因此,对于个数超过8(默认值)的列表,jdk1.8中采用了红黑树的结构。那么查询的时间复杂度可以降低到O(logN),可以改进性能。
使用Node(1.7位Entry)作为链表的数据节点,仍然包含key,value,hash和next四个属性。当由链表转换为红黑树时,节点类型也由Node改为了TreeNode(extends Node)。
根据数组元素中,第一个节点数据类型是Node还是TreeNode可以判断该位置下是链表还是红黑树。
用于判是否需要将链表转换为红黑树的阈值
用于判断是否需要将红黑树转换为链表的阈值
继续从源码的进行分析
链表Node
Node是最核心的内部类,它包装了key-value键值对。
定义基本和1.7中的HashEntry相同。而这个map本身锁持有的也是一个Node型的数组
table数组下又挂着一组Node链表。
链表长度超过8后的TreeNode
与1.8中的HashMap不同:
1. 它并不是直接转换为红黑树,而是把这些节点放在TreeBin对象中,由TreeBin完成对红黑树的包装。
TreeNode在ConcurrentHashMap1.8扩展自Node类,而并非HashMap中的扩展自LinkedHashMap.Entry<K,V>类,也就是说TreeNode带有next指针。
TreeBin——TreeNode的包装类
实际上,我们的table数组中,存放的仅仅是一个TreeBin对象,TreeBin对象中又包含了整棵TreeNode红黑树。我们来看一些TreeBin类的成员结构。
同时,这个类还带有读写锁机制。
扩容空节点占位符ForwordingNode
一个特殊的Node节点,hash值为-1。其中存储nextTable的引用。有table发生扩容的时候,ForwardingNode发挥作用,作为一个占位符放在table中表示当前节点为null或者已经被移除。(在图中已标明)
扩容并发数sizeCtl
用来控制table的初始化和扩容操作。
1. -1代表正在进行初始化· ·
2. -N表示有N-1个线程正在进行扩容操作(JDK1.8并发扩容)
0为默认值,表示当时的table还没有被初始化。正数表示初始化大小或ConcurrentHashMap中的元素达到这个数量时,需要进行扩容了。
对比ConcurrentHashMap1.7构造方法
不得不说,延迟加载的概念真的是深入所有HashMap的骨子里。再ConcurrentHashMap1.7在构造方法中,根据concurrencyLevel定义Segement的个数。现在ConcurrentHashMap1.8中,Segement这个东西被取消了。于是现在延迟加载的实际再次变得和HashMap一样,都放到向ConcurrentHashMap插入元素的时候。如:调用put,computIfAbsent,compute,merge等方法的时候,调用时检查table==null。
get操作
get操作流程
- 首先计算hash值,定位到该table索引位置。
- 如果首节点就是我们要找的Node,就直接返回。
- 如果红黑树,就调用find方法去查询相应的Node
- 如果是链表,就一次遍历查询相应的Node。
也不用加锁
当然,与1.7一样,1.8在get数据时依旧不需要加锁,均使用volatile来保证数据的一致性。
put操作
put操作流程
- 判断table数组是否初始化,未初始化先进行初始化。
- 计算hash值,定位到该table索引位置。
- 判断是否进行扩容操作,如果正在扩容则协助扩容。
- 若是正常插入操作,使用Synchronized获取该table的锁。
- 根据不同的数据结构进行插入。
- 判断是否需要进行扩容操作
重量级加锁形式
诶?有读者读到这里懵逼了。
博主博主?你不是说,对于资源竞争较少(线程冲突较轻)的情况下,使用CAS的方式效率会更高嘛,为啥又改成Synchronized这个笨笨的锁呢?
这里,博主还要再介绍一下Synchronized锁膨胀的过程。
在Jdk1.5之后,重量级锁已经不再是天生的胖子了。而是一个慢慢膨胀最后变成胖子的胖子。其膨胀过程分别是 偏向锁 轻量级锁(自旋锁) 重量级锁。
这里的细节将在其他文章中继续详细介绍。我们只需要知道在并发度极小的情况下,我们的Synchronized会做一个类似延迟变胖的过程。
ConcurrentHashMap1.8将锁的粒度再次缩小,那么出现并发争抢的可能性还高吗?还有就是,哪怕出现争抢了,只要线程可以在30到50次自旋里拿到锁,那么Synchronized就不会升级为重量级锁,而等待的线程也就不用被挂起,我们也就少了挂起和唤醒这个上下文切换的过程开销。
所以,在锁被细化到如此程度上,使用Synchronized反而再次成为了最好的选择。
初始化操作
前面说过,构造方法中并没有真正的初始化。初始化是放在了向ConcurrentHashMap中插入元素的时候发生的。具体实现为iniTable。
我们发现,在对table进行初始化的时候,ConcurrentHashMap1.8再次选择了使用CAS的方式,让一个线程对其初始化。因此才有了ConcurrentHashMap1.8是基于Synchronized+CAS+数组+链表+红黑树的大杂烩之——超高效率切线程安全的Map。
transfer扩容操作
当ConcurrentHashMap容量不足的时候,需要对table进行扩容。这个方法的基本思路和HashMap很像。但是由于它是支持并发扩容的,所以复杂很多。
为何要并发扩容?因为扩容的时候,总是会涉及到从一个”数组”到另一个数组的拷贝操作。如果这个而操作能够并发进行,就能利用并发处理去减少扩容带来的时间影响。
整个扩容分为两部分:
第一个部分是构建一个nextTable,它的容量是原来的2倍(2的次幂)。
第二部分就是将原来table中的元素复制到nextTable中。这里允许多线程进行操作。
整个扩容流程就是遍历和复制:
为null或者已经移除过的节点,将会被设置为forwardNode节点。当线程准备扩容时,发现节点是forwardNode节点,跳过这个节点,继续寻找未处理的节点。找到了,对节点上锁。
如果这个节点位置是Node节点(fh>=0),说明是一个链表。就构造一个反序链表。把他们分别放在nextTable的i和i+n的位置上。
如果这个位置是TreeBin节点(fh>0,也做一个反序处理,并且判断是否需要红黑树转链表。把处理的结果分别放在nextTable的i和i+n的位置上。
遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并更新sizeCtl为新容量的0.75倍,完成扩容。
并发扩容其实就是数据迁移任务拆分成多个小迁移任务。在实现上使用了一个变量stride作为步长控制。每个线程只负责迁移其中的一部分。
Remove
移除方法的基本流程和put很类似。只不过操作由插入数据变为移除数据而已。而且如果存在红黑树的情况下,会检查是否需要将红黑树转为链表。其余不多重复。
ConcurrentHashMap的弱一致性(哪个版本都弱)
对链表遍历判断是否存在key相同的节点,以及获得该节点的value。但由于遍历过程中,其他线程可能对链表结构做出了调整。因此get和containsKey返回的可能是过时的数据。(get到的不一定是最新put的数据)这一点是ConcurrentHashMap在弱一致性上的体现。如果要求强制一致性,那么需要使用Collections.synchronizedMap()。
同理,在ConcurrentHashMap中使用size方法计算的长度,估摸着也不咋地准。但是在高并发环境下,本身计算总数也是一个没什么意义的事。但是该值对数据量依旧可以取个大概,例如统计直播间人数之类对数据没有强一致性要求的场景依旧有着用武之地。
总结
之前说,ConcurrentHashMap1.8完全没有借鉴ConcurrentHashMap1.7。但那只是形式上的不一致。在理念上完全是再一次的升级。
1.7中的ConcurrentHashMap使用了分段锁的思想,要想实现更好的并发性就是继续减小索粒度。在1.8中的ConcurrentHashMap采用table数组元素作为锁,从而实现了更小的索力度,进一步减少并发冲突概率。
这就让我感觉很多技术思想都是相通的,Hashtable,ConcurrentHashMap1.7到1.8很容易类比到MySQL的表锁,分段锁和行锁。都是尽可能的减小锁粒度从而提高并发性能。