文章目录
- 一. `HashMap`、`HashTable`与`ConcurrentHashMap`的区别
- 二. JDK1.7中的`ConcurrentHashMap`
- 三. JDK1.8 `ConcurrentHashMap`
一. HashMap
、HashTable
与ConcurrentHashMap
的区别
HashMap
是线程不安全的HashTable
是线程安全的,HashTable
的线程安全是采用在每个方法上面都添加上了synchronized
关键字来修饰,即HashTable
是针对整个table
的锁定。所有访问HashTable
的线程都必须竞争同一把锁。当一个线程访问HashTable
的同步方法的时候,其他线程再访问HashTable
的同步方法的时候,可能会进入阻塞或者轮询状态。比如:线程1使用put
方法来添加元素,线程2不但不能使用put
方法添加元素,也不能使用get
方法来获取元素,所以锁竞争越激烈,该类的效率越低。ConcurrentHashMap
使用分段锁技术,ConcurrentHashMap
是由多个Segment
组成(Segment
下包含了多个Node
,即键值对),每个Segment
都有把锁来实现线程安全,当一个线程占用锁访问其中一段数据的时候,其它段的数据也能被其他的线程访问。
为什么HashTable
慢?
HashTable
之所以效率低下主要是因为其实现使用了synchronized
关键字对put
等操作进行加锁,而synchronized
关键字加锁是对整个对象进行加锁,也就是说在进行put
等修改Hash
表的操作时,锁住了整个Hash
表,从而使得其表现的效率低下。
二. JDK1.7中的ConcurrentHashMap
数据结构
jdk1.7中采用了segment
+HashEntry
的方式进行实现,使用了分段锁机制实现了ConcurrentHashMap
- 一个
ConcurrentHashMap
中包含一个Segment<K,V>[] segments
数组 - 一个
segment
对象中包含一个HashEntry<K,V>[] table
数组
1.7的ConcurrentHashMap
是由Segment+HashEntry
组成,和HashMap
一样,仍然是数组+链表。
ConcurrencyLevel
:默认是16,也就是说ConcurrentHashMap
有16个Segments
,所以理论上,这个时候,最多可以同时支持16个线程并发写,只要他们的操作分别分布在不同的Segments
上,Segments
通过ReentrantLock
来进行加锁。这个值在初始化的时候可以设置为其他值,但是一旦初始化后,它是不可以扩容的。
成员变量
ConcurrentHashMap
在初始化的时候,计算出Segment
数组的大小ssize
和每个Segment
中HashEntry
数组的大小cap
,并初始化Segment
数组的第一个元素。
initialCapacity
:初始容量,整个值指的是整个ConcurrentHashMap
的初始容量,实际操作的时候需要平均分给每个Segment
。ssize
:大小为2的幂次方,默认为16.cap
:大小也为2的幂次方,最小值为2,最终结果根据初始化容量initialCapacity
进行计算。
核心成员变量:
Segment
是ConcurrentHashMap
的一个内部类,主要的组成结构如下:
首先通过Key
根据hash算法定位到元素属于哪个Segment
,之后在对应的Segment
中进行具体的put
操作。
其中HashEntry
组成:
和HashMap
非常类似,唯一的区别就是其中的核心数据如value
,以及链表都是volatile
修饰的,保证了获取时的可见性。
并发度(Concurrency Level)
并发度可以理解为程序运行时能够同时更新ConcurrentHashMap
且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap
中的分段锁的个数,即Segment[]
的数组长度。ConcurrentHashMap
默认的并发度是16,但用户也可以构造函数中设置并发度,当用户设置并发度的时候,ConcurrentHashMap
会使用大于等于该值的最小2幂指数作为实际并发度(如果用户设置并发度是17,实际并发度则为32)。
运行时,通过将Key的高n位(n=32-segmentShift
)和并发度-1做位与运算定位到所在的Segment
。
初始化
initialCapacity
:初始容量,这个值指出的是整个CocurrentHashMap
的初始容量,实际操作的时候需要平均分给每个segment
。loadFactor
:负载因子,segment
数组不可以扩容,所以这个负载因子是给每个segment
内部使用的。
注意点:JDK7中除了第一个segment
之外,剩余的segments
采用的是延迟初始化的机制:每次put
之前都要检查key
对应的segment
是否为null
,如果是则调用ensureSegment()
以确保对应的segment
被创建。
初始化完成,我们可以得到一个Segment
数组:
初始化过程一共会做如下几件事:Segment
数组的长度为16,在初始化的时候可以指定长度,但是在初始化结束后,不可以修改这个长度。Segment[i]
的默认大小为2,即初始一个Segment
只能装入一个HashEntry
,负载因子为0.75,初始阈值为1.5,即在插入第一个元素的时候不会触发扩容,插入第二个元素的时候就会进行第一次扩容。- 初始化了
Segment[0]
,其他位置的Segment
数组没有初始化 - 参数的相关说明:
(1)ssize
:Segment
数组的长度
(2)sshift
:等于ssize
从1左移的次数
(3)segmentShift
:用于定位参与散列运算的位数,移位数
(4)segmentMask
:散列运算的掩码
(5)cap
:Segment
里面HashEntry
数组的长度
put
过程分析
根据hash
值找到对应的Segment
,Segment
的数组下标是hash
值的最后四位。首先是通过key
定位到Segment
,之后在对应的Segment
中进行具体的put
操作。Segment
内部是由数组+链表组成的。
虽然HashEntry
中的value
是用volatile
关键字修饰的,但是并不能保证并发的原子性,所以put
操作是需要加锁的操作,进到put
方法中首先要尝试加锁,如【第373行】代码所示。
三个关键方法:
ensureSegment
方法
这个是【第1106行】的方法。ConcurrentHashMap
初始化的时候会初始化第一个槽segment[0]
,对于其他槽来说,在插入第一个值的时候进行初始化,这里需要考虑并发,因为很可能会有多个线程同时初始化同一个槽segment[k]
,不过只要有一个初始化成功就可以了,对于并发操作,使用CAS进行控制。
scanAndLockForPut
方法
获取写入锁,首先第一步的时候尝试获取锁,如果获取失败肯定就有其他线程存在竞争,则利用scanAndLockForPut
自旋获取锁。
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value) {
HashEntry<K,V> first = entryForHash(this, hash);
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1; // negative while locating node
// 循环获取锁
while (!tryLock()) {
HashEntry<K,V> f; // to recheck first below
if (retries < 0) {
if (e == null) {
if (node == null) // speculatively create node
// 进到这里说明数组该位置的链表是空的,没有任何元素
// 当然,进到这里的另一个原因是 tryLock() 失败,所以该槽存在并发,不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}
else if (key.equals(e.key))
retries = 0;
else
// 顺着链表往下走
e = e.next;
}
// 重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁
// lock() 是阻塞方法,直到获取锁后返回
else if (++retries > MAX_SCAN_RETRIES) {
lock();
break;
}
else if ((retries & 1) == 0 &&
// 这个时候是有大问题了,那就是有新的元素进到了链表,成为了新的表头
// 所以这边的策略是,相当于重新走一遍这个 scanAndLockForPut 方法
(f = entryForHash(this, hash)) != first) {
e = first = f; // re-traverse if entry changed
retries = -1;
}
}
return node;
}
scanAndLockForPut
自旋获取锁的过程:
- 尝试自旋获取锁
- 如果重试的次数达到了
MAX_SCAN_RETRIES
则改为阻塞锁获取&#x