本文着重讲解ConcurrentHashmap的源码实现和原理,JDK8中ConcurrentHashmap摒弃了分段锁技术的实现,直接采用CAS和Synchronized保证并发更新安全性,底层采用数组+链表+红黑树的存储结构。其数据结构如下:
说明:数据结构采用数组 + 链表 + 红黑树的方式实现。当链表中的节点个数超过8个时,会转换成红黑树的数据结构存储,这样设计的目的是为了减少同一个链表冲突过大情况下的读取效率。
下面看看几个内部类
(1)Node类:存放元素的key,value,hash值,next下一个链表节点的引用。用于bucket为链表时。
(2)TreeBin:内部属性有root,first节点,以及root节点的锁状态变量lockState,这是一个读写锁的状态。用于存放红黑树的root节点,并用读写锁lockState控制在写操作即将要调整树结构前,先让读线程完成读操作。从链表结构调整为红黑树时,table中索引下标存储的即为TreeBin。
(3)TreeNode:红黑树的节点,存放了父节点,左子节点,右子节点的引用,以及红黑节点标识。
(4)ForwardingNode:在调用transfer()方法期间,插入bucket头部的节点,主要用来标识在扩容时元素的移动状态,即是否在扩容时还有并发的插入节点,并保证该节点也能够移动到扩容后的表中。
(5)ReservationNode:占位节点,不存储任何信息,无实际用处,仅用于computeIfAbsent和compute方法中。
下面看几个重要的方法;
put方法:该方法调用putVal方法
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());// 计算key的hash值
int binCount = 0;// 表示table中索引下标代表的链表或红黑树中的节点数量
// 采用自旋方式,等待table第一次put初始化完成,或等待锁或等待扩容成功然后再插入
for (Node<K,V>[] tab = table;;) {
// f节点标识table中的索引节点,可能是链表的head,也可能是红黑树的head
// n:table的长度,i:插入元素在table的索引下标,fh : head节点的hash值
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)// 第一次插入元素,先执行初始化
tab = initTable();
// 定位到的索引下标节点(head)为null,表示第一次在此索引插入,
// 不加锁直接插入在head之后,在casTabAt中采用Unsafe的CAS操作,保证线程安全
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
}
// head节点为ForwadingNode类型节点,表示table正在扩容,链表或红黑树也加入到帮助扩容操作中
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {// 索引下标存在元素,且为普通Node节点,给head加锁后执行插入或更新
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {// 为普通链表节点,还记得之前定义的几种常量Hash值吗?
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;
// 插入新元素,每次插在单向链表的末尾,这点与Java7中不同(插在首部)
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
break;
}
}
}
else if (f instanceof TreeBin) {// head为树节点,按树的方式插入节点
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;
}
}
}
}
// 链表节点树超过阈值8,将链表转换为红黑树结构
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 如果是插入新元素,则将链表或红黑树最新的节点数量加入到CounterCells中
addCount(1L, binCount);
return null;
}
putVal方法剖析:
- 采用自旋的方式,保证首次put时,当前线程或其他并发put的线程等待table初始化完成后再次重试插入。
- 采用自旋的方式,检查当前插入的元素在table中索引下标是否正在执行扩容,如果正在扩容,则帮助进行扩容,完成后,重试插入到新的table中。
- 插入的table索引下标不为空,则对链表或红黑树的head节点加synchronized锁,再插入或更新。访问入口是Head节点,其他线程访问head,在链表或红黑树插入或修改时必须等待synchronized释放。
- 插入后,如果发现链表节点数大于等于阈值8,调用treeifyBin方法,将链表转换为红黑树结构,提高读写性能。treeifyBin方法内部也同样采用synchronized方式保证线程安全性。
- 插入元素后,会将索引代表的链表或红黑树的最新节点数量更新到baseCount或CounterCell中。
其中用到了不少方法,下面我们逐个分析
spread方法:计算key的hash值,将key的hashCode的高16位也加入到计算中,避免平凡冲突,使其更加散列。如果仅用key的hashCode作为hash值,那么2,4之类的整形key值,只有低4位,那么很容易发生冲突。
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
initTable方法
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// sizeCtl小于0,表示table正在被其他线程执行初始化,
// 放弃初始化竞争,自旋等待初始化完成
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
初始化表比较简单:
1、自旋检查table是否完成初始化。
2、若发现sizeCtl值为负数,则放弃初始化的竞争,让其他正在初始化的线程完成初始化。
3、如果没有其他线程初始化,则用Unsafe.compareAndSwapInt更新sizeCtl的值为-1,表示table开始被当前线程执行初始化,其他线程禁止执行。
4、初始化:table设置为默认容量大小(元素并未初始化,只是划定了大小),sizeCtl设为下次扩容table的size大小。
5、初始化完成。
tabAt和casTabAt方法
这两个方法比较简单,都是利用Unsafe的CAS方法保证读取和替换的原子性,保证线程安全。为什么table本身明明用了volatile修饰,不直接用table[i]的方式取节点,而非要用Unsafe.getObjectVolatile方法的CAS操作取节点。虽然table本身是volatile类型,但仅仅是指table数组引用本身,而数组中每个元素并不是volatile类型,Unsafe.getObjectVolatile保证了每次从table中读取某个位置链表引用的时候都是从主内存中读取的,如果不用该方法,有可能读的是缓存中已有的该位置的旧数据。
扩容机制
JDK8最复杂的莫过于扩容机制,jdk8中,采用多线程扩容。整个扩容过程,通过CAS设置sizeCtl,transferIndex等变量协调多个线程进行并发扩容。
nextTable属性
/**
* The next table to use; non-null only while resizing.
扩容时,将table中的元素迁移至nextTable . 扩容时非空
*/
private transient volatile Node<K,V>[] nextTable;
sizeCtl属性
private transient volatile int sizeCtl;
多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。
private transient volatile int transferIndex;
扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)
1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。
2 扩容线程,在迁移数据之前,首先要将transferIndex右移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。每个扩容线程都会通过for循环+CAS的方式设置transferIndex,因此可以确保多线程扩容的并发安全。
换个角度,我们可以将待迁移的table数组,看成一个任务队列,transferIndex看成任务队列的头指针。而扩容线程,就是这个队列的消费者。扩容线程通过CAS设置transferIndex索引的过程,就是消费者从任务队列中获取任务的过程。为了性能考虑,我们当然不会每次只获取一个任务(hash桶),因此ConcurrentHashMap规定,每次至少要获取16个迁移任务(迁移16个hash桶,MIN_TRANSFER_STRIDE = 16)
cas设置transferIndex的源码如下:
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
//计算每次迁移的node个数
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // 确保每次迁移的node个数不少于16个
...
for (int i = 0, bound = 0;;) {
...
//cas无锁算法设置 transferIndex = transferIndex - stride
if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
...
...
}
...//省略迁移逻辑
}
}
ForwardingNode节点
-
标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕
-
关联了nextTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
//hash值为MOVED(-1)的节点就是ForwardingNode
super(MOVED, null, null, null);
this.nextTable = tab;
}
//通过此方法,访问被迁移到nextTable中的数据
Node<K,V> find(int h, Object k) {
...
}
}
那么什么时候会扩容呢?
当前容量超过阈值
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
addCount(1L, binCount);
...
}
private final void addCount(long x, int check) {
...
if (check >= 0) {
Node<K,V>[] tab, nt; int n, sc;
//s>=sizeCtl 即容量达到扩容阈值,需要扩容
while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
(n = tab.length) < MAXIMUM_CAPACITY) {
//调用transfer()扩容
...
}
}
}
当链表中元素个数超过默认设定(8个),当数组的大小还未超过64的时候,此时进行数组的扩容,如果超过则将链表转化成红黑树
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
if (binCount != 0) {
//链表中元素个数超过默认设定(8个)
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
...
}
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//数组的大小还未超过64
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
//扩容
tryPresize(n << 1);
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
//转换成红黑树
...
}
}
}
当发现其他线程扩容时,帮其扩容
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
//f.hash == MOVED 表示为:ForwardingNode,说明其他线程正在扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
...
}
扩容过程图
扩容线程A 以cas的方式修改transferindex=31-16=16 ,然后按照降序迁移table[31]--table[16]这个区间的hash桶
迁移hash桶时,会将桶内的链表或者红黑树,拆分成2份,将其插入nextTable[i]和nextTable[i+n](n是table数组的长度)。 迁移完毕的hash桶,会被设置成ForwardingNode节点,以此告知访问此桶的其他线程,此节点已经迁移完毕
此时线程2访问到了ForwardingNode节点,如果线程2执行的put或remove等写操作,那么就会先帮其扩容。如果线程2执行的是get等读方法,则会调用ForwardingNode的find方法,去nextTable里面查找相关元素。
如果准备加入扩容的线程,发现以下情况,放弃扩容,直接返回。发现transferIndex=0,即所有node均已分配.发现扩容线程已经达到最大扩容线程数