简介:
ConcurrentHashMap 是 J.U.C 包里面提供的一个线程安全并且高效的 HashMap,数据结构(jdk1.7到jdk1.8)变更为了数组+单向链表+红黑树的结构,取消了 segment 分段设计(1.7对segment加锁),直接使用 Node 数组来保存数据(1.8对Node加锁),并发操作时效率更高。
类图结构:
源码中的重要方法:
1 spread方法:根据hashCode计算hash值
// 2^31 - 1,int类型的最大值
// 该掩码表示节点hash的可用位,用来保证hash永远为一个正整数
static final int HASH_BITS = 0x7fffffff;
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
上面源码为计算hash算法,其实ConcurrentHashMap的散列函数与HashMap并没有什么区别,同样是把key的hash code的高16位与低16位进行异或运算(因为ConcurrentHashMap的buckets数组长度也永远是一个2的N次方),不同的是h ^ (h >>> 16)计算结果&(与) 0x7fffffff,从而保证结果一定为正整数。获得hash之后,通过hash & (n -1)计算下标,得出的结果即是目标所在的位置。
2 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) {
//key和value不能为空,hashmap中key和value均可为空
if (key == null || value == null) throw new NullPointerException();
//根据key计算hash值
int hash = spread(key.hashCode());
//用来记录链表的长度
int binCount = 0;
//这里其实就是自旋操作,当出现线程竞争时不断自旋 ,无限循环+CAS,无锁的标准套路。
//自旋锁是计算机科学用于多线程同步的一种锁,线程反复检查锁变量是否可用。自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
// 如果数组为空,则对数组进行初始化
tab = initTable();
//通过 hash 值对应的数组下标得到第一个节点; 以 volatile 读的方式来读取 table 数组中的元素,保证每次拿到的数据都是最新的
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果该下标返回的节点为空,则直接通过 cas 将新的值封装成 node 插入即可;如果 cas 失败,说明存在竞争,则进入下一次循环
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果对应的节点存在,判断这个节点的 hash 是不是等于 MOVED(-1),说明当前节点是ForwardingNode 节点,意味着有其他线程正在进行扩容,那么当前现在直接帮助它进行扩容。
else if ((fh = f.hash) == MOVED)
//bucket为ForwardingNode,当前线程前去协助进行扩容
tab = helpTransfer(tab, f);
else {
//进入到这个分支,说明 f 是当前 nodes 数组对应位置节点的头节点,并且不为空
V oldVal = null;
给对应的头结点加锁
synchronized (f) {
// 再次判断当前位置是否是f节点
if (tabAt(tab, i) == f) {
//头结点的 hash 值大于 0,说明是链表
if (fh >= 0) {
//binCount 用来记录链表的长度
binCount = 1;
//遍历链表
for (Node<K,V> e = f;; ++binCount) {
K ek;
//如果发现相同的 key,则判断是否需要进行值的覆盖
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
//默认onlyIfAbsent为false,直接覆盖旧的值
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;
}
}
}
//如果当前的 f 节点是一颗红黑树
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);
//如果 val 是被替换的,则返回替换之前的值
if (oldVal != null)
return oldVal;
break;
}
}
}
//将当前 ConcurrentHashMap 的元素数量加 1,有可能触发 transfer 操作(扩容)
addCount(1L, binCount);
return null;
}
在不加锁的情况下:线程 A 成功执行 casTabAt 操作后,随后的线程 B 可以通过 tabAt 方法立刻看到 table[i]的改变。原因如下:线程 A 的casTabAt 操作,具有 volatile 读写相同的内存语义,根据 volatile 的 happens-before 规则:线程 A 的 casTabAt 操作,一定对线程 B 的 tabAt 操作可见。
关于volatile的特性可参考 Volatile关键字详解
3 initTable方法:数组初始化
ConcurrentHashMap与HashMap一样是Lazy的,buckets数组会在第一次访问put()函数时进行初始化。源码分析如下:
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
// sizeCtl是table初始化和resize控制字段。负数表示table正在初始化或resize。-1表示正在初始化,-N表示有N-1个线程正在resize操作,0 标识 Node 数组还没有被初始化,正数代表初始化或者下一次扩容的大小。
//sizeCtl)< 0表明被其他线程抢占了初始化的操作,则直接让出自己的 CPU时间片。
Thread.yield(); // lost initialization race; just spin
///通过 cas 操作,将 sizeCtl 替换为-1,标识当前线程抢占到了初始化资格
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
// sizeCtl = 0,使用默认容量(16)进行初始化, 否则,会根据sizeCtl进行初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
计算下次扩容的大小,实际就是当前容量的 0.75倍,这里使用了右移来计算
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
4 helpTransfer方法
顾名思义该函数的作用是协助扩容,源码如下:
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
判断此时是否仍然在执行扩容,nextTab=null 的时候说明扩容已经结束了
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
//生成扩容戳
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
//说明扩容还未完成的情况下不断循环来尝试将当前线程加入到扩容操作中。下面部分的整个代码表示扩容结束,直接退出循环
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// //在低 16 位上增加扩容线程数
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
//协助扩容
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
说明:
1 transferIndex<=0 表示所有的 Node 都已经分配了线程 。
2 sc=rs+MAX_RESIZERS 表示扩容线程数达到最大扩容线程数。
3 sc==rs+1 表示扩容结束
4 sc >>> RESIZE_STAMP_SHIFT !=rs, 如果在同一轮扩容中,那么 sc 无符号右移比较高位和 rs 的值,那么应该是相等的。如果不相等,说明扩容结束了 。
5 treeifyBin方法
使用场景:判断链表的长度是否已经达到临界值 8. 如果达到了临界值,这个时候会根据当前数组的长度来决定是扩容还是将链表转化为红黑树。也就是说如果当前数组的长度小于 64,就会先扩容。否则,会把当前链表转化为红黑树。
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//tab 的长度是不是小于 64,如果是,则执行扩容
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
{//否则,将当前链表转化为红黑树结构存储
synchronized (b) {
if (tabAt(tab, index) == b) {
TreeNode<K,V> hd = null, tl = null;
for (Node<K,V> e = b; e != null; e = e.next) {
TreeNode<K,V> p =
new TreeNode<K,V>(e.hash, e.key, e.val,
null, null);
if ((p.prev = tl) == null)
hd = p;
else
tl.next = p;
tl = p;
}
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
6 get方法
说明:get函数根据key查找键值对步骤如下:
1 根据key值通过计算得到数组下标的桶,比较桶中第一个Node是否是要查找的节点,是就返回,不是进行第二步。
2 判断桶中第一个Node是否是红黑树,如果是则按照红黑树的方式遍历查找,如果不是则进行第三步。
3 进入第三步说明该桶是一个链表,遍历链表查找,如果还是查找不到返回null。
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 根据key获取hash值
int h = spread(key.hashCode());
// 如果表不为空,计算后的key所在的桶不为空
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;
}
// eh < 0代表这是一个特殊节点(TreeBin或ForwardingNode),所以直接调用find()进行遍历查找。
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// eh < 0,则说明该节点上存放的是一个链表,循环遍历查找。
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
总结:ConcurrentHashMap的操作总体上跟HashMap相似,只不过ConcurrentHashMap在每个桶上都加锁,从而实现并发操作的安全性。HashMap相关原理可参考 HashMap深度解析
Java 8除了对ConcurrentHashMap重新设计以外,还引入了基于Lambda表达式的Stream API。它是对集合对象功能上的增强,以一种优雅的方式来批量操作、聚合或遍历集合中的数据。
Java 8中ConcurrentHashMap最精华的部分是它可以利用多线程来进行协同扩容。简单来说,它把 Node 数组当作多个线程之间共享的任务队列,然后通过维护一个指针来划分每个线程锁负责的区间,每个线程通过区间逆向遍历来实现扩容,一个已经迁移完的bucket 会被替换为一个 ForwardingNode 节点,标记当前 bucket 已经被其他线程迁移完了。