参考文章
ConcurrentHashMap底层实现原理(JDK1.7 & 1.8)
HashMap?ConcurrentHashMap?相信看完这篇没人能难住你!
0. 先上图
对比ConcurrentHashMap的底层数据结构,在JDK1.7和1.8中的不同
1. JDK1.7中的ConcurrentHashMap
1.1 底层数据结构
首先是数组+链表,其次是Segment数组+HashEntry,看图
1.2 并发控制
ConcurrentHashMap内部进一步细分了若干个小的HashMap,称之为段(segment),默认为16个段。在put()元素的时候,不需要对曾哥HashMap加锁,而是首先根据hash值得到该元素应该被存在哪个段中,然后对该段加锁,并完成put()操作。对段加锁时用的是重入锁ReentrantLock。get()操作不需要加锁,操作也分两步,先定位到段,再定位到具体的桶位。由于 HashEntry 中的 value 属性是用 volatile 关键词修饰的,保证了内存可见性,所以每次获取时都是最新值。
除了put()和get()方法,size()方法在实际开发中也频繁使用,它的目的是统计ConcurrentHashMap的总元素数量, 肯定要把每个segment内部的元素数量都加起来。为了使得效率高,采用的是先乐观后悲观的策略。即先用无锁的方式对各段的元素数进行求和,如果失败的话,就对没个segment都加锁后进行求和。
2. JDK1.8中的ConcurrentHashMap
2.1 底层数据结构
JDK1.8对ConcurrentHashMap的实现已经摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap。虽然在JDK1.8中还能看到Segment的数据结构,但是已经简化了属性,只是为了兼容旧版本。看图
2.2 并发控制
首先Node中的val和next都使用volatile进行修饰,保证可见性,对这两个变量取值时,每次都能取到最新值。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
Node(int hash, K key, V val, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
}
put()方法,使用synchronized和CAS保证多线程安全,看代码(不要被吓到,只需耐心把里面的注释看完)
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 如果还没有初始化,这里进行初始化
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 如果没有冲突,CAS方式插入(无锁)
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;
// 至此,说明存在冲突,那么,锁住链表或者红黑树的头结点
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) { // 说明这是链表结构
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
// 已存在相同的key,覆盖旧值
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,尝试转为红黑树
// 之所以说尝试,是因为在treeifyBin方法中,
// 还要判断数组容量是不是大于等于64
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
//统计size,并且检查是否需要扩容
addCount(1L, binCount);
return null;
}
put步骤总结:
- 如果没有初始化就先调用initTable()方法来进行初始化过程
- 如果没有hash冲突就直接CAS插入
- 如果还在进行扩容操作就去帮忙转移元素
- 如果存在hash冲突,就加锁来保证线程安全,这里有两种情况,一种是链表形式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入。
- 最后一个如果,Hash冲突时会形成Node链表,在链表长度超过8,Node数组超过64时会将链表结构转换为红黑树的结构
- 最后如果添加成功就调用addCount()方法统计size,并且检查是否需要扩容
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());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
if ((eh = e.hash) == h) {
// 首节点命中,直接返回
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// hash值为负值表示正在扩容,通过ForwardingNode的find方法来查找
// 如果找到就返回
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) { //既不是首节点也不是ForwardingNode,那就往下遍历
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
// 无果而返
return null;
}
get步骤总结:
- 计算hash值,定位到该table索引位置,如果是首节点符合就返回
- 如果遇到扩容的时候,会调用标志正在扩容节点ForwardingNode的find方法,查找该节点,匹配就返回
- 以上都不符合的话,就往下遍历节点,匹配就返回,否则最后就返回null
3. 总结
JDK1.7版本,ReentrantLock+Segment+HashEntry;JDK1.8版本,synchronized+CAS+Node+红黑树。其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发。
- 1.7和1.8都是进行了所粒度的减小。JDK1.7版本锁的粒度是基于Segment的,Segment包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。
- JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
- JDK1.8使用红黑树来优化链表,当链表较长时,搜索的复杂度从O(N)优化到O(log(N));
- JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据