多线程篇(其它容器- ConcurrentHashMap)(持续更新迭代)

目录

一、简介

二、为什么要使用

1. 线程不安全的HashMap。

2. 效率低下的HashTable

3. ConcurrentHashMap的锁分段技术可有效提升并发访问率

三、基本结构

四、初始化

1. 初始化segments数组

2. 初始化segmentShift和segmentMask

3. 初始化每个segment

4. 定位Segment

五、基本操作

1. get

2. put

2.1. 是否需要扩容

2.2. 如何扩容

2.3. size操作


一、简介

ConcurrentHashMap 是线程安全并且高效的 HashMap。

本节让我们一起研究下该容器是如何在 保证线程安全的同时又能保证高效的操作。

二、为什么要使用

在并发编程中使用HashMap可能导致程序死循环。

而使用线程安全的HashTable效率又非常低下,

基于以上两个原因,便有了ConcurrentHashMap的登场机会。

1. 线程不安全的HashMap。

在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所 以在并发情况下

不能使用HashMap。

例如,执行以下代码会引起死循环。

HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表 形成环形数

据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。

2. 效率低下的HashTable

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。

因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状

态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,

所以竞争越激烈效率越低。

3. ConcurrentHashMap的锁分段技术可有效提升并发访问率

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把

锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么 当多线程访问容器里不同数据段的数据

时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技

术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时

候,其他段的数 据也能被其他线程访问。

三、基本结构

通过ConcurrentHashMap的类图来分析ConcurrentHashMap的结构,如下图所示。

ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁

(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数 据。一个

ConcurrentHashMap里包含一个Segment数组。

Segment的结构和HashMap类似,是一种 数组和链表结构。一个Segment里包含一个HashEntry数组,每个

HashEntry是一个链表结构的元 素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的

数据进行修改时, 必须首先获得与它对应的Segment锁,如图6-2所示。

四、初始化

ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel等几个参数来初

始化segment数组、段偏移量segmentShift、段掩码segmentMask和每个segment里的HashEntry数组

来实现的。

1. 初始化segments数组

让我们来看一下初始化segments数组的源代码。

由上面的代码可知,segments数组的长度ssize是通过concurrencyLevel计算得出的。为了能 通过按位与

的散列算法来定位segments数组的索引,必须保证segments数组的长度是2的N次方 (power-of-two

size),所以必须计算出一个大于或等于concurrencyLevel的最小的2的N次方值来作为segments数组的

长度。假如concurrencyLevel等于14、15或16,ssize都会等于16,即容器里锁的个数也是16。

注意 concurrencyLevel的最大值是65535,这意味着segments数组的长度最大为65536,对应的二进制

是16位。

2. 初始化segmentShift和segmentMask

这两个全局变量需要在定位segment时的散列算法里使用,sshift等于ssize从1向左移位的 次数,在默认情

况下concurrencyLevel等于16,1需要向左移位移动4次,所以sshift等于4。segmentShift用于定位参与

散列运算的位数,segmentShift等于32减sshift,所以等于28,这里之所 以用32是因为

ConcurrentHashMap里的hash()方法输出的最大数是32位的,后面的测试中我们可以看到这点。

segmentMask是散列运算的掩码,等于ssize减1,即15,掩码的二进制各个位的值都是1。因为ssize的最

大长度是65536,所以segmentShift最大值是16,segmentMask最大值是 65535,对应的二进制是16

位,每个位都是1。

3. 初始化每个segment

输入参数initialCapacity是ConcurrentHashMap的初始化容量,loadfactor是每个segment的负 载因

子,在构造方法里需要通过这两个参数来初始化数组中的每个segment。

上面代码中的变量cap就是segment里HashEntry数组的长度,它等于initialCapacity除以ssize的倍数c,

如果c大于1,就会取大于等于c的2的N次方值,所以cap不是1,就是2的N次方。segment的容量

threshold=(int)cap*loadFactor,默认情况下initialCapacity等于16,loadfactor等于0.75,通过运算

cap等于1,threshold等于零。

4. 定位Segment

既然ConcurrentHashMap使用分段锁Segment来保护不同段的数据,那么在插入和获取元素 的时候,必

须先通过散列算法定位到Segment。可以看到ConcurrentHashMap会首先使用Wang/Jenkins hash的变

种算法对元素的hashCode进行一次再散列。

之所以进行再散列,目的是减少散列冲突,使元素能够均匀地分布在不同的Segment上, 从而提高容器的

存取效率。假如散列的质量差到极点,那么所有的元素都在一个Segment中, 不仅存取元素缓慢,分段锁

也会失去意义。笔者做了一个测试,不通过再散列而直接执行散列计算。

计算后输出的散列值全是15,通过这个例子可以发现,如果不进行再散列,散列冲突会非 常严重,因为只

要低位一样,无论高位是什么数,其散列值总是一样。我们再把上面的二进制 数据进行再散列后结果如下

(为了方便阅读,不足32位的高位补了0,每隔4位用竖线分割下)。

可以发现,每一位的数据都散列开了,通过这种再散列能让数字的每一位都参加到散列 运算当中,从而减

少散列冲突。ConcurrentHashMap通过以下散列算法定位segment。

默认情况下segmentShift为28,segmentMask为15,再散列后的数最大是32位二进制数据, 向右无符

号移动28位,意思是让高4位参与到散列运算中,(hash>>>segmentShift)& segmentMask的运算结

果分别是4、15、7和8,可以看到散列值没有发生冲突。

五、基本操作

1. get

Segment的get操作实现非常简单和高效。先经过一次再散列,然后使用这个散列值通过散 列运算定位到

Segment,再通过散列算法定位到元素,代码如下。

get操作的高效之处在于整个get过程不需要加锁,除非读到的值是空才会加锁重读。我们知道HashTable容器的

get方法是需要加锁的,那么ConcurrentHashMap的get操作是如何做到不加锁的呢?原因是它的get方法里将要

使用的共享变量都定义成volatile类型,如用于统计当前Segement大小的count字段和用于存储值的HashEntry的

value。

定义成volatile的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,但是只

能被单线程写 (有一种情况可以被多线程写,就是写入的值不依赖于原值),在get操作里只需要读不需要写 共

享变量count和value,所以可以不用加锁。之所以不会读到过期的值,是因为根据Java内存模 型的happen

before原则,对volatile字段的写入操作先于读操作,即使两个线程同时修改和获取volatile变量,get操作也能拿

到最新的值,这是用volatile替换锁的经典应用场景。

在定位元素的代码里我们可以发现,定位HashEntry和定位Segment的散列算法虽然一样, 都与数组的长度减去

1再相“与”,但是相“与”的值不一样,定位Segment使用的是元素的 hashcode通过再散列后得到的值的高

位,而定位HashEntry直接使用的是再散列后的值。其目的是避免两次散列后的值一样,虽然元素在Segment里

散列开了,但是却没有在HashEntry里散列开。

2. put

由于put方法里需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。put方法

首先定位到Segment,然后在Segment里进行插入操作。插入操作需要经历两个 步骤,第一步判断是否需

要对Segment里的HashEntry数组进行扩容,第二步定位添加元素的位 置,然后将其放在HashEntry数组

里。

2.1. 是否需要扩容

在插入元素前会先判断Segment里的HashEntry数组是否超过容量(threshold),如果超过阈 值,则对数组进

行扩容。值得一提的是,Segment的扩容判断比HashMap更恰当,因为HashMap是在插入元素后判断元素是否

已经到达容量的,如果到达了就进行扩容,但是很有可能扩容 之后没有新元素插入,这时HashMap就进行了一次

无效的扩容。

2.2. 如何扩容

在扩容的时候,首先会创建一个容量是原来容量两倍的数组,然后将原数组里的元素进行再散列后插入到新的数

组里。为了高效,ConcurrentHashMap不会对整个容器进行扩容,而只对某个segment进行扩容。

2.3. size操作

如果要统计整个ConcurrentHashMap里元素的大小,就必须统计所有Segment里元素的大小后求和。Segment

里的全局变量count是一个volatile变量,那么在多线程场景下,是不是直接把所有Segment的count相加就可以

得到整个ConcurrentHashMap大小了呢?不是的,虽然相加时可以获取每个Segment的count的最新值,但是可

能累加前使用的count发生了变化,那么统计结果就不准了。所以,最安全的做法是在统计size的时候把所有

Segment的put、remove和clean方法 全部锁住,但是这种做法显然非常低效。

因为在累加count操作过程中,之前累加过的count发生变化的几率非常小,所以ConcurrentHashMap的做法是

先尝试2次通过不锁住Segment的方式来统计各个Segment大小,如 果统计的过程中,容器的count发生了变

化,则再采用加锁的方式来统计所有Segment的大小。

那么ConcurrentHashMap是如何判断在统计的时候容器是否发生了变化呢?使用modCount变量,在put、

remove和clean方法里操作元素前都会将变量modCount进行加1,那么在统计size前后比较modCount是否发生

变化,从而得知容器的大小是否发生变化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

W哥教你学后端

你的鼓励是我创作最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值