一、前言
ConcurrentHashMap
是线程安全并且高效的HashMap
,其它的类似容器有以下缺点:
-
HashMap
在并发执行put
操作时,会导致Entry
链表形成环形数据结构,就会产生死循环获取Entry
,参考文章 HashMap并发导致死循环 -
HashTable
使用synchronized
来保证线程安全,但在线程竞争激烈的情况下HashTable
的效率非常低下。
ConcurrentHashMap
高效的原因在于它采用 锁分段技术,首先将数据分成一段一段地存储,然后给每段数据配一把锁,当一个线程占用锁并且访问一段数据的时候,其他段的数据也能被其他线程访问。
二、 ConcurrentHashMap 的结构
ConcurrentHashMap
是由Segment
数组结构和HashEntry
数组结构组成:
-
Segment
是一种可重入锁,在ConcurrentHashMap
里面扮演锁的角色。 -
HashEntry
则用于存储键值对数据。
一个ConcurrentHashMap
里包含一个Segment
数组,它的结构和HashMap
类似,是一种数组和链表结构。
一个
Segment
里包含一个
HashEntry
数组,每个
HashEntry
是一个链表结构的元素,每个
Segment
守护着一个
HashEntry
里的元素,当对
HashEntry
数组的数据进行修改时,必须首先获得与它对应的
Segment
锁。
Segment 结构
static final class Segment extends ReentrantLock implements Serializable {
transient volatile int count;
transient int modCount;
transient int threshold;
transient volatile HashEntry[] table;
final float loadFactor;
}
-
count
:Segment
中元素的数量 -
modCount
:对table
的大小造成影响的操作的数量 -
threshold
:阈值,Segment
里面元素的数量超过这个值依旧就会对Segment
进行扩容 -
table
:链表数组,数组中的每一个元素代表了一个链表的头部 -
loadFactor
:负载因子,用于确定threshold
HashEntry 结构
static final class HashEntry {
final K key;
final int hash;
volatile V value;
final HashEntry next;
}
2.1 初始化
ConcurrentHashMap
的初始化方法是通过initialCapacity
、loadFactor
和concurrencyLevel
等几个参数来初始化segment
数组、段偏移量segmentShift
、段掩码segmentMask
和每个segment
里的HashEntry
来实现的。
2.1.1 初始化 segment 数组
初始化segment
的源代码如下,它会计算出:
-
ssize
:segment
数组的长度 -
segmentShift
:sshift
等于ssize
从1
向左移位的次数,segmentShift
等于32-sshift
,segmentShift
用于 定位参与散列运算的位数 -
segmentMask
:散列运算的掩码,等于ssize-1
if (concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
int sshift = 0;
int ssize = 1;
//计算 segments 数组的长度,它是大于等于 concurrencyLevel 的最小的 2 的 N 次方。
while (ssize < concurrencyLevel) {
++sshift;
ssize <<= 1;
}
segmentShift = 32 - sshift;
segmentMask = ssize - 1;
this.segments = Segment.newArray(ssize);
2.1.2 初始化每个 segment
输入参数initialCapacity
是ConcurrentHashMap
的初始化容量,loadFactor
是每个segment
的负载因子,在构造方法里通过这两个参数来初始化数组中的每个segment
。
if (initialCapacity < MAXIMUM_CAPACITY) {
initialCapacity = MAXIMUM_CAPACITY;
}
int c = initialCapacity / ssize;
if (c * ssize < initialCapacity) {
++c;
}
int cap = 1;
while (cap < c) {
cap <<= 1;
}
for (int i = 0; i < this.segments.length; i++) {
this.segments[i] = new Segment(cap, loadFactor);
}
cap 是 segment 里 HashEntry 数组的长度,它等于initialCapacity / ssize
,如果c
大于1
,就会取大于等于c
的2
的N
次方。segment
的容量threshold
等于(int) cap * loadFactor
,默认情况下initialCapacity
等于16
,ssize
等于16
,loadFactor
等于0.75
,因此cap
等于1
,threshold
等于0
。
2.2 定位 segment
在插入和获取元素的时候,必须先通过散列算法定位到Segment
,ConcurrentHashMap
会首先对元素的hashCode()
进行一次再散列。
private static int hash(int h) {
h += (h << 15) ^ 0xffffcd7d;
h ^= (h >>> 10);
h += (h << 3);
h ^= (h >>> 6);
h += (h << 2) + (h << 14);
return h ^ (h >>> 16);
}
再散列的目的是减少散列冲突,使元素能够均匀地分布在不同的Segment
上,从而提高容器的存取效率。
2.3 操作
2.3.1 get 操作
segment
的get
操作过程为:先进行一次再散列,然后使用这个散列值通过散列运算定位到Segment
,再通过散列算法定位到元素。
public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
get
操作的高效之处在于整个get
过程不需要加锁,除非读到的值为空才加锁重读。在它的get
方法里,将要使用的共享变量都定义成volatile
类型,如用于统计当前segment
大小的count
字段和用于存储值的HashEntry
的value
,定义成volatile
的变量,能够在线程之间保持可见性,能够被多线程同时读,并且保证不会读到过期的值,在get
操作里,只需要读而不需要写共享变量count
和value
,所以可以不用加锁。
transient volatile int count;
volatile V value;
2.3.2 put 操作
由于put
方法需要对共享变量进行写入,所以为了线程安全,在操作共享变量时必须加锁。put
方法首先定位到Segment
,然后在Segment
里进行插入操作。插入操作需要经历两个步骤:
- 判断是否需要对
Segment
里的HashEntry
数组进行扩容 - 定位添加元素的位置,然后将其放在
HashEntry
数组里
2.3.3 size 操作
如果要统计整个ConcurrentHashMap
里元素的大小,就必须统计所有Segment
元素的大小后求和,虽然每个Segment
的全局变量count
是一个volatile
变量,在相加时可以获取最新值,但是不能保证之前累加过的Segment
大小不发生变化。
因此,ConcurrentHashMap
会先尝试2
次通过不锁住Segment
的方式来统计各个Segment
大小,如果统计的过程中,容器的count
发生了变化,则再采用加锁的方式来统计所有Segment
的大小。
检测容器大小是否发生变化的原理为:在put
、remove
和clean
方法里操作元素前会将变量modCount
进行加1
,那么在统计size
前后比较modCount
是否发生变化,从而得知容器的大小是否发生变化。
三、参考文献
<<Java
并发编程的艺术>> - Java
并发容器和框架