ConcurrentHashMap
作者:CloudMissing
写在前面
ConcurrentHashMap 是 HashMap 的线程安全版本,它更像 hashTable 而非 hashMap,它同样不允许 key 或 value 为 null 值。ConcurrentHashMap 被设计的主要目的就是为了提供并发可读性,并且更大程度减少更新操作的资源争抢。
对此,在 JDK1.7 到 JDK 1.8 的演变过程做了重大的改变。
从如何保证线程安全角度去看:JDK 1.7 采用的是分段锁(Segment 继承了 ReentrantLock)和 CAS 的操作来保证线程安全;JDK 1.8 采用的是 sychronized 关键字和 CAS 的操作来保证线程安全。
从锁的实现粒度去看:JDK 1.7 中的分段锁是锁的整个 Segment 对象;JDK 1.8 中的 sychronized 关键字锁的是数据数组的索引下标位置的元素。锁的粒度更加细化,同时也保证了同一时间可以有更多的线程操作,提高了并发性能。(关于 JDK 1.7/之后 中的数据如何存放将在下文介绍)。
从内部的数据结构去看:JDK 1.7 使用的是内部数据节点 HashEntry,采用的数据结构是 数组 + 链表;JDK 1.8 使用的是实现了 Map.Entry 接口的 Node、MapEntry 节点,采用的数据结构是 数组 + 链表 + 红黑树。
静态声明
/*
* Encodings for Node hash fields. See above for explanation.
*/
// ForwardNode 对象内部的 hash 值,用于判断当前内部的 table 是否处于扩容阶段
static final int MOVED = -1; // hash for forwarding nodes
// Treebins 对象内部的 hash 值,当链表树话之后,数组索引下标指向的对象就是该节点,内部持有 TreeNodes 的 root 节点引用
static final int TREEBIN = -2; // hash for roots of trees
// ReserveNode 对象内部的 hash 值,只是在 computeIfAbsent 或其他计算方法,当索引下标元素为空时的占位符
static final int RESERVED = -3; // hash for transient reservations
// 因为当前内部的 hash 最高位用作了特殊控制,所以在散列的时候,因为是采用的 高 16 位和低 16 位的异或方式以高位参与运算的方式(增加均匀散列的概率),但散列之后的结果不能保证首位是 0,所以 spread 方法在得到异或之后的结果又与 2^32 进行了 & 操作,目的就是为了保证首位是 0,用作符号位。防止正常的 hash 散列充当了内部的特殊控制节点
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash 实际上是 2^32 ,Integer 的最大值
// 注:其他的静态属性基本都与 hashMap 相同,除了为了兼容并发操作的
线程安全的保证
JDK 1.8 版本的 ConcurrentHashMap 做了重大的变化,一方面是因为数据结构在原来的基础上新增了红黑树结构,另一方面是为了提高这个类的并发读效率,在线程安全的前提下,保证更多的线程可以同时执行并发更新操作。
链表结构的并发安全措施:
JDK 1.7 : 采用的是分段锁 + CAS 的方式来保证多线程情况下安全的并发写操作。Segment 继承了 ReentrantLock,并且 ConcurrentHashMap 内部维护了 Segement 数组,Segment 数组中的 table 数组存储了真正的数据节点(HashEntry),每次的 Put 或 Remove 操作都是使用 tryLock() 方法获取独占锁,如果获取成功就继续后续的操作,获取失败就一直尝试获取,当尝试的次数到达尝试最大次数(64)时,就执行 lock() 方法,直接阻塞,等待释放锁之后的唤醒或者自己打断自己。同时,锁定中的表元素或者 entry 节点的 “next” 字段写入采用了更加优秀的 “lazySet” 的方式例如 putOrderedObject,因为这些操作总是伴随着锁释放,保证了表更新的顺序一致性。
JDK 1.8 :优化了 1.7 版本的并发安全控制方式,由原来的分段锁 + CAS,变换为了 synchronized 关键字 + CAS 的方式。由于分段锁的粒度过大,直接对一个 Segement 段进行了锁操作,导致同一时间一个段只能有一个线程操作。所以 synchronized 的粒度变得更小,只对链表的首位进行同步锁定操作,因为链表的更新操作都是先验证首位,首位不管是 put 还是 remove 都会存在,除非整个桶内没有数据。同时,如果插入的桶内没有元素,那么此时就会采用 CAS 的方式,而非加锁。但是 JDK 1.8 依然保留了 Segment 这个类以及老版本的一些内容,但是并没有使用。
JDK 1.8 的红黑树结构并发安全保证:
在 JDK 1.8 新加入了红黑树的数据结构。TreeBin 并不持有 key 和 value,而是指向了 TreeNode 列表及其根节点,实现了简易版的读写锁来控制多线程情况下树的更新操作。每次尝试都是锁定 root 节点,通过判断当前的锁定状态(lockState),如果当前是读锁,那么写锁会强制等到读锁完成。
锁定状态:WRITER = 1 WAITER = 2 READER = 4 下面是 lockRoot 的部分代码
private final void lockRoot() { if (!U.compareAndSwapInt(this, LOCKSTATE, 0, WRITER)) contendedLock(); // offload to separate method } private final void contendedLock() { boolean waiting = false; for (int s;;) { if (((s = lockState) & ~WAITER) == 0) { if (U.compareAndSwapInt(this, LOCKSTATE, s, WRITER)) { if (waiting) waiter = null; return; } } else if ((s & WAITER) == 0) { if (U.compareAndSwapInt(this, LOCKSTATE, s, s | WAITER)) { waiting = true; waiter = Thread.currentThread(); } } else if (waiting) LockSupport.park(this); } }
put 操作
对于 ConcurrentHashMap 的插入操作,是不允许key为空或者value为空的。
1、table 为初始化会进行初始化操作
2、如果计算的数组索引下标值为 NULL,尝试使用 CAS 修改当前的值,如果修改失败,则尝试再次循环遍历
3、如果当前索引节点的 hash 值为 MOVED(-1),就代表当前处于扩容阶段,当前线程会加入扩容操作,结束之后再次进行重新求索引下标遍历
3、如果计算的数组索引下标存在值,首先锁住当前 first 节点,然后再次判断当前节点是否为 first 节点,防止刚获取完锁之后,该节点被删除或者触发扩容操作。
1)如果当前节点是链表,会根据节点按照顺序进行遍历,如果找到 key 的 hash 相同,并且对象相等或值相等的,直接进行替换,并返回 oldValue。如果未找到相同的,则直接加入链表的尾部,返回 NULL。在此过程中,如果当前链表的节点大于等于8,则会触发树化操作,如果 table 数组大小未超过最小树化大小,则进行扩容操作(tryPresize),否则就进行树化操作。
2)如果当前节点是红黑树。拿到当前节点的 root 对象引用,执行树的插入操作。执行的判断逻辑与读取时一致,首先判断二者 key 的hash大小,如果root对象大于当前节点,则遍历右子树,小于则遍历左子树。如果二者的 hash 一致,比较二者key对象地址或equals方法,如果为true,则进行value值替换,返回就对象。否则那么就会采用一下三种方案进比较是遍历左子节点还是右子节点:
如果二者是同一个类,并且都实现了 Comparable ,会使用 Comparable 比较
比较二者类名称(String 类型),因为 String 类型实现了 Comparable 方法
最后使用 identityHashCode 方法,该方法会返回 object 的 hashCode 方法对应的值,不论对象是否重新hashCode
之后重复 1)和 2)操作,直到找到满足条件的 key 替换 value 或者 为NULL的子节点,将 key 和 value 封装为 Node 对象作为该子节点插入
对于红黑树,在插入的过程中是需要锁 root 节点。因为要满足并发读写,其次是因为在插入的时,为了达到平衡的效果,需要进行左旋或右旋的操作,会导致 root 节点变化。如果此时不锁 root 节点,读操作读到的结果会有模糊性
部分源码:
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)))) {
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) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
并发读写
ConcurrentHashMap 对并发读的操作不会进行同步操作,只是返回的结果可能具有误导性,也就是不是最准确的
下面是 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;
}
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;
}
分以下两种场景(如果存在当前 key 的情况下,否则就是返回 NULL):
1、读的过程中,不是处于扩容阶段
如果索引下标的节点的key与目标对象匹配(key 地址相等或者equals相等),直接返回下标节点的值
如果当前索引下标对应的是链表,遍历当前链表,如果找到与目标 hash 值相同,并且满足对象相同逻辑(地址相等或者equals相等),就直接返回当前节点的值
如果当前索引下标对应的是TreeBin(已经树化),hash 值为 -2,小于 0。TreeBin 内部持有当前索引下标平衡排序树的 root 节点引用,从 root 节点开始进行查询,具体过程为 :
一、首先尝试获取读锁,采用 CAS 的方式,获取成功则执行
二、查询 key 与树节点的 hash比较大小,大于当前树节点,则比较当前树节点的右子节点。小于当前树节点,则比较当前树节点的左节点。如果 hash 值和 (对象地址或equals方法为true)都相同,就直接返回该对象。否则那么就会采用一下三种方案进比较是遍历左子节点还是右子节点:
1)如果二者是同一个类,并且都实现了 Comparable ,会使用 Comparable 比较
2)比较二者类名称(String 类型),因为 String 类型实现了 Comparable 方法
3)最后使用 identityHashCode 方法,该方法会返回 object 的 hashCode 方法对应的值,不论对象是否重新hashCode
三、直到左子节点或右子节点为空,找不到结果就返回 NULL
2、读的过程中,处于扩容阶段
如果处于扩容阶段,数组索引下标如果对应的数据结构不是 TreeBin,都会被内部的扩容控制对象(ForwardNode)替换,内部 hash 值为 -1
在扩容阶段,ConcurrentHashMap 为了保证线程安全,采用了 newTable(为原数组的二倍) 做数据迁移,这个数组只有在初始化或扩容阶段有值,否则都为 NULL。
ForwardNode 内部都有一个 newTable 数组引用,方便在扩容时,从对应的 newTable 数组中查询对象
1、如果是链表或普通节点。调用 find 方法。如果 newTable 为初始化完成或者原索引下标对应的值还未进行数据迁移,这个时候就会直接返回 NULL。否则就会进行遍历:
1)如果当前节点 key 的 hash 与查询 key hash相同,并且地址或equals相同,那么就返回当前节点的值。否则会便利 next 节点,如果为空,则返回 NULL
2)如果当前索引下标节点为 ForwardNode,代表已经扩容了该 key,但数据还未转移完成,所以,会递归进行 find 方法调用。
3)如果遍历到最后,没有找到匹配的Node 节点,也会返回 NULL
2、如果是 TreeBin。对于红黑树的并发读写操作是有控制的,实现了简易版的读写锁,如果在写操作的过程中,存在读操作。此时写操作就会阻塞,直到读操作完成。如果读操作的过程中,存在写操作,自然直接阻塞。所以就不会存在中间数据迁移的情况
总结
在 JDK 1.8 之后的 ConcurrentHashMap 的性能已经无限接近 HashMap,甚至说比它的性能还要好。所以在 JDK 1.8 之后牵扯到并发问题时,尽量使用 ConcurrentHashmap 。