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()