ConcurrentHashMap这个集合是在面试中出现频率较高的一个集合,这个集合的源码层面体现了多处并发设计的思想,除此之外,其底层存储结构也有很多值得考量的地方,源码实现中对位运算的运用也相当成熟,因此这个集合源码有很高的学习价值,这里依旧是在大牛博客的基础上进行总结。本篇博客以1.8中的源码为例进行总结。
package com.learn.concurrentSet.hashmap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* autor:liman
* createtime:2019/7/14
* comment: ConcurrentHashMap实例
*/
public class ConcurrentHashMapDemo {
private static int INPUT_NUMBER = 100000;
public static void main(String[] args) throws InterruptedException {
// Map map = new Hashtable<>(12 * INPUT_NUMBER);
Map map = new ConcurrentHashMap<>(12 * INPUT_NUMBER);
long begin = System.currentTimeMillis();
ExecutorService service = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
service.execute(new InputWorker(map, i));
}
service.shutdown();
service.awaitTermination(1, TimeUnit.DAYS);
long end = System.currentTimeMillis();
System.out.println("span time = " + (end - begin) + ", map size = " + map.size());
}
private static class InputWorker implements Runnable {
private static Random rand = new Random(System.currentTimeMillis());
private final Map map;
private final int flag;
private InputWorker(Map map, int begin) {
this.map = map;
this.flag = begin;
}
@Override
public void run() {
int input = 0;
while (input < INPUT_NUMBER) {
int x = rand.nextInt();
if (!map.containsKey(x)) {
map.put(x, "liman " + x);
input++;
}
}
System.out.println("InputWorker" + flag + " is over.");
}
}
}
上述实例比较简单,只是利用几个线程,往ConcurrentHashMap中存取数据。测试中会发现,HashMap效率比HashTable要快的的多。
这里说明一点,ConcurrentHashMap可以支持并发读写,在1.8的版本中,ConcurrentHashMap取消了段(segment)的概念,但是在源码中这个还是有保留的。
ConcurrentHashMap是典型的Hash表的结构,其中是通过一个Node
具体的扩容原则我们后面再详细说明。
private static final int MAXIMUM_CAPACITY = 1 << 30;
private static final int DEFAULT_CAPACITY = 16;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
static final int MOVED = -1; // 表示正在转移
static final int TREEBIN = -2; // 表示已经转换成红黑树
static final int RESERVED = -3; // hash for transient reservations
static final int HASH_BITS = 0x7fffffff; // usable bits of normal node hash
transient volatile Node[] table; //默认没初始化的数组,用来保存元素
private transient volatile Node[] nextTable; //转移的时候的数组
/**
这个值有多重含义
在初始化的时候指定了长度,sizeCtl表示需要扩容的阈值,大小为数组长度的0.75
如果为-1,表示ConcurrentHashMap正在初始化
如果为-(1+n),n表示正在进行扩容的线程个数
*/
private transient volatile int sizeCtl;
static class Node implements Map.Entry {
final int hash; //node的hash值
final K key; //key值
volatile V val; //对应的value值
volatile Node next; //next 节点
Node(int hash, K key, V val, Node next) {
this.hash = hash;
this.key = key;
this.val = val;
this.next = next;
}
}
//可以看到,这个TreeNode继承至Node,所以这里就没有了key和value
static final class TreeNode extends Node {
TreeNode parent; // red-black tree links
TreeNode left;
TreeNode right;
TreeNode prev; // needed to unlink next upon deletion
boolean red;
TreeNode(int hash, K key, V val, Node next,
TreeNode parent) {
super(hash, key, val, next);
this.parent = parent;
}
}
//用来返回数组中指定下标的节点数据,其实质也就是tab[i],只是利用cas来操作,这个是为了避免可见性问题。
static final Node tabAt(Node[] tab, int i) {
return (Node)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//cas原子操作,替换指定位置上的值
static final boolean casTabAt(Node[] tab, int i,
Node c, Node v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//设置指定位置上的值
static final void setTabAt(Node[] tab, int i, Node v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
//如果参数是Map,则将调用putAll,同时将sizeCtl设置为默认大小
public ConcurrentHashMap(Map extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
//如果指定了容量,这将sizeCtl初始化为指定的cap
this.sizeCtl = cap;
}
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
//onlyIfAbsent如果是true,表示只有在不存在该key时才会进行put操作
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();//如果key和value,两者中任意一个为空,则抛出异常
//hash码是32位字符串,这里是取出高4位
int hash = spread(key.hashCode());
int binCount = 0;//用来记录相应链表的长度
//自旋,将元素放入到ConcurrentHashMap中
for (Node[] tab = table;;) {
Node f; int n, i, fh;
//如果table没有数据,或者为空,则初始化table,即第一次放入元素的时候才初始化Node数组。
if (tab == null || (n = tab.length) == 0)
tab = initTable();//这里就是初始化数组
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {//如果对应的Node数组的下标中,没有元素。
//则用cas替换该位置上的元素。
if (casTabAt(tab, i, null,
new Node(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果检测到某个节点的hash值是MOVED,则表示正在进行数组扩容的数据复制阶段。则当前线程也要参与去复制数据的功能。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {//加锁,获得数组该位置头结点的监视器锁
if (tabAt(tab, i) == f) {
if (fh >= 0) {//如果去取出的元素的hash值>0,说明是链表,因为转换为树之后,节点的hash值为-2.
binCount = 1;//用于计数,记录链表长度,如果大于阈值,是要扩容的。
for (Node e = f;; ++binCount) {//遍历链表
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {//如果key和value,hash都相等,则替换该节点的value值。
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
//到了链表末端,将这个新值放入到链表的最后面
Node pred = e;
if ((e = e.next) == null) {//如果不是,则加在链表的尾部。
pred.next = new Node(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {//这里表示,ConcurrentHashMap已经转换为红黑树了,
Node p;
binCount = 2;
//调用putTreeVal方法,将元素放入到ConcurrentHashMap中。
if ((p = ((TreeBin)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)//当在同一个节点的数目达到8个的时候,则扩张数组或将给节点的数据转为tree
treeifyBin(tab, i);//这个方法和HashMap有所不同,如果数组的长度小于64,那么会选择进行数组扩容,而不是转换为红黑树
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);//这个是计数的方法,里面用到了MapReduce的思想,比较复杂
return null;
}
以上的put操作可以用一下文字来总结:当添加一对键值对的时候,首先会判断保存这些键值对的数组是不是初始化了,如果没有初始化的话,就初始化这个数组,然后计算hash值来确定放在数组的那个位置,如果这个位置为空则直接添加,如果不为空的话, 则取出这个节点来,如果取出的节点的hash值是MOVED的话,则表示当前正在对这个数组进行扩容,复制到新数组,则当前线程也去帮助复制,即调用helpTransfer方法。
如果这个节点不为空,也没有扩容,则通过synchronized来加锁,进行添加操作,然后判断当前取出的节点位置存放的是链表还是树,如果是链表的话,则遍历整个链表,将取出的节点key值、value值还有hash值,如果相等,则直接覆盖,如果不等,则加载链表的末尾。如果是树的话,则用putTreeVal方法将其添加到红黑树中去。
添加完元素之后会判断当前的binCount是否大于8,如果大于8则转换成红黑树,或者扩容数组。后面的treeBin就是完成这个操作的。
上述代码几乎就是ConcurrentHashMap的put主流程,但是有几个问题我们还需要深入探讨,第一个是初始化,第二个是扩容,第三个是扩容过程中的数据迁移。
这里涉及一个关键的变量——sizeCtl,这个变量的值有多种含义。这个变量是在Node数组初始化或者扩容的时候的一个控制标志位。
sizeCtl为-1,表示正在初始化
sizeCtl为-N,表示有N-1个线程正在进行扩容。
sizeCtl为0,表示Node数组还没初始化完成。
源码如下:
private final Node[] initTable() {
Node[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)//如果其他线程正在初始化数组。
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) {
//如果szieCtl>0,这个变量指示扩容的大小。否则默认的初始化容量是16
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
//这里就是传说中的初始化Node数组了。
Node[] nt = (Node[])new Node,?>[n];
table = tab = nt;
//sc=sc*0.75,这个时候sc变成了阈值
sc = n - (n >>> 2);
}
} finally {
//sizeCtl记录阈值
sizeCtl = sc;
}
break;
}
}
return tab;
}
可以看到其实也很简单,根据sizeCtl的状态来进行初始化操作,如果sizeCtl<0表示有线程正在初始化,在初始化数组之前先替换sizeCtl的状态,之后根据sc判断初始化的数组容量,默认Node节点数组长度是16,阈值为长度的0.75。
其实扩容分为两种,一种是Node数组的扩容,另一种是链表转红黑树的扩容。前者源码阅读起来难度较大,这里先看看后者。毕竟前置需要再hash.
putVal方法中链表转红黑树,treeifyBin扩容操作发生在如下,如下所示。binCount是用来记录当前链表的长度,如果链表中节点的个数大于TREEIFY_THRESHOLD,这个值为8,也就是说,如果链表中的节点个数大于8的时候,数据结构就有可能从链表转换成红黑树。
treeifyBin的源码分析
private final void treeifyBin(Node[] tab, int index) {
Node b; int n, sc;
if (tab != null) {
//这里还有一个判断,如果Node数组的长度小于64,则直接进行Node数组的扩容即可
if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
tryPresize(n << 1);//后面会详细说明这个方法
else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {//这里b被赋值为头结点
synchronized (b) {
if (tabAt(tab, index) == b) {
//这里就是遍历链表建立红黑树,这个在后续红黑树的总结中再详细讨论。
TreeNode hd = null, tl = null;
for (Node e = b; e != null; e = e.next) {
TreeNode p =
new TreeNode(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(hd));
}
}
}
}
}
从源码上看,如果链表中长度binCount>8并不一定会将链表转换为红黑树,如果Node数组的长度小于64,则只扩容Node数据即可。也就是说,如果链表要转换成红黑树,需要满足两个条件:1、Node数组长度大于64。2、binCount>8。
//在putVal传入进来的时候,size是已经是原来的两倍了
private final void tryPresize(int size) {
//对size进行修复,先判断size是否大于阈值。通过tableSizeFor将传入的参数转换为大于这个数最小的2次幂
int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
tableSizeFor(size + (size >>> 1) + 1);
int sc;
while ((sc = sizeCtl) >= 0) {
Node[] tab = table; int n;
//这段代码和initTable是一样的。table没有初始化,就初始化table
if (tab == null || (n = tab.length) == 0) {
n = (sc > c) ? sc : c;
if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
if (table == tab) {
@SuppressWarnings("unchecked")
Node[] nt = (Node[])new Node,?>[n];
table = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
}
}
else if (c <= sc || n >= MAXIMUM_CAPACITY)
break;
else if (tab == table) {//这里与addCount一样
int rs = resizeStamp(n);
if (sc < 0) {
Node[] nt;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
transferIndex <= 0)
break;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
transfer(tab, nt); //扩容后的再hash操作
}
else if (U.compareAndSwapInt(this, SIZECTL, sc,
(rs << RESIZE_STAMP_SHIFT) + 2))
transfer(tab, null);
}
}
}
上述基本属于链表扩容的操作,其实正真比较难的是transfer操作
再介绍transfer的时候,先要介绍resizeStamp
static final int resizeStamp(int n) {
return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}
其中的Integer.numberOfLeadingZeros(n)——返回无符号整数n最高位非0位前面的0的个数。
例如:10的二进制为:0000 0000 0000 0000 0000 0000 0000 1010 ,上述函数则返回标红的0的个数,为28。RESIZE_STAMP_BITS的默认值为16,这里就以resizeStamp(16)为实例来说明其最后的返回值。
16的二进制为:0000 0000 0000 0000 0000 0000 0001 0000。那Integer.numberOfLeadingZeros(16)会返回27,27的二进制为:0000 0000 0000 0000 0000 0000 0001 1011。这个数值再与左移1位的15二进制进行或操作即:0000 0000 0000 0000 0000 0000 0001 1011 | 0000 0000 0000 0000 1000 0000 0000 0000最后得到:0000 0000 0000 0000 1000 0000 0001 1011。
这似乎没有什么意义,具体的意义在下面一个方法中,当一个线程尝试进行扩容的时候,会执行下面一段代码:
U.compareAndSwapInt(this, SIZECTL, sc, (rs << RESIZE_STAMP_SHIFT) + 2))
上述的代码中的U其实就是JDK为我们封装的Unsafe类,上述的rs就是resizeStamp的返回值,这个在后面会贴出来,这个操作就是将上面的resizeStamp函数的返回的二进制左移16位,然后加2,最终得到的二进制为
1000 0000 0001 1011 0000 0000 0000 0010。这就是sizeCtl的最终结果,这里可以看出,sizeCtl的高16位记录的是扩容标记,后面记录的是扩容线程的个数
下面开始正式说明transfer方法,transfer方法是支持并发操作的,针对transfer来说,transfer中将Node数组当作多个线程之间共享的任务队列,然后通过维护一个指针(transferIndex)来划分每个线程负责的区间,每个线程通过区间逆向遍历来实现扩容,一个已经迁移完的节点会被替换为一个ForwardingNode节点,用来标记当前节点已经被其他线程迁移完毕。
//tab是原node数组,nextTab是用来扩容的数组
private final void transfer(Node[] tab, Node[] nextTab) {
int n = tab.length, stride;
//这里就是让每个CPU处理的任务一样多,避免出现转移任务不均匀的情况。
//这里的stride就是步长,有n个位置需要进行迁移,每个任务迁移stride个子任务。
//
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
//如果nextTab为空,就进行一次初始化,
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
//容量翻倍
Node[] nt = (Node[])new Node,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;//扩容失败,则将sizeCtl初始化最大值。
return;
}
nextTable = nextTab;
transferIndex = n;//初始化迁移的位置,上述两者都是ConcurrentHashMap中的属性。
}
int nextn = nextTab.length;//新的tab的长度。
//ForwardingNode表示正在被迁移的节点,其hash值为-1,其作用是用来占位,表示原数组中位置i处的节点完成迁移以后,就会在i位置设置一个fwd来告诉其他线程这个位置已经处理过了。
ForwardingNode fwd = new ForwardingNode(nextTab);
boolean advance = true;
//判断是否已经扩容完成
boolean finishing = false; // to ensure sweep before committing nextTab
//i指向了transferIndex,bound指向了transferIndex-stride
for (int i = 0, bound = 0;;) {
Node f; int fh;
while (advance) {//advance表示是否可以进入下一个Node节点进行迁移
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//将transferIndex赋值给nextIndex,如果nextIndex<0说明原数组的所有位置都有相应的线程去处理了
i = -1;
advance = false;
}
//通过cas来替换transferIndex
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
//括号中的代码,nextBound是这次迁移的边界点,从后往前
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {//i<0表示已经遍历完旧的数组
int sc;
if (finishing) {//如果完成了扩容,
nextTable = null;
table = nextTab;//更新table数组
sizeCtl = (n << 1) - (n >>> 1);//更新阈值
return;
}
//将sizeCtl中的迁移线程数减一
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)//任务结束方法退出。
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
//对数组该位置的节点进行加锁,开始处理数组该位置处的迁移工作。
synchronized (f) {
if (tabAt(tab, i) == f) {
Node ln, hn;
if (fh >= 0) {//头结点的hash>0说明是链表的Node节点。
//下面就是将链表一分为二,找到原链表中的lashRun,然后lastRun及其之后的节点是一起迁移的,lastRun之前的节点需要进行克隆,然后分到两个链表中,这其实就是再hash的过程。
int runBit = fh & n;
Node lastRun = f;
for (Node p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
for (Node p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node(ph, pk, pv, ln);
else
hn = new Node(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);//其中的一个链表放在新数组的位置i
setTabAt(nextTab, i + n, hn);//另一个链表放在新数组的位置i+n
//将原数组该位置处设置为fwd,代表该位置已经处理完毕,
//其他线程一旦看到该位置的hash值为MOVED,就不会进行迁移了
setTabAt(tab, i, fwd);
advance = true;//advance设置为true,代表该位置已经迁移完毕
}
else if (f instanceof TreeBin) {//红黑树的迁移
TreeBin t = (TreeBin)f;
TreeNode lo = null, loTail = null;
TreeNode hi = null, hiTail = null;
int lc = 0, hc = 0;
for (Node e = t.first; e != null; e = e.next) {
int h = e.hash;
TreeNode p = new TreeNode
(h, e.key, e.val, null, null);
if ((h & n) == 0) {
if ((p.prev = loTail) == null)
lo = p;
else
loTail.next = p;
loTail = p;
++lc;
}
else {
if ((p.prev = hiTail) == null)
hi = p;
else
hiTail.next = p;
hiTail = p;
++hc;
}
}
//如果一分为二之后,节点数少于8,那么将红黑树转换回链表
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new TreeBin(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new TreeBin(hi) : t;
//将ln放置在新数组的位置
setTabAt(nextTab, i, ln);
//将hn放置在新数组的位置i+n
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);//将原数组该位置处设置为fwd,代表该位置已经被处理过了
advance = true;//表示迁移已经完成
}
}
}
}
}
}
想要看到transfer还是需要一点精力的,后面的博客会画图进行总结,这里先写上注释。
整篇文章写的有些凌乱,但总体写出了ConcurrentHashMap的主要流程,ConcurrentHashMap中针对位运算和多线程的控制真心值得学习
ConcurrentHashMap 1.8源码解析
Java7/8中的HashMap源码解析