概述
本文基于jdk1.8来对CurrentHashMap来做源码分析。
ConcurrentHashMap在jdk1.8中的源码实现和HashMap非常相似。这个容器的实现完美的展示了如何不用锁的情况下实现同步(当然里面还是用到了锁的),看代码真的叹为观止,收益匪浅。
在1.7前ConcurrentHashMap采用分段加锁的技术来实现,说白了就是初始化若干个段,每个段管理一个小的HashMap,对每个小HashMap的同步操作都是基于对对应段的加锁、解锁。
Jdk1.7 ConcurrentHashMap结构示意图
在1.8ConcurrentHashMap放弃了分段加锁的方式,而是采用了更细粒度的锁,直接对hash表中的桶内Node对象加锁(即拉出的链表头节点,或者是红黑树的根节点)。建议阅读本文前先阅读《HashMap源码分析》,弄懂了HashMap后再看本篇源码分析就简单多了。
1.8 ConcurrentHashMap结构示意图
看过之前写的《HashMap源码分析》就知道HashMap不仅实现了Map接口,还继承AbstractMap抽象类,但是AbstractMap就已经实现了Map接口,有人说是为了使用动态代理所以HashMap实现时重复的implement了Map接口;但是ConcurrentHashMap就没有implement Map接口,啪啪打脸。。。事实证明HashMap可能就是实现时的一个无关紧要的小失误。
类成员
1. MAXIMUM_CAPACITY
private static final int MAXIMUM_CAPACITY = 1 << 30;
最大容量设置为一个integer值的最大的2的次方。
2. DEFAULT_CAPACITY
private static final int DEFAULT_CAPACITY = 16;
默认初始值设置为了16,未设置HashMap的capacity,将会使用这个final变量来作为它的初始capacity。
3. MAX_ARRAY_SIZE
static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
//TODO还没搞懂
4. DEFAULT_CONCURRENCY_LEVEL
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
默认同步级别,和默认容量一样大
//TODO 还没搞懂
5. LOAD_FACTOR
private static final float LOAD_FACTOR = 0.75f;
默认加载因子为0.75,这个值是大量实验得出的权衡空间、时间的最优数据。这个值的用处就是:当桶数组被占用比例达到这个值时就需要扩容了。
6. TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
桶中链表转换为红黑树的阈值。这个值必须至少为8且为2的次方。
7. UNTREEIFY_THRESHOLD
static final int UNTREEIFY_THRESHOLD = 6;
桶中红黑树由红黑树换成链表的阈值,只有在扩容后需要分裂一个树时使用,分裂是会用两个链表暂存分裂后的树节点,如果链表长度小于等于6则直接转换为Node类型节点链表放到对应桶位置,否则还会把裂成的链表变成树放到桶对应位置。这个值最大为6。
8. MIN_TREEIFY_CAPACITY
static final int MIN_TREEIFY_CAPACITY = 64;
容器可以树化的最小容量标准。哈希表table数组长度超过这个值时才会考虑将桶中Node树化。(因为哈希冲突主要因素是哈希表不够大,所以哈希表很小的情况下要优先考虑扩容而不是树化,因为树化后如果发生扩容消耗的计算资源会更多),应该至少为4 * TREEIFY_THRESHOLD,以避免调整大小和树化阈值之间的冲突。
9. RESIZE_STAMP_BITS
private static int RESIZE_STAMP_BITS = 16;
//TODO 暂未搞懂
10. MAX_RESIZERS
private static final int MAX_RESIZERS = (1 << (32 - RESIZE_STAMP_BITS)) - 1;
//TODO 暂未搞懂
11. RESIZE_STAMP_SHIFT
private static final int RESIZE_STAMP_SHIFT = 32 - RESIZE_STAMP_BITS;
//TODO 暂未搞懂
12. static class Node<K,V> implements Map.Entry<K,V>
ConcurrentHashMap桶中链表状态时存放的Node,树化后会用TreeNode来保存。
和HashMap中Node实现的最大区别是val和next都是volatile类型的,这样就保证了读取节点时的可见性(具体可以参见jvm内存模型volatile关键字可见性规则,大致描述如下:多线程操作volatile对象时,对其写操作happens-before对其读操作)。这是其get()方法不用同步的原因之一,另一个原因就是使用Unsafe类来对桶节点以及ConcurrentHashaMap其他成员进行读写操作。
13. transient volatile Node<K,V>[] table;
HashMap中的hash表,它的大小始终会设置成2的幂次方(设置的capacity如果不是2的幂次方会取小于capacity的最大的2的幂次方),如果占用超过负载因子的时候会重新设置它的大小。transient关键字标识这个变量不可以被序列化。
14. private transient volatile long baseCount;
统计当前ConcurrentHashMap中元素个数(部分计数,在addCount()方法中会用CounterCells[]一起统计,只有因为baseCount发生争用导致CAS失败使用CountCells[]来统计),采用CAS更新。
15. private transient volatile int sizeCtl;
用于标识桶数组table是否正在初始化或者扩容操作,这个值时volatile类型且操作始终会使用Unsafe类的CAS。它的值为-1时表示桶数组正在初始化,除了-1以外的负值表示正在进行扩容;当桶数组为null时,如果构造函数传入了桶初始化大小,它用来保存第一次初始化表的大小,调用了不传桶初始大小参数的构造函数会用默认大小16来初始化桶数组,这个值就是0,桶数组被初始化后这个变量就保存下次如果大于这个值就扩容的阈值,即2*table.len()*0.75(这点和HashMap中的threshold一样)。
16. private transient volatile int transferIndex;
多线程扩容table时记录本次操作的旧table的下标
static final int MOVED = -1; // FrowardingNode节点的hash值,用于判断是否处于扩容中,扩容时会用该类型节点放到桶对应位置进行标识,后续调用put方法线程就知道当前是否处于扩容
static final int TREEBIN = -2; // 树形节点TreeNode的hash值
static final int RESERVED = -3; // ReservationNode的hash值
static final int HASH_BITS = 0x7fffffff; // 全1的一个int最大正直,在ConcurrentHashMap中仅仅用到spread()方法,用来做与操作
构造方法
ConcurrentHashMap提供了四种不同的构造函数,提供了一个使用其他类型Map作为入参实例化一个ConcurrentHashMap的构造函数,方便将非同步Map转化为同步Map。
传入初始化容量的构造函数依然会像HashMap一样,如果传入的初始化容量参数不是2的幂次方,会用大于这个参数的最小的2的幂次方作为哈希桶的初始化容量。
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));//没有直接使用tableSizeFor(initialCapacity),做了个小优化,结果是一样的
this.sizeCtl = cap;
}
同时传入初始化容量参数和加载因子参数时使用两个参数共同决定ConcurrentHashMap哈希桶的初始化容量
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);
this.sizeCtl = cap;
}
源码分析
主要方法处理逻辑分析如下:
插入节点
1. public V put(K key, V value)
注意这里key、value都不能为null,这是和HashMap不一样的地方。
因为hashtable,concurrenthashmap它们是用于多线程的,并发的 ,如果map.get(key)得到了null,不能判断到底是映射的value是null,还是因为没有找到对应的key而为空,而用于单线程状态的hashmap却可以用containKey(key) 去判断到底是否包含了这个null。
hashtable为什么就不能containKey(key)
一个线程先get(key)再containKey(key),这两个方法的中间时刻,其他线程怎么操作这个key都会可能发生,例如删掉这个key
---------------------
作者:Gitter_
来源:优快云
原文:https://blog.youkuaiyun.com/qq_25560423/article/details/77713423
版权声明:本文为博主原创文章,转载请附上博文链接!
public V put(K key, V value) {
return putVal(key, value, false);//调用putVal方法将键值对放入ConcurrentHashMap中
}
2. final V putVal(K key, V value, boolean onlyIfAbsent)
从代码看可以知道,putVal方法仅仅在添加节点到冲突链表/树时才会使用synchronized来进行同步,在判断桶数组对应位置是否有元素,已经判断没有时放入桶数组对应哈希位置中都未加锁,那么他是怎么保证线程安全以及可见性的呢。
/**
* 从代码看可以知道,putVal方法仅仅在添加节点到冲突链表/树时才会使用synchronized来进行同步,
* 在判断桶数组对应位置是否有元素,已经判断没有时放入桶数组对应哈希位置中都未加锁,那么他是怎么保证线程安全以及可见性的呢
* @param key
* @param value
* @param onlyIfAbsent 设置为true时如果对应key值存在于CurrentHashMap中,不会用新值覆盖老值;
* ConcurrentHashMap中调用直接用false,j就是覆盖的方法
* @return
*/
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key、value如果为null直接抛空指针异常
if (key == null || value == null) throw new NullPointerException();
// 对key.hashCode()计算哈希值,用于散列到桶数组中,方法和HashMap中处理方式一样,不再赘述
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
/**
* <p>桶数组未初始化则初始化桶数组</p>
* <p>这里判断和初始化分两步且未进行同步,在initTable()方法中还会进行二次判断,
* 并且会利用sizeCtl变量来标识桶数组是否在进行初始化,当然sizeCtl也会全程用CAS操作,
* 具体可以查看initTable()方法实现</p>
*/
if (tab == null || (n = tab.length) == 0)
tab = initTable();
/**
* 桶数组对应位置为null,将k,v实例化一个节点放到桶数组对应位置;
* <p>需要注意的是“判断桶数组对应位置是否为null{@code tabAt}”以及“放置Node到桶数组对应位置{@code casTabAt}”
* 都是使用的Unsafe类的cas原子的方式来操作的,这两步操作分开进行,不是原子的操作也没用使用锁同步,如果两个线程
* 都判断桶数组对应位置为null后,去创建节点放入桶对应位置,后面放的那个会失败,然后在for(;;)循环中执行下一次的放入
* 节点操作。使用for(;;)循环非常巧妙的在不用加锁的情况下实现了线程安全。用死循环+CAS的方式来进行多线程的同步在concurrent包下的使用挺多的,但是实现起来还是有难度的,实际生产代码中还是老老实实用锁吧</p>
*/
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
}
/**
* 桶数组对应位置不为null,但是hash值=MOVED,表示桶中节点为ForwardingNode数组正在扩容,本线程去帮助扩容
*/
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// hash冲突,需要将k,v新建一个Node链接到桶后面
else {
V oldVal = null;
synchronized (f) { // 用桶对应hash位置节点做同步锁,这里才真正用到了锁来同步
if (tabAt(tab, i) == f) {
// 在同步代码里再次判断Node f.hashCode是否大于等于0,即代表是链表的形式来处理hash冲突
if (fh >= 0) {
binCount = 1;
/**
* 找到相同的key值已经存在,根据onlyIfAbsent参数来决定是否覆盖
* 否则连接到链表尾部
*/
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;
}
}
}
/**
* 哈希冲突的Node被树化了;
* 找到相同的key值已经存在,根据onlyIfAbsent参数来决定是否覆盖,否则插入树对应位置
*/
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) {
// 本次插入Node到哈希冲突链表,导致链表长度超过树化阈值,调用treeifyBin树化哈希表中哈希冲突链表
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
// 将ConcurrentHashMap节点数+1,隐含了检查是否需要扩容,需要则执行扩容操作
addCount(1L, binCount);
return null;
}
InitTable()方法用于初始化table,里面完全没有显示的用到锁来进行同步。
/**
* 使用sizeCtl记录的值来初始化表大小
* 这个方法依然没有用到任何锁来同步
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
/**
* 操作放到while(table未被初始化完成)循环中,直到判断table已经被正确初始化,初始化可能是本线程做的,也可能是其它线程做的
*/
while ((tab = table) == null || tab.length == 0) {
// sizeCtl<0表示当前表正在被别的线程操作(初始化或者扩容),让出本次线程调度执行机会
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// 将sizeCtl使用CAS方式设置为-1,标识当前table正在被初始化
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 再次判断table是不是被初始化过,因为前面判断sizeCtl<0与否和设置它为-1两步不是原子操作,中间可能人家已经初始化好table了
if ((tab = table) == null || tab.length == 0) {
// 根据sizeCtl是否为0(如果实例化Map时传入正确初始化桶大小,sc就不是0了),判断是否要用默认桶数组大小初始化ConcurrentHashMap
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];// 初始化table
table = tab = nt;
sc = n - (n >>> 2);// 计算下次扩容table的大小
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
我们看一下helpTransfer()方法是如何并发的完成扩容后Node转移的
/**
* 如果有线程正在进行扩容操作,调用本方法线程去帮助从旧table转移Node到扩容后的新table
*/
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) {
// 扩容完成,break;
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
// 调用transfer帮助转移节点
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
真正的重头戏是从旧table转移Node到新的table的方法transfer()
/**
* 从旧table转移Node到新的扩容后的table
*/
private final void transfer(ConcurrentHashMap.Node<K,V>[] tab, ConcurrentHashMap.Node<K,V>[] nextTab) {
int n = tab.length, stride;
// 根据jvm允许使用cpu个数计算最小每个线程操作table的下标范围,防止多个线程操作同一个table下标
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
/**
* 扩容后的新table未初始化则进行初始化
* <p>可以看到这里并未用到任何同步操作,那么他是如何实现多线程下防止重复初始化nextTab的呢,通过查看代码共有5处,调用transfer()方法都
* 写在循环内前都会使用CAS来设置sizeCtl,需要调用transfer()方法扩容时,只有第一个检测sizeCtl>0,成功将其设置为负值,并进入transfer初始
* 化nextTab,初始化完成后修改sizeCtl为Integer.MAX_VALUE,其它线程在下次循环又可以进来帮助进行转移Node了<p/>
*/
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
ConcurrentHashMap.Node<K,V>[] nt = (ConcurrentHashMap.Node<K,V>[])new ConcurrentHashMap.Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;// 设置本次扩容首次transferIndex为新table的大小
}
int nextn = nextTab.length;
ConcurrentHashMap.ForwardingNode<K,V> fwd = new ConcurrentHashMap.ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
for (int i = 0, bound = 0;;) {
ConcurrentHashMap.Node<K,V> f; int fh;
/**
* 根据transferIndex和stride计算出当前线程要处理的"旧table"下标范围[transferIndex-stride+1,transferIndex-1],并更新transferIndex -= stride;
* 所谓多线程并行转移其实就是:
* 1、计算本线程需要转移的[i,bound]
* 2、转移完成设置旧table对应位置为FN节点
* 3、最后一个线程完成转移工作后设置table为扩容后的新table
* 这里要注意的是如何计算本线程要处理的范围:方式是用transfer记录当前没有线程处理的最大的那个table的下标值,计算stride值,那么本线程
* 要处理的范围就是[transfer-stride, transfer]
*/
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 最后一个线程转移完毕,将nextTab设置为当前的table,结束方法
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);//记录下次扩容阈值,2 * table.len() * 0.75
return;
}
/**
* 该扩容线程退出前cas将sizeCtl减一,若当前线程是最后一个扩容线程,则将finishing置为true
* 此时的sizeCtl后16位大小等于当前扩容线程数+1
*/
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
}
}
// 如果旧table对应位置为null,设置为ForwardingNode标识正在扩容,调用put方法线程发现这个FN后就能知道正在扩容,会来帮忙
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
/**
* 使用桶内节点作为锁;有线程调用put()方法放入对应桶中时也会对桶节点f使用synchronized来进行同步,
* 所以只有在(1)同时又两个线程put()在同一个桶节点处产生哈希冲突需要往链表或树放入节点时需要加锁同步,
* 以及(2)桶节点因为扩容还未转移完(或还将要开始)和另一个线程调用put()在这个桶节点产生哈希
* 冲突要放入链表或树种需要用锁同步
*/
synchronized (f) {
if (tabAt(tab, i) == f) {
ConcurrentHashMap.Node<K,V> ln, hn;
// 链表形式,拆分为两个链表分别链接到新table
if (fh >= 0) {
int runBit = fh & n;// 计算Node新的散列位置
ConcurrentHashMap.Node<K,V> lastRun = f;
for (ConcurrentHashMap.Node<K,V> 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 (ConcurrentHashMap.Node<K,V> 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 ConcurrentHashMap.Node<K,V>(ph, pk, pv, ln);
else
hn = new ConcurrentHashMap.Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
// 树形式,拆分为两个链表,根据拆分链表长度是否小于阈值UNTREEIFY_THRESHOLD,决定是否再次树化,并链接到新table对应位置
else if (f instanceof ConcurrentHashMap.TreeBin) {
ConcurrentHashMap.TreeBin<K,V> t = (ConcurrentHashMap.TreeBin<K,V>)f;
ConcurrentHashMap.TreeNode<K,V> lo = null, loTail = null;
ConcurrentHashMap.TreeNode<K,V> hi = null, hiTail = null;
int lc = 0, hc = 0;
for (ConcurrentHashMap.Node<K,V> e = t.first; e != null; e = e.next) {
int h = e.hash;
ConcurrentHashMap.TreeNode<K,V> p = new ConcurrentHashMap.TreeNode<K,V>
(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;
}
}
ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
(hc != 0) ? new ConcurrentHashMap.TreeBin<K,V>(lo) : t;
hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
(lc != 0) ? new ConcurrentHashMap.TreeBin<K,V>(hi) : t;
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
取值操作
get()方法就简单多了,完全没有加锁的操作
public V get(Object key) {
ConcurrentHashMap.Node<K,V>[] tab; ConcurrentHashMap.Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode()); // 计算key的散列hash
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;
}
// 对应桶hash散列位置hash值小于0表示为FordwardingNode(-1)节点或TreeNode(-2),调用节点的find方法来获取对应key的节点值
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;
}
参考:
https://blog.youkuaiyun.com/sinat_34976604/article/details/80971620