concurrenthashmap加锁机制,concurrenthashmap get加锁吗

http://www.itjxue.com  2023-01-09 00:18  来源:未知  点击次数: 

ConcurrentHashMap

HashMap是我们用得非常频繁的一个集合,但是它是线程不安全的。并且在多线程环境下,put操作是有可能产生死循环,不过在JDK1.8的版本中更换了数据插入的顺序,已经解决了这个问题。

为了解决该问题,提供了Hashtable和Collections.synchronizedMap(hashMap)两种解决方案,但是这两种方案都是对读写加锁,独占式。一个线程在读时其他线程必须等待,吞吐量较低,性能较为低下。而J.U.C给我们提供了高性能的线程安全HashMap:ConcurrentHashMap。

在1.8版本以前,ConcurrentHashMap采用分段锁的概念,使锁更加细化,但是1.8已经改变了这种思路,而是利用CAS+Synchronized来保证并发更新的安全,当然底层采用数组+链表+红黑树的存储结构。

HashMap 是最简单的,它不支持并发操作,下面这张图是 HashMap 的结构:

HashMap 里面是一个数组,然后数组中每个元素是一个单向链表。每个绿色的实体是嵌套类 Entry 的实例,Entry 包含四个属性:key, value, hash 值和用于单向链表的 next。

public HashMap(int initialCapacity, float loadFactor) 初始化方法的参数说明:

put 过程

get过程

ConcurrentHashMap 和 HashMap 思路是差不多的,但是因为它支持并发操作,所以要复杂一些。

整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多人都会将其描述为分段锁。简单的说,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的。

再具体到每个 Segment 内部,其实每个 Segment 很像之前介绍的 HashMap,每次操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的。

初始化

public ConcurrentHashMap(int initialCapacity, float loadFactor, int concurrencyLevel) 初始化方法

举个简单的例子:

用 new ConcurrentHashMap() 无参构造函数进行初始化的,那么初始化完成后:

put过程

get过程

Java8 对 HashMap 进行了一些修改,最大的不同就是利用了红黑树,所以其由 数组+链表+红黑树 组成。

根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度。

为了降低这部分的开销,在 Java8 中,当链表中的元素超过了 8 个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度。

jdk7 中使用 Entry 来代表每个 HashMap 中的数据节点,jdk8 中使用 Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。

我们根据数组元素中,第一个节点数据类型是 Node 还是 TreeNode 来判断该位置下是链表还是红黑树的。

put过程

和jdk7的put差不多

get 过程分析

Java7 中实现的 ConcurrentHashMap 还是比较复杂的,Java8 对 ConcurrentHashMap 进行了比较大的改动。可以参考 Java8 中 HashMap 相对于 Java7 HashMap 的改动,对于 ConcurrentHashMap,Java8 也引入了红黑树。

在1.8版本以前,ConcurrentHashMap采用分段锁的概念,使锁更加细化,但是1.8已经改变了这种思路,而是利用CAS+Synchronized来保证并发更新的安全,底层采用数组+链表+红黑树的存储结构。

ConcurrentHashMap通常只被看做并发效率更高的Map,用来替换其他线程安全的Map容器,比如Hashtable和Collections.synchronizedMap。线程安全的容器,特别是Map,很多情况下一个业务中涉及容器的操作有多个,即复合操作,而在并发执行时,线程安全的容器只能保证自身的数据不被破坏,和数据在多个线程间是可见的,但无法保证业务的行为是否正确。

ConcurrentHashMap总结:

案例2:业务操作的线程安全不能保证

案例3:多线程删除

12.7 对比Hashtable

Hashtable和ConcurrentHashMap的不同点:

Hashtable对get,put,remove都使用了同步操作,它的同步级别是正对Hashtable来进行同步的,也就是说如果有线程正在遍历集合,其他的线程就暂时不能使用该集合了,这样无疑就很容易对性能和吞吐量造成影响,从而形成单点。而ConcurrentHashMap则不同,它只对put,remove操作使用了同步操作,get操作并不影响。

Hashtable在遍历的时候,如果其他线程,包括本线程对Hashtable进行了put,remove等更新操作的话,就会抛出ConcurrentModificationException异常,但如果使用ConcurrentHashMap的话,就不用考虑这方面的问题了

ConcurrentHashMap简介

先贴出一张图表示对ConCurrentHashMap的理解

HashMap: HashEntry数组

HashEntry :注意 value 以及 next域都用volatile修饰,保证数据安全。

Segments :并发的最小单元,ConcurrentHashMap与Hashtable不同的是,ConcurrenHashMap是分段加锁,而Hashtable则是整个对象加锁。从加锁的方式开看,ConcurrentHashMap效率相对来说高一点。每个Segments都是一个小型的HashMap。

Segment 继承了可重入锁,提升并发操作的效率。

ConcurrentHashMap数据插入:

这只是读源码笔记,主要将自己的感受记下来,写的不好的地方请大家原谅。如果对您有帮助那是莫大的荣幸了,同时想说 纸上读来终觉浅 ,感兴趣的同学可以翻看一下源码,会有意想不到的收获。

一图了解ConcurrentHashMap底层原理

1、ConcurrentHashMap底层数据结构是一个数组table

2、table数组上挂着单向链表或红黑树

3、new ConcurrentHashMap();如果没有指定长度的话,默认是16,并且数组长度必须是2的n次幂,若自定义初始化的长度不是2的n次幂,那么在初始化数组时,会吧数组长度设置为大于自定义长度的最近的2的n次幂。(如:自定义长度为7,那么实际初始化数组后的长度为8)

4、底层是使用synchronized作为同步锁,并且锁的粒度是数组的具体索引位(有些人称之为分段锁)。

5、Node节点,hash0,当hash冲突时,会形成一个单向链表挂在数组上。

6、ForwardindNode,继承至Node,其hash=-1,表示当前索引位的数据已经被迁移到新数组上了

7、ReservationNode,继承至Node,其hash=-3,表示当前索引位被占用了(compute方法)

8、TreenBin,继承至Node,其hash=-2,代表当前索引下挂着一颗红黑树

9、lockState为ConcurrentHashMap底层自己实现的基于cas的读写锁,锁粒度是具体的某颗树。当向红黑树进行增,删操作时,首先会先上sync同步锁,然后自平衡的时候会上lockState写锁,当读红黑树的时候,会上lockState读锁,然后判断此时是否有线程正持有写锁,或是否有线程正在等待获取写锁,若有,则读线程会直接去读双向链表,而不是去读红黑树。

10、TreeNode,hash0,为红黑树具体节点。TreeBin的root代表红黑树的根节点,first代表双向链表的第一个节点

11、双向链表:构建红黑树时还维护了一个双向链表,其目的为:(1)当并发写读同一颗红黑树的时候,读线程判断到有线程正持有写锁,那么会跑去读取双向链表,避免因为并发写读导致读线程等待或阻塞。(2)当扩容的时候,会去遍历双向链表,重新计算节点hash,根据新的hash判断放在新数组的高位还是地位

1、触发扩容的规则:

1)添加完元素后,若判断到当前容器元素个数达到了扩容的阈值(数组长度*0.75),则会触发扩容

2)添加完元素后,所在的单向链表长度=8,则会转为红黑树,不过在转红黑树之前会判断数组长度是否小于64,若小于64则会触发扩容

2、table为扩容前的数组,nextTable为扩容中创建的新数组,迁移数据完毕后需要将nextTable赋值给table

3、扩容后的数组是元素组长度的2倍

4、ln,hn分别表示高位和低位的链表(高链,低链)。即,扩容时,会用nh==0来判断元素应该放在新数组的i位置还是i+n位置。n:元素组长度;h:元素hash值;i:元素所在的原数组索引位;。这样就会出现有些元素会被挂在低位,有些元素会被挂在高位,从而达到打散元素的目的。

5、红黑树扩容时,会遍历双向链表,并且计算nh==0来判断元素放在低位(lo)还是高位(ho),确定完元素的去处之后,会判断分别判断两个双向链表(lo,ho)的长度是否大于6,若大于6则将还是以一颗红黑树的结构挂在数组上,若=6的话,则转为单向链表,挂在数组上

ConcurrentHashMap原理和使用

ConcurrentHashMap是线程安全的,使用环境大多在多线程环境下,在高并发情况下保证数据的可见性和一致性。

HashMap是一种键值对的数据存储容器,在JDK1.7中使用的是数组+链表的存储结构,在JDK1.8使用的是数组+链表+红黑树的存储结构,关于HashMap的实现原理可以查看 《hashMap实现原理》 。

HashMap是线程不安全的,主要体现在多线程环境下,容器扩容的时候会造成环形链表的情况,关于hashMap线程不安全原因可以查看 《HashMap线程不安全原因》

HashTable也是一种线程安全的键值对容器,我们一般都是听说过,具体使用较少,为什么线程安全的HashTable在多线程环境下使用较少,主要原因在于其效率较低(虽然效率低,但是很安全在多线程环境下)。

下面来分析一下HashTable为什么线程安全,但是效率较低的原因?

查看HashTable类,我们发现在源码中所有的方法都加上了synchronized同步关键字,这也就保证的在多线程环境下线程安全,由于所有的方法都加上了synchronized,这也就导致在一个线程获取到HashTable对象锁的时候,其他线程是不能访问HashTable中的其他方法的,比如A线程在使用HashTable的get()方法时,当B线程想使用HashTable的put()方法的时候必须等到A线程使用完get()方法并释放锁,而且B线程正好能够获取到HashTable的锁的时候才行,这样在多线程环境下就会造成线程长时间的阻塞。使其效率底下的原因所在。

在HashTable中由于在方法上设置synchronized,导致虽然是线程安全的,但是只有一个HashTable的对象锁,也就是说同一时刻只能有一个线程可以访问HashTable,线程都必须竞争同一把锁。假如容器中里面有多把锁,每把锁用于锁住容器的一部分数据,那么当多线程访问容器里面不同的数据时,多线程之间就不会存在锁的竞争,从而提高了并发访问的效率,这就是ConcurrentHashMap的 锁分段技术 。

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁

ConcurrentHashMap的初始化

在ConcurrentHashMap中如何将数据均匀的散列到每一个Segment中?如果数据不能均匀的散列到各个Segment中,那么ConcurrentHashMap的并行性就会下降,好比,所有的数据都在一个Segment中,那么一个ConcurrentHashMap中就相当于只有一个锁,这和HashTable没有什么区别,所以通过一个hash()方法,将数据均匀的散列到不同的Segment中去(注意,虽然经过散列数据会均匀分散的不同的Segment中去,但是,也会有可能出现一个Segment中数据过多的问题,如果数据过多,多线程访问到的概率就会增加,导致并行度下降,如何优化解决ConcurrentHashMap中锁的加入时机和位置,这就是JDK1.8对ConcurrentHashMap所要做的事情)

查看ConcurrentHashMap的put()方法

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put方法首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个步骤,第一步判断是否需要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位置,然后将其放在HashEntry数组里。

(1)是否需要扩容

在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈值,则对数组进行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否已经到达容量的,如果到达了就进行扩容,但是很有可能扩容之后没有新元素插入,这时HashMap就进行了一次无效的扩容。

(2)如何扩容

在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。我们知道HashTable容器的get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要使用的共享变量都定义成volatile类型(关于volatile可以查看 《volatile synchronized final的内存语义》 ),如用于统计当前Segement大小的count字段和用于存储值的HashEntry的value。定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只能被单线程写(有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写共享变量count和value,所以可以不用加锁。

锁的优化

在前面我们提到,在JDK1.7中ConcurrentHashMap使用锁分段的技术,提高了ConcurrentHashMap的并行度,虽然经过散列数据会均匀分散的不同的Segment中去,但是也会有可能出现一个Segment中数据过多的问题,如果数据过多,多线程访问到的概率就会增加,导致并行度下降。

在JDK1.8中ConcurrentHashMap细化了锁的粒度,缩小了公共资源的范围。采用synchronized+CAS的方式实现对共享资源的安全访问,只锁定当前链表或红黑二叉树的 首节点 ,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

结构优化

在JDK1.7中ConcurrentHashMap的结构是数组+链表,我们知道链表随机插入和删除较快,但是查询和修改则会很慢,在ConcurrentHashMap中如果存在一个数组下标下的链表过长,查找某个value的复杂度为O(n),

在JDK1.8中ConcurrentHashMap的结构是数组+链表+红黑树,在链表的长度不超过8时,使用链表,在链表长度超过8时,将链表转换为红黑树复杂度变成O(logN)。效率提高

下面是JDK1.8中ConcurrentHashMap的数据结构

TreeBin:红黑树数节点? ? ?Node:链表节点

在JDK1.8中,hash()方法得到了简化,提高了效率,但是增加了碰撞的概率,不过碰撞的概率虽然增加了,但是通过红黑树可以优化,总的来说还是相较JDK1.7有很大的优化。

下面来分析put(),来观察JDK1.8中如何采用synchronized+CAS的方式细化锁的粒度,只锁定当前链表或红黑二叉树的 首节点。

ConcurrentHashMap常问问题

采用了分段锁的思想,将哈希桶数组分成一个个的Segment数组(继承ReentrantLock),每一个Segment里面又有多个HashEntry,也是被volatile修饰的,是为了保证在数组扩容时候的可见性,HashEntry中又有key,hash,value,next属性,而value,next又是被volatile修饰为了保证多线程环境下数据修改时的可见性,多线程环境下ConcurrentHashMap会对这些小的数组进行加锁,这样多线程操作Map就相当于是操作单线程环境下的HashMap,比如A线程对其中一个段进行写操作的时候线程B就不能对其进行写操作,但是线程B可以对其他的段进行写操作,从而实现并发修改和访问。

JDK1.8的ConcurrentHashMap摒弃了分段锁的思想,采用jdk1.8中HashMap的底层机构,Node数组+链表+红黑树。Node是继承了Entry的一个内部类,他的value和next都是被volatile修饰的原因也是为了保证多线程下修改数据的可见性。

采用CAS+synchronized实现更加细粒度的锁,将锁的级别控制在更细粒度的哈希桶数组元素的级别,只需要锁住链表头节点(红黑树的根节点)就不会影响到其他哈希桶数组元素的读写,大大的提高了并发度。

是不需要加锁的,因为Node节点使用了volatile修饰了value和next节点,而在jdk8中同样也是使用了volatile修饰了value和next节点,这样保证可见性了就不需要加锁了。

key不能为空,无法解释,没有什么可说的,可能就是作者的想法。

value不能为空是因为ConcurrentHashMap是工作在多线程环境下的,如果调用get方法,返回null,这个时候就存在二义性,因为ConcurrentHashMap不知道是没有这个key,还是这个key对应的值是不是null。所以干脆不支持value为null。

HashMap的迭代器是强一致性的,而ConcurrentHashMap的迭代器是弱一致性的,因为在多线程环境下,在创建迭代器的过程中,内部的元素会发生变化,如果是在已经遍历过去的数据中发生变化,迭代器是无法反映出来数据发生了改变,如果是发生在未迭代的数据时,这个时候就会反映出来,强一致性就是说只要迭代器创建出来之后数据就不会发生改变了。这样设计的好处就是迭代器线程可以使用原来的老数据进行遍历,写线程可以并发的完成改变,这样就保证了多个线程执行的时候的连续性和可拓展性,提升了并发性能。

JDK1.7中,并发度就是ConcurrentHashMap中的分段个数,即Segment[]数组的长度,默认是16,这个值可以在构造函数中设置。如果自己设置了并发度那么就会和HasHMap一样会去找到大于等于当前输入值的最小的2的幂指数作为实际并发度。如果过小就会产生锁竞争,如果过大,那么就会导致本来位于同一个Segment的的访问会扩散到不同的Segment中,导致性能下降。

JDK1.8中摈弃了Segment的概念,选择使用HashMap的结构,并发度依赖于数组的大小。

ConcurrentHashMap效率高,因为hashTable是给整个hash表加锁,而ConcurrentHashMap锁粒度要更低。

使用Collections.synchronizedMap(Map类型的对象)方法进行同步加锁,把对象转换为SynchronizedMapK,V类型。其实就是对HashMap做了一次封装,多个线程竞争一个mutex对象锁,在竞争激烈的情况下性能也非常差,不推荐使用。

(责任编辑:IT教学网)

更多

推荐CSS教程文章