很多次听到ConcurrentHashMap的一大堆优点,但是每次听到都是云里雾里,并不了解这些优点的本质,所以,要破解这些疑问还得要从实际入手,去观摩源码。
以上是Map集合的各类比较,注意ConcurrentHashMap的key和Value都不能为null
首先要知道ConcurrentHashMap是JDK5引入的线程安全的HashMap,但是再JDK7之前一直采用的是Segment分段锁的设计思想,相当于HashMap与HashTable的折中处理,因为HashTable是粗暴的一把大锁,对所有的操作都加上了锁,ConcurrentHashMap则是为每个哈希桶加上了小锁.就如ConcurrentHashMap被Segment分成了很多小区,Segment就相当于小区保安,HashEntry(哈希桶)列表相当于小区业主,小区保安通过加锁的方式,保证每个 Segment 内都不发生冲突。既然能细分到分段加锁,那能不能继续细分?那对每个结点都加上一个锁这样不也行吗?这样虽然也行,但是也造成了繁琐的获取锁的操作,那再来思考下这个锁加在哪最合适。
先思考下hashMap中所有操作的共同点,比如put和get,发现它们都需要获取到table[]数组中下标对应的首结点,也就是每个哈希桶的首结点,这是它们统一的入口,既然是统一的入口,那为何不在这里加锁,所有,这也就是JDK1.8改进的思路。
再回忆一下哈希集合类的一些基本概念吧。
首先,要熟悉基本的成员变量
//默认为null,对应于上方图中table,大小为2的幂次方,同HashMap
//在第一次put时初始化
transient volatile Node<K,V>[] table;
//默认为null,在扩容时生成,为原数组2倍大小
private transient volatile Node<K,V>[] nextTable;
/**
* Base counter value, used mainly when there is no contention,
* but also as a fallback during table initialization
* races. Updated via CAS.
*/
private transient volatile long baseCount;
//默认为0,用于控制table的初始化和扩容
//= -1时表示正在初始化
//= -n时表示(n-1)个线程在扩容中
//> 0表示初始化或扩容使用的容量大小
//= 0默认值,使用默认容量初始化
private transient volatile int sizeCtl;
//并发扩容时下一片索引+1,也就是当前分片的最后一个结点
private transient volatile int transferIndex;
/**
* Spinlock (locked via CAS) used when resizing and/or creating CounterCells.
*/
private transient volatile int cellsBusy;
/**
* Table of counter cells. When non-null, size is a power of 2.
*/
private transient volatile CounterCell[] counterCells;
// views
private transient KeySetView<K,V> keySet;
private transient ValuesView<K,V> values;
private transient EntrySetView<K,V> entrySet;
下面还有几个重要的常量,和HashMap一样的常量就不重复说明了
//哈希桶内由链表转换为红黑树的阈值
static final int TREEIFY_THRESHOLD = 8;
//可以转换为红黑树的最小集合容量,该值应至少为4 * TREEIFY_THRESHOLD,以避免调整大小和树化阈值之间的冲突。
static final int MIN_TREEIFY_CAPACITY = 64;
//红黑树退化为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
还有几个内部类结构
//存储键值对的Entry,有4个、在ConcurrentHashMap内部定义的子类TreeBin,TreeNode,ForwardingNode,ReservationNode。
static class Node<K,V> implements Map.Entry<K,V>
//这个用来维护每个哈希桶的锁,存放红黑树结点的引用,也就是TreeNode
static final class TreeBin<K,V> extends Node<K,V>
//实际存储结点的类
static final class TreeNode<K,V> extends Node<K,V>
//添加该节点后,在Transfer操作时会转发到nextTable中,有点不太明白,不过先继续看着,后面就理解了。
static final class ForwardingNode<K,V> extends Node<K,V>
//占位符结点,在执行某些方法时加锁,如computeIfAbsent
static final class ReservationNode<K,V> extends Node<K,V>
重点拿出Node内部类:
```java
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
//声明volatile保证可见性
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 K getKey() { return key; }
public final V getValue() { return val; }
public final int hashCode() { return key.hashCode() ^ val.hashCode(); }
public final String toString(){ return key + "=" + val; }
//禁用的set方法
public final V setValue(V value) {
throw new UnsupportedOperationException();
}
public final boolean equals(Object o) {
Object k, v, u; Map.Entry<?,?> e;
return ((o instanceof Map.Entry) &&
(k = (e = (Map.Entry<?,?>)o).getKey()) != null &&
(v = e.getValue()) != null &&
(k == key || k.equals(key)) &&
(v == (u = val) || v.equals(u)));
}
/**
* Virtualized support for map.get(); overridden in subclasses.
*/
Node<K,V> find(int h, Object k) {
Node<K,V> e = this;
if (k != null) {
do {
K ek;
if (e.hash == h &&
((ek = e.key) == k || (ek != null && k.equals(ek))))
return e;
} while ((e = e.next) != null);
}
return null;
}
}
volatile的作用再来回忆下:
- 1.保证此变量对所有的线程的可见性,这里的“可见性”,为当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存来完成。
- 2.禁止指令重排序优化。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。
下面给出ConcurrentHashMap这些类所组成的大致结构
图中第一处哈希桶处table长度为64,符合阈值(>=64),所以转换为红黑树。当容量小于64时,只会进行扩容,扩容就同HashMap一样,2倍扩容,扩容的时候会使用同步锁锁住当前哈希桶的首结点,以防止其他线程进行修改,扩容完成后再通过CAS替换掉原有的链表,完成resize扩容。
CAS是什么CAS操作的就是乐观锁,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。可参考Java:CAS(乐观锁)。里面讲的很通俗易懂
CAS是采用了unsafe包下的方法来实现的,unsafe是java提供的直接操作内存地址的方法,CAS便是其中的一个操作
下面来看看具体使用CAS的几个方法,这几个方法支持起整个的操作
//用来返回节点数组的指定位置的节点的原子操作
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
//cas原子操作,在指定位置设定值
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
//设置节点位置的值,仅在上锁区被调用
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
了解这三个后便可以继续看下面具体的操作了,可以还是半懂的状态,这是因为还没有具体看到如何使用的
下面是putVal()方法
final V putVal(K key, V value, boolean onlyIfAbsent) {
//首先判null,这就印证了上面的key和value都不能为null
if (key == null || value == null) throw new NullPointerException();
//计算hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
//此处也印证了上面所说的懒初始化,即在第一次put操作时初始化。
tab = initTable();
//这里又看到了经典的(n-1)&hash取下标的方法,沿用HashMap
//tabAt()是在table中取值的原子操作
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
//如果当前值为null,则直接CAS设置值
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
//如果遇到forwarding结点,说明正在进行扩容操作,整合并扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//对首结点上锁
synchronized (f) {
//f.hash的值可用来区分时链表还是红黑树
if (tabAt(tab, i) == f) {
//>0为链表,则按链表形式插入
if (fh >= 0) {
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;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
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) {
//超过树华阈值则进行红黑树转换
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
插入流程:
initTable()
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
//注意这里,sizeCtl<0说明有线程正在初始化,直接暂停当前正在执行的线程对象,并执行其他线程。
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {//如果CAS成功,则置SIZECTL为-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);//重新设置扩容的阈值0.75n
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
再回到上面putVal()方法
helpTransfer()
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
//上面有说过ForwardingNode里面存放着下一个table的数组
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;
if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
下面则具体要涉及到transfer()方法,由于太长了,只贴一部分,去掉了Tree的扩容那段,因为和链表的类似,只是多了判断是否要退化
//整体的思想是为每个线程分配一定大小的任务,共同完成扩容和迁移任务
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
int n = tab.length, stride;
//此处是用于为每个线程计算允许处理的最少table桶首节点个数,最小为MIN_TRANSFER_STRIDE(16)
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];//创建扩容后的table数组
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
int nextn = nextTab.length;
//具体的操作是如果遍历到的节点是ForwardingNode节点,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制。
//多线程遍历节点,当一个节点为空或已经被转移之后,就把对应点的值set为ForwardingNode,另一个线程看到ForwardingNode,就向后遍历。
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
//是否跳过当前结点
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
//死循环,因为是倒着遍历,所以i是当前线程的最大位置
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
if (--i >= bound || finishing)
advance = false;
else if ((nextIndex = transferIndex) <= 0) {//表示当前分片已经分配完成
i = -1;
advance = false;
}
//更新transferIndex值
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;
//如果所有的节点都已经完成复制工作便把nextTable赋值给table,清空临时对象nextTable,此处只能由一个线程完成
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
//重新设置扩容阈值,每当有线程参与helpTrasnfer是sizeCtl会自增1,完成后减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
}
}
//如果结点为null,则置为ForwardingNode结点
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)//如果该节点已被移动,跳过
advance = true; // already processed
else {
//对当前结点上锁,开始移动,下面便是对链表的迁移,过程和hashmap也一样的,就不再说明,再来回到putVal()方法
synchronized (f) {
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (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 (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 Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
setTabAt(tab, i, fwd);
advance = true;
}
else if (f instanceof TreeBin) {
}
再补充下扩容流程:
剩下的也不做过多描述了,与hashMap也大同小异,来总结一下
1.在操作时对首结点也就是table[]加锁,通过CAS乐观锁来实现并发控制
2.并发扩容,为每个线程分配切片,每个线程完成指定切片的扩容和迁移
看完源码后,果然对这些理解了许多,而且又学习到了并发控制的新思路,特别在transfer中,通过状态来实现线程控制,分片实现并发扩容是真的巧妙。
参考:
Java集合源码:ConcurrentHashMap(JDK 1.8)
ConcurrentHashMap源码分析(JDK8版本)
《Java源码分析》:ConcurrentHashMap JDK1.8
ConcurrentHashMap源码分析(1.8)
码出高效· Java 开发手册