并发编程实践中,ConcurrentHashMap是一个经常被使用的数据结构,相比于Hashtable以及Collections.synchronizedMap(),ConcurrentHashMap在线程安全的基础上提供了更好的写并发能力,但同时降低了对读一致性的要求(这点好像CAP理论啊 O(∩_∩)O)。ConcurrentHashMap的设计与实现非常精巧,大量的利用了volatile,final,CAS等lock-free技术来减少锁竞争对于性能的影响,无论对于Java并发编程的学习还是Java内存模型的理解,ConcurrentHashMap的设计以及源码都值得非常仔细的阅读与揣摩
通过分析Hashtable就知道,synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术。它使用了多个锁来控制对hash表的不同部分进行的修改。ConcurrentHashMap内部使用段(Segment)来表示这些不同的部分,每个段其实就是一个小的hash table,它们有自己的锁。只要多个修改操作发生在不同的段上,它们就可以并发进行。而Hashtable的实现方式是---锁整个hash表
1. JDK6与JDK7中的实现
1.1 设计思路
ConcurrentHashMap采用了分段锁的设计,只有在同一个分段内才存在竞态关系,不同的分段锁之间没有锁竞争。相比于对整个Map加锁的设计,分段锁大大的提高了高并发环境下的处理能力。但同时,由于不是对整个Map加锁,导致一些需要扫描整个Map的方法(如size(), containsValue())需要使用特殊的实现,另外一些方法(如clear())甚至放弃了对一致性的要求(ConcurrentHashMap是弱一致性的,具体请查看ConcurrentHashMap能完全替代HashTable吗?)。
二、应用场景
当有一个大数组时需要在多个线程共享时就可以考虑是否把它给分层多个节点了,避免大锁。并可以考虑通过hash算法进行一些模块定位。
其实不止用于线程,当设计数据表的事务时(事务某种意义上也是同步机制的体现),可以把一个表看成一个需要同步的数组,如果操作的表数据太多时就可以考虑事务分离了(这也是为什么要避免大表的出现),比如把数据进行字段拆分,水平分表等.
三、源码解读
ConcurrentHashMap中主要实体类就是三个:ConcurrentHashMap(整个Hash表),Segment(桶),HashEntry(节点),对应上面的图可以看出之间的关系
Put方法
前面的所有的介绍其实都为这个方法做铺垫。ConcurrentHashMap最常用的就是put和get两个方法。现在来介绍put方法,这个put方法依然沿用HashMap的put方法的思想,根据hash值计算这个新插入的点在table中的位置i,如果i位置是空的,直接放进去,否则进行判断,如果i位置是树节点,按照树的方式插入新的节点,否则把i插入到链表的末尾。ConcurrentHashMap中依然沿用这个思想,有一个最重要的不同点就是ConcurrentHashMap不允许key或value为null值。另外由于涉及到多线程,put方法就要复杂一点。在多线程中可能有以下两个情况
- 如果一个或多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容;
- 如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。尽管这个有一些影响效率,但是还是会比hashTable的synchronized要好得多。
整体流程就是首先定义不允许key或value为null的情况放入 对于每一个放入的值,首先利用spread方法对key的hashcode进行一次hash计算,由此来确定这个值在table中的位置。
如果这个位置是空的,那么直接放入,而且不需要加锁操作。
如果这个位置存在结点,说明发生了hash碰撞,首先判断这个节点的类型。如果是链表节点(fh>0),则得到的结点就是hash值相同的节点组成的链表的头节点。需要依次向后遍历确定这个新加入的值所在位置。如果遇到hash值与key值都与新加入节点是一致的情况,则只需要更新value值即可。否则依次向后遍历,直到链表尾插入这个结点。如果加入这个节点以后链表长度大于8,就把这个链表转换成红黑树。如果这个节点的类型已经是树节点的话,直接调用树节点的插入方法进行插入新的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
|
public
V put(K key, V value) {
return
putVal(key, value,
false
);
}
/** Implementation for put and putIfAbsent */
final
V putVal(K key, V value,
boolean
onlyIfAbsent) {
//不允许 key或value为null
if
(key ==
null
|| value ==
null
)
throw
new
NullPointerException();
//计算hash值
int
hash = spread(key.hashCode());
int
binCount =
0
;
//死循环 何时插入成功 何时跳出
for
(Node<K,V>[] tab = table;;) {
Node<K,V> f;
int
n, i, fh;
//如果table为空的话,初始化table
if
(tab ==
null
|| (n = tab.length) ==
0
)
tab = initTable();
//根据hash值计算出在table里面的位置
else
if
((f = tabAt(tab, i = (n -
1
) & hash)) ==
null
) {
//如果这个位置没有值 ,直接放进去,不需要加锁
if
(casTabAt(tab, i,
null
,
new
Node<K,V>(hash, key, value,
null
)))
break
;
// no lock when adding to empty bin
}
//当遇到表连接点时,需要进行整合表的操作
else
if
((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else
{
V oldVal =
null
;
//结点上锁 这里的结点可以理解为hash值相同组成的链表的头结点
synchronized
(f) {
if
(tabAt(tab, i) == f) {
//fh〉0 说明这个节点是一个链表的节点 不是树的节点
if
(fh >=
0
) {
binCount =
1
;
//在这里遍历链表所有的结点
for
(Node<K,V> e = f;; ++binCount) {
K ek;
//如果hash值和key值相同 则修改对应结点的value值
if
(e.hash == hash &&
((ek = e.key) == key ||
(ek !=
null
&& key.equals(ek)))) {
oldVal = e.val;
if
(!onlyIfAbsent)
e.val = value;
break
;
}
Node<K,V> pred = e;
//如果遍历到了最后一个结点,那么就证明新的节点需要插入 就把它插入在链表尾部
if
((e = e.next) ==
null
) {
pred.next =
new
Node<K,V>(hash, key,
value,
null
);
break
;
}
}
}
//如果这个节点是树节点,就按照树的方式插入值
else
if
(f
instanceof
TreeBin) {
Node<K,V> p;
binCount =
2
;
if
((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) !=
null
) {
oldVal = p.val;
if
(!onlyIfAbsent)
p.val = value;
}
}
}
}
if
(binCount !=
0
) {
//如果链表长度已经达到临界值8 就需要把链表转换为树结构
if
(binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if
(oldVal !=
null
)
return
oldVal;
break
;
}
}
}
//将当前ConcurrentHashMap的元素数量+1
addCount(1L, binCount);
return
null
;
}
|
我们可以发现JDK8中的实现也是锁分离的思想,只是锁住的是一个Node,而不是JDK7中的Segment,而锁住Node之前的操作是无锁的并且也是线程安全的,建立在之前提到的3个原子操作上。
总体描述:
concurrentHashmap是为了高并发而实现,内部采用分离锁的设计,有效地避开了热点访问。而对于每个分段,ConcurrentHashmap采用final和内存可见修饰符volatile关键字(内存立即可见:Java 的内存模型可以保证:某个写线程对 value 域的写入马上可以被后续的某个读线程“看”到。注:并不能保证对volatile变量状态有依赖的其他操作的原子性)
借用某博客对concurrentHashmap对结构图:
不难看出,concurrenthashmap采用了二次hash的方式,第一次hash将key映射到对应的segment,而第二次hash则是映射到segment的不同桶中。
为什么要用二次hash,主要原因是为了构造分离锁,使得对于map的修改不会锁住整个容器,提高并发能力。当然,没有一种东西是绝对完美的,二次hash带来的问题是整个hash的过程比hashmap单次hash要长,所以,如果不是并发情形,不要使用concurrentHashmap。
代码实现:
该数据结构中,最核心的部分是两个内部类,HashEntry和Segment
concurrentHashmap维护一个segment数组,将元素分成若干段(第一次hash)
1
2
3
4
|
/**
* The segments, each of which is a specialized hash table.
*/
final
Segment<K,V>[] segments;
|
segments的每一个segment维护一个链表数组
代码:
再来看看构造方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
public
ConcurrentHashMap(
int
initialCapacity,
float
loadFactor,
int
concurrencyLevel) {
if
(!(loadFactor >
0
) || initialCapacity <
0
|| concurrencyLevel <=
0
)
throw
new
IllegalArgumentException();
if
(concurrencyLevel > MAX_SEGMENTS)
concurrencyLevel = MAX_SEGMENTS;
// Find power-of-two sizes best matching arguments
int
sshift =
0
;
int
ssize =
1
;
while
(ssize < concurrencyLevel) {
++sshift;
ssize <<=
1
;
}
this
.segmentShift =
32
- sshift;
this
.segmentMask = ssize -
1
;
if
(initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
int
c = initialCapacity / ssize;
if
(c * ssize < initialCapacity)
++c;
int
cap = MIN_SEGMENT_TABLE_CAPACITY;
while
(cap < c)
cap <<=
1
;
// create segments and segments[0]
Segment<K,V> s0 =
new
Segment<K,V>(loadFactor, (
int
)(cap * loadFactor),
(HashEntry<K,V>[])
new
HashEntry[cap]);
Segment<K,V>[] ss = (Segment<K,V>[])
new
Segment[ssize];
UNSAFE.putOrderedObject(ss, SBASE, s0);
// ordered write of segments[0]
this
.segments = ss;
}
|
代码28行,一旦指定了concurrencyLevel(segments数组大小)便不能改变,这样,一旦threshold超标,rehash真不会影响segments数组,这样,在大并发的情况下,只会影响某一个segment的rehash而其他segment不会受到影响
(put方法都要上锁)
HashEntry
与hashmap类似,concurrentHashmap也采用了链表作为每个hash桶中的元素,不过concurrentHashmap又有些不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
static
final
class
HashEntry<K,V> {
final
int
hash;
final
K key;
volatile
V value;
volatile
HashEntry<K,V> next;
HashEntry(
int
hash, K key, V value, HashEntry<K,V> next) {
this
.hash = hash;
this
.key = key;
this
.value = value;
this
.next = next;
}
/**
* Sets next field with volatile write semantics. (See above
* about use of putOrderedObject.)
*/
final
void
setNext(HashEntry<K,V> n) {
UNSAFE.putOrderedObject(
this
, nextOffset, n);
}
// Unsafe mechanics
static
final
sun.misc.Unsafe UNSAFE;
static
final
long
nextOffset;
static
{
try
{
UNSAFE = sun.misc.Unsafe.getUnsafe();
Class k = HashEntry.
class
;
nextOffset = UNSAFE.objectFieldOffset
(k.getDeclaredField(
"next"
));
}
catch
(Exception e) {
throw
new
Error(e);
}
}
}
|
HashEntry的key,hash采用final,可以避免并发修改问题,HashEntry链的尾部是不能修改的,而next和value采用volatile,可以避免使用同步造成的并发性能灾难,新版(jdk1.7)的concurrentHashmap大量使用java Unsafe类提供的原子操作,直接调用底层操作系统,提高性能(这块我也不是特别清楚)