ConcurrentHashMap底层原理分析
一、简介
- ConcurrentHashMap是J.U.C包里提供的一个线程安全且高效的HashMap,类继承关系如图:
- ConcurrentHashMap基本结构:
内部维护一个存放Node结点的数组,即table。数组的每个位置代表了一个桶,table可以包含4种不同类型的桶:Node、TreeBin、ForwardingNode、ReservationNode。
- ConcurrentHashMap包含五种结点:
- Node结点:普通的Entry结点,以链表形式存储实际数据;
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;
}
public final int hashCode() {
return key.hashCode() ^ val.hashCode();
}
//链表查找
Node<K, V> find(int h, Object k) {...}
...
}
数据会先以链表的形式存放在Node结点中,当结点到达一定数目时,链表会转化成红黑树,因为链表查找的平均时间复杂度为O(n),而红黑树为O(logn),这样可以提高查找效率。
- TreeNode结点:红黑树的结点,存储实际数据;
static final class TreeNode<K, V> extends Node<K, V> {
boolean red;
TreeNode<K, V> parent;
TreeNode<K, V> left;
TreeNode<K, V> right;
TreeNode<K, V> prev;
TreeNode(int hash, K key, V val, Node<K, V> next,
TreeNode<K, V> parent) {
super(hash, key, val, next);
this.parent = parent;
}
//以当前结点为根结点,开始遍历查找指定key
Node<K, V> find(int h, Object k) {...}
...
}
- TreeBin结点:提供了红黑树相关的操作,以及加解锁操作;
static final class TreeBin<K, V> extends Node<K, V> {
TreeNode<K, V> root; // 红黑树结构的根结点
volatile TreeNode<K, V> first; // 链表结构的头结点
volatile Thread waiter; // 最近的一个设置WAITER标识位的线程
volatile int lockState; // 整体的锁状态标识位,0表示未加锁
static final int WRITER = 1; // 二进制001,红黑树的写锁状态
static final int WAITER = 2; // 二进制010,红黑树的等待获取写锁状态
static final int READER = 4; // 二进制100,红黑树的读锁状态,读可以并发,每多一个读线程,lockState都加上一个READER值
//在hashCode相等并且不是Comparable类型时,用此方法判断大小
static int tieBreakOrder(Object a, Object b) {...}
//将以b为头结点的链表转换为红黑树
TreeBin(TreeNode<K, V> b) {...}
//对红黑树的根结点加写锁
private final void lockRoot() {...}
//释放写锁
private final void unlockRoot() {...}
/**
* 从根结点开始遍历查找,找到“相等”的结点就返回它,没找到就返回null
* 当存在写锁时,以链表方式进行查找
* 两种特殊情况下以链表的方式进行查找:
* 1. 有线程正持有写锁,这样做能够不阻塞读线程
* 2. 有线程等待获取写锁,不再继续加读锁,相当于“写优先”模式
*/
final Node<K, V> find(int h, Object k) {...}
//查找指定key对应的结点,如果未找到,则插入.
final TreeNode<K, V> putTreeVal(int h, K k, V v) {...}
/**
* 删除红黑树的结点:
* 1. 红黑树规模太小时,返回true,然后进行 树 -> 链表 的转化;
* 2. 红黑树规模足够时,不用变换成链表,但删除结点时需要加写锁.
*/
final boolean removeTreeNode(TreeNode<K, V> p) {...}
...
}
- ForwardingNode结点:在扩展时使用,是一种临时结点,hash值固定为-1,不存储实际数据。扩展时如果旧table某一个桶中的结点已经全部迁移到新的table了,则在这个桶中放置一个FowardingNode结点。另外作为转发结点,在扩容期间如果遇到查询操作,会将该查询转发到nextTable,即新table上,不会阻塞查询;
static final class ForwardingNode<K, V> extends Node<K, V> {
final Node<K, V>[] nextTable;
ForwardingNode(Node<K, V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
// 在新的数组nextTable上进行查找
Node<K, V> find(int h, Object k) {...}
}
读操作碰到ForwardingNode时,将操作转发到扩容后的新table数组上去执行;写操作碰到它时,则尝试协助扩容。
- ReservationNode结点:保留结点,hash值固定为-3,不存储实际数据,只在computeIfAbsent和compute这两个方法充当占位符加锁使用;
- ConcurrentHashMap提供了5个构造器,但是构造器内部只是计算了table的初始容量(赋值给sizeCtrl),并没有创建table的操作,只有在首次插入键值对的时候才会进行初始化table数组的操作。
如果new实例时传入初始大小initialCapacity,那会调用tableSizeFor返回大于initialCapacity的最小2次幂值,并把这个值设置到sizeCtrl。
private static final int tableSizeFor(int c) { int n = c - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
代码各个阶段的结果如下图所示:
如果传入初始大小initialCapacity和负载因子loadFactor,则还需要通过1+initialCapacity/loadFactor计算出实际的初始化大小。一个结点在桶中出现的概率符合泊松分布,使用0.75作为负载因子,可以降低结点在某一个桶中出现的概率,降低hash冲突,也就降低了查询时间,同时也不会因为负载因子过小导致table过大,占用过多的内存。
二、常量
private static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
private static final int DEFAULT_CAPACITY = 16; //默认初始容量
private static final float LOAD_FACTOR = 0.75f; //负载因子,恒定为0.75
static final int TREEIFY_THRESHOLD = 8; //链表转树的阈值,即链接结点数大于8时, 链表转换为树
static final int UNTREEIFY_THRESHOLD = 6; //树转链表的阈值,即树结点树小于6时,树转换为链表
static final int MIN_TREEIFY_CAPACITY = 64; //只有键值对数量大于MIN_TREEIFY_CAPACITY,链表才能转变成树
private static final int MIN_TRANSFER_STRIDE = 16; //只有键值对数量小于MIN_TRANSFER_STRIDE,树才能转变成链表
private static int RESIZE_STAMP_BITS = 16; //用于在扩容时生成唯一的随机数
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1; // 可同时进行扩容操作的最大线程数
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS; // 扩容用
static final int MOVED = -1; // 标识ForwardingNode结点(在扩容时才会出现,不存储实际数据)
static final int TREEBIN = -2; // 标识红黑树的根结点
static final int RESERVED = -3; // 标识ReservationNode结点
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
static final int NCPU = Runtime.getRuntime().availableProcessors(); // CPU核心数,扩容时使用
三、字段定义
transient volatile Node<K, V>[] table; //Node数组,标识整个Map,首次插入元素时创建,大小总是2的幂次
private transient volatile Node<K, V>[] nextTable; //扩容后的新Node数组,只有在扩容时才非空
/**
* 控制table的初始化和扩容
* 0 : 初始默认值
* -1 : 有线程正在进行table的初始化
* >0 : table初始化时使用的容量,或初始化/扩容完成后的阈值
* -(1 + nThreads) : 表示有nThreads个线程正在执行扩容
*/
private transient volatile int sizeCtl;
private transient volatile int transferIndex;//扩容时需要用到的一个下标变量
private transient volatile long baseCount;//计数基值,当没有线程竞争时,计数将加到该变量上
private transient volatile CounterCell[] counterCells;//计数数组,出现并发冲突时使用
private transient volatile int cellsBusy;//自旋标识位,用于CounterCell[]扩容时使用。
// 视图相关字段
private transient KeySetView<K, V> keySet;
private transient ValuesView<K, V> values;
private transient EntrySetView<K, V> entrySet;
四、put方法源码分析
从put()方法切入,实际调用了putVal()方法,主要做了以下事情:
- 如果key和value为null时,抛出NullPointerException
- 调用扰动函数获取哈希值,对key的hashCode进行高16位不变,低16位和高16位进行异或操作,然后将得出的值去掉符号位。
static final int HASH_BITS = 0x7fffffff;
static final int spread(int h) {
return (h ^ (h >>> 16)) & HASH_BITS;
}
- 遍历table,进行以下判断:
- 如果table为空,则开始初始化数组。当sizeCtrl小于0时,表示已经有线程正在进行初始化,此时让出当前线程的CPU占用权;否则通过CAS将sizeCtrl设置成-1,表示当前线程已经开始初始化table。创建一个大小为16(默认大小,如果sizeCtrl大于0,则使用sizeCtrl作为table大小,new一个ConcurrentHashMap的时候会设置sizeCtrl作为初始大小)的Node数组赋值给table,并把sizeCtrl设置成0.75,然后进行下一步。
transient volatile Node<K,V>[] table;
private transient volatile int sizeCtl;
private static final int DEFAULT_CAPACITY = 16;
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
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); //设置sizeCtrl = 0.75 * table.length
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
- 如果已经table已经初始化,但是table中索引为(n-1)&hash的位置没有值。则将Node节点CAS进table中索引为(n-1)&hash的位置。
private static final long ABASE;
private static final int ASHIFT;
Class<?> ak = Node[].class;
ABASE = U.arrayBaseOffset(ak);
int scale = U.arrayIndexScale(ak);
if ((scale & (scale - 1)) != 0)
throw new Error("data type scale not a power of two");
ASHIFT = 31 - Integer.numberOfLeadingZeros(scale);
transient volatile Node<K,V>[] table;
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
//table虽然是volatile变量,但对于其他线程只是引用可见,table里的元素不可见,因此需要调用getObjectVolatile保证每次拿到的数据都是最新的。
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
// 更新Node数组上的元素
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
由于是通过(n-1)&hash查找元素,因此需要保证n(即table长度)为2的N次幂才能让key均匀分布,减少哈希冲突。
- 如果table中索引为(n-1)&hash的位置有值,且该位置的元素hash属性为MOVED,说明此时table正在扩容,当前线程可以协助数据迁移,参考addCount方法。
static final int MOVED = -1;
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
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;
//将sizeCtrl加1,然后开始扩容+数据迁移
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
扩容状态下其他线程对集合进行插入、修改、删除、合并、compute等操作时遇到 ForwardingNode 结点会调用该方法。
- 如果插入的节点不在table上,则遍历链表或者红黑树,如果存在相同哈希值和Key的节点时更新Node/TreeNode的value,否则插入新的Node/TreeNode结点。
使用同步锁synchronized,插入相同hash的元素时会阻塞。
- 如果链表长度大于8,则将链表转换成红黑树,但是如果table的长度小于64,则只将table扩容一倍,不转换成红黑树,这是为了避免table建立初期,多个键值对恰好放入同一个链表中导致不必要的转换。
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
private static final int MAXIMUM_CAPACITY = 1 << 30;
private final void treeifyBin(Node<K,V>[] tab, int index) {
Node<K,V> b; int n, sc;
if (tab != null) {
//table的容量小于64时,直接进行table扩容,不进行红黑树转换
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);
//table的容量大于等于64时,将链表转换成红黑树
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;
}
//将TreeNode封装成TreeBin,并放到table[index]中
setTabAt(tab, index, new TreeBin<K,V>(hd));
}
}
}
}
}
- 更改计数以及扩容,这里先讨论计数的部分,扩容部分后面再做介绍。如果没有并发,直接累加baseCount,如果存在并发,则累加counterCells。
private final void addCount(long x, int check) {
CounterCell[] as;
long b, s;
if ((as = counterCells) != null ||
// 首先尝试更新baseCount
!U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
// 更新失败,说明出现并发冲突,则将计数值累加到CounterCells[ThreadLocalRandom.getProbe() & counterCells的长度]
CounterCell a;
long v;
int m;
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
//根据线程安全的随机数和counterCells长度计算元素索引
(a = as[ThreadLocalRandom.getProbe() & m]) == null ||
!(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
// 计数值累加到CounterCells失败
fullAddCount(x, uncontended);
return;
}
if (check <= 1)
return;
s = sumCount();
}
...(扩容的内容第七节再讨论)
}
如果累加counterCells失败,则会调用fullAddCount()方法,代码逻辑如下:
- 如果线程安全的随机数为0,则重新生成随机数;
if ((h = ThreadLocalRandom.getProbe()) == 0) {
ThreadLocalRandom.localInit();
h = ThreadLocalRandom.getProbe();
wasUncontended = true;//表示当前不存在锁竞争
}
- 如果counterCells已经初始化,尝试加锁,然后将计数插入或者更新counterCells中,如果失败则进行扩容(原来的2倍);
//counterCells[(n-1)&h]没有被使用(n为counterCells的长度),尝试加锁,然后将计数插入counterCells
if ((a = as[(n - 1) & h]) == null) {
if (cellsBusy == 0) {//counterCells没有加锁
CounterCell r = new CounterCell(x);
if (cellsBusy == 0 &&
//尝试加锁,通过CAS将cellsBusy从0设置为1
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean created = false;
try {
CounterCell[] rs; int m, j;
if ((rs = counterCells) != null &&
(m = rs.length) > 0 &&
rs[j = (m - 1) & h] == null) {//重新判断一次
rs[j] = r;//将创建的CounterCell赋值到counterCells[(n-1)&h]
created = true;
}
} finally {
cellsBusy = 0;//释放锁
}
if (created)//如果创建的CounterCell加到counterCells则停止自旋
break;
continue;
}
}
collide = false;//counterCells扩展标识,锁被占用时不需要扩展
}
else if (!wasUncontended)//待研究。。。
wasUncontended = true;
//如果counterCells[(n - 1) & h]已被使用,则把计数累加到该CounterCell的value值
else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
break;
//当counterCells大小大于CPU核数后,不再进行扩容
else if (counterCells != as || n >= NCPU)
collide = false;
else if (!collide)
collide = true;
//如果更新CounterCell失败,则尝试加锁,然后扩容counterCells为原来的2倍
else if (cellsBusy == 0 &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
try {
if (counterCells == as) {
CounterCell[] rs = new CounterCell[n << 1];//扩容后的大小为原来的2倍
for (int i = 0; i < n; ++i)//将数据迁移到新的counterCells
rs[i] = as[i];
counterCells = rs;
}
} finally {
cellsBusy = 0;//释放锁
}
collide = false;
continue;
}
h = ThreadLocalRandom.advanceProbe(h);
- 如果counterCells没有初始化且没有加锁,则尝试对它进行加锁,如果加锁成功就初始化counterCells,大小为2;
else if (cellsBusy == 0 && counterCells == as &&
U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
boolean init = false;
try {
if (counterCells == as) {
//初始化大小为2的CounterCell数组
CounterCell[] rs = new CounterCell[2];
//将创建的CounterCell存入CounterCell数组,存放索引为0或者1
rs[h & 1] = new CounterCell(x);
counterCells = rs;
init = true;
}
} finally {
//释放锁
cellsBusy = 0;
}
if (init)
break;
}
- 如果counterCells正在进行初始化,则尝试直接累加到baseCount上;
else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
break;
五、结语
本篇主要介绍了ConcurrentHashMap的基本原理,以及添加元素时如何保证一致性,写的比较零散。关于如何计数、如何获取元素、如何扩容有空再整理了。
by alen.wen