谈到HashMap和ConcurrentHashMap,必然会联想到一些其他集合结构,比如HashTable,Vector等,先理一下他们的区别吧。其实HashTable和Vector已经被废弃了,HashTable和Vector以及ConcurrentHashMap都是线程安全的同步结构,区别是HashTable和Vector是采用synchronized关键字对整个集合对象加锁,效率低下。而ConcurrentHashMap则是采用分段加锁的方式细化了锁的颗粒度,由于高效率所以替代了前两者。但是HashMap是线程不安全的,至于为什么线程不安全下文会有详解,先比较一下两个版本中HashMap和ConcurrentHashMap有什么改变。
ConcurrentHashMap的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响,无论对于Java并发编程的学习还是Java内存模型的理解,ConcurrentHashMap的设计以及源码都值得非常仔细的阅读与揣摩。
1、HashMap随JDK的变化
1.1 JDK1.7
HashMap本质上是一个Entry数组,Entry中即为键值对,Entry数组在table中的位置是这么计算出来的:
int hash = hash(key.hashCode());
int index = indexFor(hash,table.length);
大致是根据key对象的hashcode值,即key对象的内存地址值,通过hash算法得到一个hash值,这里很清晰的可以看出hashcode值和hash值是两个东西两个概念不要混淆。得到hash值后,再结合数组table的长度计算出数组下标,这里贴出两个算法内容,即hash算法hash()以及位置算法indexFor()。
static final hash(Object key){
int h;
return (key == null)?0:(h = key.hashcode()^(h >> 16));
}
static int indexFor(int h,int length){
return h & (length - 1);
}
这里有一个设计很巧妙的地方,关于为什么HashMap的长度包括扩容后的长度都是2的次方这个问题?
这个indexFor方法逻辑给出了答案。因为2的次方减一即(length - 1)得到的数其二进制样式差不多,前面有多少个0不一定,但后面一定是连续的1。
比如(2-1)的二进制是000001,(2的三次方-1)的二进制是000111。而&(与运算)原则是有0就是0,都是1才是1。想象一下,比如000111这种二进制参与与运算,前三位即0的位置不管另一个二进制上对应位是什么都是0,即一种情况,而后三位即1的那位得看另一个二进制对应位置上是什么,即两种情况。很好理解,1越多,情况越多,即求出来的数组下标可能性越多,即散列越均匀,形成的链表越少,查询效率越高。理解这个小知识点很重要。
如果两个key计算出来的数组下标index相同,那么这两个Entry会在该位置上形成链表,结构可以这么画:
HashMap的get(key)方法就是根据key求出数组下标,还是那一套,如果那个位置没有链表,直接返回value,如果有链表就遍历链表拿到value。这里有个地方容易模糊,哪怕这个key在HashMap中不存在,也是可以根据这个key求出一个数组下标index的,只不过根据这个key拿value的时候为null。
HashMap的put方法就是根据负载因子和数组长度先判断需不需要扩容 ,注意,因为扩容后的table的长度就变了嘛,而每个Entry的数组下标位置和table的长度有关,所以扩容后会重新计算这些Entry的数组下标位置,可以理解为全部重新放了一遍,空间大了整理一下位置,整理好后就可以继续往里面PUT东西了,还是那套,先根据key计算出index值,判断key存在不存在,存在就覆盖不存在就添加。
这里可以再补充一个知识点,关于为什么HashMap不是线程安全的呢?
因为put没有加锁,最终结果是某个位置上的Entry可能形成链表。知道多线程下有这个结果就行,至于为什么会形成链表过程有点复杂暂且不论。如果某个位置上的Entry可能形成链表,get(key)的时候根据这个key计算出来的位置刚好是这个环形链表的位置,更不巧的是没有这个key对应的键值对,那就完了,会一直遍历这个环形链表,因为源码中是遍历到null才推出循环的,环形链表没有null结果就是死循环。这么看来HashMap多线程下造成死循环的条件还是蛮苛刻的嘛。
1.2 JDK1.8
JDK1.8中HashMap的结构发生了变化,即当Entry链表达到了一定的长度,会用红黑树结构代替链表结构,也就是说存在链表结构和红黑树结构同时存在的情况,图可以这么画:
这样就有效解决了Hash冲突问题,其他变化不大。
2、ConcurrentHashMap
2.1 JDK1.7
ConcurrentHashMap能够实现线程安全且高效是因为采用了分段加锁的方式,与HashMap结合起来看,其实就是把一个大table分成了一段一段的Segment,Segment实现了再入锁ReentranLock,即充当了ConcurrentHashMap中锁的角色。Segment中有一个一个的HashEntry键值对有效数据,图可以这么画:
其实可以把Segment理解为一个大table中一个一个的位置,这么一理解ConcurrentHashMap与HashTable最大的区别就是ConcurrentHashMap对大table中每个位置加了锁,而HashMap如果要加锁的话就是对整个table加锁,当然效率就高了。
ConcurrentHashMap的get方法还是那套,根据key找到对应的Segment,再遍历key拿到具体的HashEntry。
ConcurrentHashMap的put方法就显得复杂了,不过大致还是那套,大致是先判断是否需要扩容,扩容整理后根据key找到对应的Segment,再往Segment中put键值对,这个时候put是加锁的,利用自旋锁去尝试获取锁,获取锁后判断key是否存在,存在就覆盖不存在就添加一个键值对。总之就是利用再入锁的方式锁住Segment,保证只有一个线程在操作Segment,这就相当于在HashMap中保证了只有一个线程在数组的一个位置中put,这当然不会形成环形链表了。
ConcurrentHashMap的内部细分了若干个小的HashMap,称之为段(SEGMENT)。ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
ConcurrentHashMap 有 16 个 Segments,所以理论上,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。每个Segment内部更像是一个hashmap。
Segment 内部是由 数组+链表 组成的。 插入的位置是链表表头!
ConcurrentHashMap 初始化的时候会初始化第一个槽 segment[0],对于其他槽来说,在插入第一个值的时候进行初始化。
resize() 该方法不需要考虑并发,因为到这里的时候,是持有该 segment 的独占锁的
get 操作是不加锁的
并发问题分析:
get 过程中是没有加锁的,那自然我们就需要去考虑并发问题。添加节点的操作 put 和删除节点的操作 remove 都是要加 segment 上的独占锁的,所以它们之间自然不会有问题,我们需要考虑的问题就是 get 的时候在同一个 segment 中发生了 put 或 remove 操作。
1. put 操作的线程安全性
初始化Segment,使用了 CAS 来初始化 Segment 中的数组。
添加节点到链表的操作是插入到表头的,所以,如果这个时候 get 操作在链表遍历的过程已经到了中间,是不会影响的。当然,另一个并发问题就是 get 操作在 put 之后,需要保证刚刚插入表头的节点被读取,这个依赖于 setEntryAt 方法中使用的 UNSAFE.putOrderedObject。
扩容是新创建了数组,然后进行迁移数据,最后面将 newTable 设置给属性 table。所以,如果 get 操作此时也在进行,那么也没关系,如果 get 先行,那么就是在旧的 table 上做查询操作;而 put 先行,那么 put 操作的可见性保证就是 table 使用了 volatile 关键字。
2. remove 操作的线程安全性
get 操作需要遍历链表,但是 remove 操作会”破坏”链表。
如果 remove 破坏的节点 get 操作已经过去了,那么这里不存在任何问题。
如果 remove 先破坏了一个节点,分两种情况考虑。 1、如果此节点是头结点,那么需要将头结点的 next 设置为数组该位置的元素,table 虽然使用了 volatile 修饰,但是 volatile 并不能提供数组内部操作的可见性保证,所以源码中使用了 UNSAFE 来操作数组,请看方法 setEntryAt。2、如果要删除的节点不是头结点,它会将要删除节点的后继节点接到前驱节点中,这里的并发保证就是 next 属性是 volatile 的。
2.2 JDK1.8
JDK1.8中变化较大,首先取消了Segment,理解一下第一层直接就是HashEntry。还有就是当链表达到一定长度的时候会以红黑树的形式代替,这个和HashMap一样嘛,图可以这么画:
put的时候采用了CAS+synchronized保证线程安全,get就还是那样,读不影响线程安全,所以变化不大。
如果是在链表中插入,则插入的位置是在链表尾。
3、区别
JDK1.8 和 JDK1.7的几个区别:
数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。
定位结点的hash算法简化,会带来弊端:Hash冲突加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。
插入节点:JDK1.7是链表头,JDK1.8是链表尾。
ConCurrentHashMap 1.8 相比 1.7的话,主要改变为:
-
去除
Segment + HashEntry + Unsafe
的实现,
改为Synchronized + CAS + Node + Unsafe
的实现
其实 Node 和 HashEntry 的内容一样,但是HashEntry是一个内部类。
用 Synchronized + CAS 代替 Segment ,这样锁的粒度更小了,并且不是每次都要加锁了,CAS尝试失败了在加锁。 -
put()方法中 初始化数组大小时,1.8不用加锁,因为用了个
sizeCtl
变量,将这个变量置为-1,就表明table正在初始化。
底层数据结构区别:
<jdk1.7>:数组(Segment) + 数组(HashEntry) + 链表(HashEntry节点)
Node数组+链表 / 红黑树: 类似hashMap<jdk1.8>
4、具体方法的区别
(1) put
JDK1.7:
-
需要定位 2 次 (segments[i],segment[i] 中的 table[j] )
由于引入segment的概念,所以需要
- 先通过key的
rehash值的高位
和segments数组大小-1
相与得到在 segments中的位置 - 然后在通过
key的rehash值
和table数组大小-1
相与得到在table中的位置
-
没获取到 segment锁的线程,没有权力进行put操作,不是像HashTable一样去挂起等待,而是会去做一下put操作前的准备:
- table[i]的位置(你的值要put到哪个桶中)
- 通过首节点first遍历链表找有没有相同key
- 在进行1、2的期间还不断自旋获取锁,超过
64次
线程挂起!
JDK1.8:
- 先拿到根据
rehash值
定位,拿到table[i]的首节点first
,然后:
- 如果为
null
,通过CAS
的方式把 value put进去 - 如果
非null
,并且first.hash == -1
,说明其他线程在扩容,参与一起扩容 - 如果
非null
,并且first.hash != -1
,Synchronized锁住 first节点,判断是链表还是红黑树,遍历插入。
(2)get
JDK1.7:
- get操作实现非常简单和高效。先经过一次再哈希,然后使用这个哈希值通过哈希运算定位到segment,再通过哈希算法定位到元素
- get方法里将要使用的共享变量都定义成volatile,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value,所以并不用加锁,valatile保证了共享变量的可见性,所以支持多线程读,但是只支持单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值)
- 由于变量
value
是由volatile
修饰的,java内存模型中的happen before
规则保证了 对于 volatile 修饰的变量始终是写操作
先于读操作
的,并且还有 volatile 的内存可见性
保证修改完的数据可以马上更新到主存中,所以能保证在并发情况下,读出来的数据是最新的数据。
JDK1.8:
- 1.计算hash值,定位到该table索引位置,如果是首节点符合就返回
- 2.如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点, 匹配就返回
- 3.以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
- 和JDK1.7一样,value是用volatile修饰的,所以get操作不用加锁。
(3)resize
JDK1.7:
跟HashMap的 resize() 没太大区别,都是在 put() 元素时去做的扩容,所以在1.7中的实现是获得了锁之后,在单线程中去做扩容(1.new个2倍数组
2.遍历old数组节点搬去新数组
)。
JDK1.8:
jdk1.8的扩容支持并发迁移节点,从old数组的尾部开始,如果该桶被其他线程处理过了,就创建一个 ForwardingNode 放到该桶的首节点,hash值为-1,其他线程判断hash值为-1后就知道该桶被处理过了。
(4)size
JDK1.7:
- 先采用不加锁的方式,计算两次,如果两次结果一样,说明是正确的,返回。
- 如果两次结果不一样,则把所有 segment 锁住,重新计算所有 segment的
Count
的和
JDK1.8:
由于没有segment的概念,所以只需要用一个 baseCount
变量来记录ConcurrentHashMap 当前 节点的个数
。
- 先尝试通过CAS 修改
baseCount
- 如果多线程竞争激烈,某些线程CAS失败,那就CAS尝试将
CELLSBUSY
置1,成功则可以把baseCount变化的次数
暂存到一个数组counterCells
里,后续数组counterCells
的值会加到baseCount
中。 - 如果
CELLSBUSY
置1失败又会反复进行CASbaseCount
和 CAScounterCells
数组
6、JDK1.8相对于JDK1.7的进步点
其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。
主要设计上的变化有以下几点:
- 不采用segment而采用node,锁住node来实现减小锁粒度。
- 设计了MOVED状态 当resize的中过程中 线程2还在put数据,线程2会帮助resize。
- 使用3个CAS操作来确保node的一些操作的原子性,这种方式代替了锁。
- sizeCtl的不同值来代表不同含义,起到了控制的作用。
意思同下:
- JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据
参考文章:
ConcurrentHashMap总结(包括JDK 1.7和1.8的实现比较) https://blog.csdn.net/bolang789/article/details/79855053
ConcurrentHashMap的JDK1.7和JDK1.8的实现 https://blog.csdn.net/qq_27139155/article/details/80513419
ConCurrentHashMap JDK1.7 和 JDK1.8 的区别 https://blog.csdn.net/pange1991/article/details/85127013
高并发编程系列:ConcurrentHashMap的实现原理(JDK1.7和JDK1.8) https://baijiahao.baidu.com/s?id=1617089947709260129&wfr=spider&for=pc
ConcurrentHashMap在jdk1.7和jdk1.8中的不同 https://blog.csdn.net/qq_41884976/article/details/89532816
ConcurrentHashMap JDK1.7和JDK1.8区别 https://blog.csdn.net/houguofei123/article/details/81484233