ConcurrentHashMap是线程安全且高效率的HashMap,本文我们将研究一下该容器的具体实现。
目录
为什么要使用ConcurrentHashMap
- 在多线程环境下,使用HashMap,有可能会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环。
- 虽然可以使用HashTable来应对多线程环境,但是当线程访问HashTable同步方法时,其他线程将进入阻塞或者轮询,所以HashTable的效率十分低下。并且HashTable已经慢慢被淘汰了。
- 所有访问HashTable的线程都必须竞争同一把锁来获得访问HashTable的权利,但是ConcurrentHashMap使用分段锁技术,将所有数据分段,每段数据配备一把锁,那么当一段数据的锁被获得的时候,其他段的数据依然能够被访问,有效的提高了并发的效率。
ConcurrentHashMap实现
JDK1.8中的ConcurrentHashMap已经抛弃了分段锁,使用了CAS+synchronized来保证线程安全,所以我们分两部分讲解。
理解ConcurrentHashMap首先要对于HashMap有所了解,如果没有了解的同学可以先看一下我的关于HashMap的博文
JDK1.5中
ConcurrentHashMap的数据结构如图
ConcurrentHashMap中,segment继承了ReentrankLock充当锁的角色,每个segment守护了若干个桶(Bucket)。
在HashMap中,除去segment部分,就是HashMap的数据结构。
我们可以理解为ConcurrentHashMap就是将一个HashMap分成了多个HashMap,并且对每一个HashMap使用继承了ReentrankLock的segment来维护,实现线程安全。
get方法
public V get(Object key){
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}
先经过一次散列运算,定位到segment,然后再通过散列运算定位到其中的元素。非常简洁高效。
put方法
由于put方法中需要对共享变量进行写入操作,所以为了线程安全,在操作共享变量时必须加锁。
put方法首先定位到segment,然后在segment里进行put操作。
插入操作首先需要判断是否需要对segment里的HashEntry进行扩容(在HashMap中插入操作也需要检查是否需要扩容),如果需要扩容则扩容后再插入,否则直接插入。
由于已经弃用,我们不做更细致的讨论,主要看JDK1.8中的ConcurrentHashMap实现。
JDK1.8中
ConcurrentHashMap的数据结构如图
ConcurrentHashMap在1.8中的实现,相比于1.7的版本基本上全部都变掉了。首先,取消了Segment分段锁的数据结构,取而代之的是数组+链表(红黑树)的结构。而对于锁的粒度,调整为对每个数组元素加锁(Node)。然后是定位节点的hash算法被简化了,这样带来的弊端是Hash冲突会加剧。因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。这样一来,查询的时间复杂度就会由原先的O(n)变为O(logN)。
put方法
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) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());//计算hash值,两次hash操作
int binCount = 0;
for (Node<K,V>[] tab = table;;) {//类似于while(true),死循环,直到插入成功
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)//检查是否初始化了,如果没有,则初始化
tab = initTable();
/*
i=(n-1)&hash 等价于i=hash%n(前提是n为2的幂次方).即取出table中位置的节点用f表示。
有如下两种情况:
1、如果table[i]==null(即该位置的节点为空,没有发生碰撞),则利用CAS操作直接存储在该位置,
如果CAS操作成功则退出死循环。
2、如果table[i]!=null(即该位置已经有其它节点,发生碰撞)
*/
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)//检查table[i]的节点的hash是否等于MOVED,如果等于,则检测到正在扩容,则帮助其扩容
tab = helpTransfer(tab, f);//帮助其扩容
else {//运行到这里,说明table[i]的节点的hash值不等于MOVED。
V oldVal = null;
synchronized (f) {//锁定,(hash值相同的链表的头节点)
if (tabAt(tab, i) == f) {//避免多线程,需要重新检查
if (fh >= 0) {//链表节点
binCount = 1;
/*
下面的代码就是先查找链表中是否出现了此key,如果出现,则更新value,并跳出循环,
否则将节点加入到链表末尾并跳出循环
*/
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)//仅putIfAbsent()方法中onlyIfAbsent为true
e.val = value;//putIfAbsent()包含key则返回get,否则put并返回
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) {
if (binCount >= TREEIFY_THRESHOLD)//实则是>8,执行else,说明该桶位本就有Node
treeifyBin(tab, i);//若length<64,直接tryPresize,两倍table.length;不转树
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
从上面代码可以看出,put的步骤大致如下:
-
检查
key/value
是否为空,处理hash
值 -
进入
for
死循环,因为CAS
的无锁操作需要一直尝试直至成功 -
检查
table
是否初始化,没有则初始化initTable()
-
根据
key
的hash
值找到在table
中的位置i
,取出table[i]
的节点f
-
如果
f==null
(即该位置的节点为空,没有发生碰撞)直接
CAS
存储,退出循环 -
如果
f!=null
(即该位置已经有其它节点,发生碰撞),检查f
的节点的hash
是否等于MOVED
a.如果等于,则检测到正在扩容,则帮助其扩容
b.如果不等于,如果f是链表节点,则直接插入链表;如果是树节点,则插入树中
-
-
判断
f
是否需要将链表转换为平衡树 -
并发控制:
-
使用
CAS
操作插入数据 -
在每个链表的头结点都使用
Synchronized
上锁
除了上述步骤以外,还有一点我们留意到的是,代码中加锁片段用的是synchronized关键字,而不是像1.7中的ReentrantLock。这一点也说明了,synchronized在新版本的JDK中优化的程度和ReentrantLock差不多了。
get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());// 定位到table[]中的i
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {// 若table[i]存在
if ((eh = e.hash) == h) {// 比较链表头部
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
else if (eh < 0)// 若为红黑树,查找树
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {// 循环链表查找
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;// 未找到
}
get()方法的流程相对简单一点,从上面代码可以看出以下步骤:
- 首先定位到table[]中的i。
- 若table[i]存在,则继续查找。
- 首先比较链表头部,如果是则返回。
- 然后如果为红黑树,查找树。
- 最后再循环链表查找。
从上面步骤可以看出,ConcurrentHashMap的get操作上面并没有加锁。所以在多线程操作的过程中,并不能完全的保证一致性。这里和1.7当中类似,是弱一致性的体现。
总结
- JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据
JDK1.8中concurrentHashMap的介绍摘自https://blog.csdn.net/fouy_yun/article/details/77816587
参考资料:《Java并发编程的艺术》