currentHashMap的介绍
ConcurrentHashMap是线程安全并且高效的一种容器,我们就需要研究一下ConcurrentHashMap为什么既能够保证线程安全,又可以保证高效的操作。
为什么使用ConcurrentHashMap,我们就需要和HashMap以及HashTable进行比较?
HashMap是线程不安全的,在多线程的情况下,HashMap的操作会引起死循环,导致CPU的占有量达到100%,所以在并发的情况下,我们不会使用HashMap。
死锁原因
在 HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。
HashTable其中使用synchronize来保证线程安全,即当有一个线程拥有锁的时候,其他的线程都会进入阻塞或者轮询状态,这样会使得效率越来越低。
而ConcurrentHashMapMap的锁分段技术可以有效的提高并发访问率
HashTable访问效率低下的原因,就是因为所有的线程在竞争同一把锁。
如果容器中有多把锁,不同的锁锁定不同的位置,这样线程间就不会存在锁的竞争,这样就可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术将数据一段一段的存储,为每一段都配一把锁,当一个线程只是占用其中的一个数据段时,其他段的数据也能被其他线程访问。
jdk7 的currentHashMap
在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。
采用Segment(分段锁)来减少锁的粒度,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
currentHashMap内部结构
ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。如下图是ConcurrentHashMap的内部结构图:
Segment默认是16,按理说最多同时支持16个线程并发读写,但是是操作不同的Segment,初始化时也可以指定Segment数量,每一个Segment都会有一把锁,保证线程安全。
该结构的优劣势
>坏处是这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长。
好处是写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。
所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。
put操作
对于ConcurrentHashMap的数据插入,这里要进行两次Hash去定位数据的存储位置。
Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置。
get操作
ConcurrentHashMap的get操作跟HashMap类似,只是ConcurrentHashMap第一次需要经过一次hash定位到Segment的位置,然后再hash定位到指定的HashEntry,遍历该HashEntry下的链表进行对比,成功就返回,不成功就返回null
为什么get不加锁可以保证线程安全
首先获取value,我们要先定位到segment,使用了UNSAFE的getObjectVolatile具有读的volatile语义,也就表示在多线程情况下,我们依旧能获取最新的segment.
获取hashentry[],由于table是每个segment内部的成员变量,使用volatile修饰的,所以我们也能获取最新的table.
然后我们获取具体的hashentry,也时使用了UNSAFE的getObjectVolatile具有读的volatile语义,然后遍历查找返回.
总结:我们发现整个get过程中使用了大量的volatile关键字,其实就是保证了可见性(加锁也可以,但是降低了性能),get只是读取操作,所以我们只需要保证读取的是最新的数据即可.
size操作
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案
1、第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的
2、第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回(美团面试官的问题,多个线程下如何确定size)
rehash操作
ConcurrentHashMap的扩容仅仅是和每个Segment中的HashEntry数组的长度有关。但需要扩容时,只扩容当前Segment中的HashEntry数组即可。也就ConcurrentHashMap中的Segment数组在初始化的时候就确定了,后面扩容不会改变这个长度。
相比较HashMap的resize操作,ConcurrentHashMap的rehash原理类似。但是对其做了一定优化,避免让所有节点进行计算操作。
由于扩容是基于2的幂指来操作,假设扩容前某HashEntry对应到Segment中数组的index为i,数组的容量为capacity,那么扩容后该HashEntry对应到新数组中的index只可能为i或者i+capacity,因此大多数HashEntry节点在扩容前后index可以保持部件。基于此,rehash()方法中会定位第一个后续所有节点在扩容后idnex都保持不变的节点,然后将这个节点之前的所有节点重排即可。
JDK1.8的currentHashMap
JDK1.8的currentHashMap参考了1.8HashMap的实现方式,采用了数组+链表+红黑树的实现方式,其中大量的使用CAS操作.CAS(compare and swap)的缩写,也就是我们说的比较交换。
CAS是一种基于锁的操作,而且是乐观锁。java的锁中分为乐观锁和悲观锁。悲观锁是指将资源锁住,等待当前占用锁的线程释放掉锁,另一个线程才能够获取线程.乐观锁是通过某种方式不加锁,比如说添加version字段来获取数据。
CAS操作包含三个操作数(内存位置,预期的原值,和新值)。如果内存的值和预期的原值是一致的,那么就转化为新值。CAS是通过不断的循环来获取新值的,如果线程中的值被另一个线程修改了,那么A线程就需要自旋,到下次循环才有可能执行。
JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。
Java8的ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。在JDK8中ConcurrentHashMap的结构,由于引入了红黑树,使得ConcurrentHashMap的实现非常复杂。
我们都知道,红黑树是一种性能非常好的二叉查找树,其查找性能为O(logN),早期完全采用链表结构时Map的查找时间复杂度为O(N),JDK8中ConcurrentHashMap在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。
put操作
如果没有初始化就先调用initTable()方法对其初始化;
对key进行hash计算,求得值没有哈希冲突的话,则利用自旋CAS操作来进行插入数据;
如果存在hash冲突,那么就加synchronized锁来保证线程安全
如果存在扩容,那么就去协助扩容
加完数据之后,再判断是否还需要扩容
get操作
据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
如果正在扩容,且当前节点已经扩容完成,那么根据ForwardingNode查找扩容后的table上的对应数据
如果是红黑树那就按照树的方式获取值。如果不满足那就按照链表的方式遍历获取值。
size操作
在JDK1.8版本中,对于size的计算,在扩容和addCount()方法就已经有处理了,可以注意一下Put函数,里面就有addCount()函数,早就计算好的,然后你size的时候直接给你。JDK1.7是在调用size()方法才去计算,其实在并发集合中去计算size是没有多大的意义的,因为size是实时在变的,只能计算某一刻的大小,但是某一刻太快了,人的感知是一个时间段,所以并不是很精确
transfer扩容
1.8版本的扩容比较复杂,具体可以看哲这篇的解析ConcurrentHashMap 成员、方法分析
helpTransfer 会在put,remove,get时发现当前槽的头节点为MOVE状态时 也就是已经转换为ForwardingNode,代表当前节点已经转移完毕,整个ConcurrentHashMap还正在扩容,说明整个concurrenHashMap正在扩容。那么进入helpTransfer方法,协助进行扩容,直到扩容完成,那么如果当前需要操作的节点还不是ForwardingNode即还没有完成扩容操作,那么会直接使用源tab,进行操作,对于写操作,也就是说,扩容期间,除了锁住头节点的槽,和已经扩容完成的节点,其他节点依然正常读写。不会因为访问这些节点进入协助扩容!,可见ConcurrentHashMap对锁粒度的控制十分细。
看完了整个 HashMap 和 ConcurrentHashMap 在 1.7 和 1.8 中不同的实现方式相信大家对他们的理解应该会更加到位。其实这块也是面试的重点内容,通常的套路是:
谈谈你理解的 HashMap,讲讲其中的 get put 过程。
>hashmapPut方法的过程
1、通过key 调用hashcode 得出 hash 值 ;
2、判断map是否为null 或只长度是否为0,如果为真,则 reszie() 数组 ;
3、根据hash[int]值 与 数组长度 -1 的值 进行 & 运算, 定位到 key 所在位置 进行判null
4、如果为null,则直接在此位置插入数据;
5、如果不为null,判读 key所在位置的对象的和hash 值 和要插入的hash 值 是否相等,并且判断key是否相等(== 和equals);
5-1、为真, 则直接返回新插入的对象数据;
5-2、继续判断插入的位置是否为[treeNode]树状链表,为真,则调用插入的相关方法(putTreeVal),此处数据结构个人理解为红黑树;
5-3、如果不为树状链表,则进行循环比较,从链表的头开始进行逐个比较;
5-3-1、如果链表头的 next 为null,则直接插入;此时如果链表长度大于等于7,则转为树状链表(调用 treeifyBin()方法)并调出循环;
5-3-2、如果链表中的数据对象和插入的数据对象的hash值相等,且key相等(== 和equals),则直接调出循环;
6、如果插入的对象已经存在,只有当key 对应的值为null 的时候进行赋值,并返回插入对象的value;
7、记录修改次数 ++ ;
8、判断map此时的size+1 后 是否大于阀值,大于则进行 resize() 扩容;
9、移除map 之前的数据
>get方法的过程
get 方法:
1、通过key 调用hashcode 得出 hash 值 ;
2、根据hash值与 数组长度 -1 的值 进行 & 运算, 定位到 key 所在位置
3、如果链表头的hash值和key 相等(== 和equals),则直接返回该值;
4、如果链表中有多个,则判断链表数据结构
4-1、如果为树状链表,则调用方法getTreeNode() 获取对应值并返回;
4-2、如果为 链表,则从链表头开始遍历 获取对应的值 并返回
1.8 做了什么优化?
头插法变为尾插法避免死循环
存储结构新增红黑树
hash函数
扩容时计算数组元素下标的算法
优化点
是线程安全的嘛?
hashmap不是线程安全的
不安全会导致哪些问题?
(1)死循环
这个是最常在面试中问到的问题,然而其实这个问题已经在java1.8版本被修复了,只在1.7版本之前存在这个问题。
大致原因是在HashMap扩容的时候链表采用了头插法会使链表反序,两个线程同时扩容的话,在某种场景下会出现循环链表导致死循环
(2) 多线程哈希冲突导致覆盖,也就是 多线程put导致元素丢失
(3)put和get并发时,可能导致get为null
如何解决?有没有线程安全的并发容器?
可以使用ConcurrentHashMap和hashtable
ConcurrentHashMap 是如何实现的?1.7、1.8 实现有何不同?为什么这么做?
1.7和1.8区别
JDK1.7版本:ReentrantLock+Segment+HashEntry
JDK1.8版本:synchronized+CAS+HashEntry+红黑树
1.JDK1.8降低锁的粒度,JDK1.7锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry
2.JDK1.8使用红黑树来优化链表
3.JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock
因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了。
synchronized之前一直都是重量级的锁,但是后来java官方是对他进`行过升级的,他现在采用的是锁升级的方式去做的。
针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式,就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为 CAS 轻量级锁,如果失败就会短暂自旋,防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
所以是一步步升级上去的,最初也是通过很多轻量级的方式锁定的。
ConcurrentHashMap 与HashMap和Hashtable 最大的不同在于:put和 get 两次Hash到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的Entry,然后在遍历entry链表
总结
HashMap 是一种散列表的数据结构,底层采用数组 + 链表 + 红黑树来实现存储。
HashMap 默认容量为 16(1 << 4),每次超过阀值时,按照两倍大小进行自动扩容,所以容量总是 2^N 次方。并且,底层的 table 数组是延迟初始化,在首次添加 key-value 键值对才进行初始化。
HashMap 默认加载因子是 0.75 ,如果我们已知 HashMap 的大小,需要正确设置容量和加载因子。
HashMap 每个槽位在满足如下两个条件时,可以进行树化成红黑树,避免槽位是链表数据结构时,链表过长,导致查找性能过慢。
条件一,HashMap 的 table 数组大于等于 64 。
条件二,槽位链表长度大于等于 8 时。选择 8 作为阀值的原因是,参考 泊松概率函数(Poisson distribution) ,概率不足千万分之一。
在槽位的红黑树的节点数量小于等于 6 时,会退化回链表。
HashMap 的查找和添加 key-value 键值对的平均时间复杂度为 O(1) 。
对于槽位是链表的节点,平均时间复杂度为 O(k) 。其中 k 为链表长度。
对于槽位是红黑树的节点,平均时间复杂度为 O(logk) 。其中 k 为红黑树节点数量。