關於ConcurrentHashMap高併發性的實現機制的探討
Java記憶體模型中的相關部分
1. 記憶體可見性
按照維基百科對於Java記憶體模型的說法,Java虛擬機器線上程中需要遵循as-if-serial語義,但是這個語義不會阻止不同的執行緒訪問同一個資料時具有多個場景。也就是說另一個執行緒可能不會立即看到一個執行緒對資料操作後的結果。
2. happens-before指令
happens-before指令歸入程式指令。在程式指令中,如果一個動作在其他動作之前發生,那麼他將比其他指令先進入到happens-before指令中。此外,釋放和隨後獲取鎖會形成happens-before圖的邊。一個讀執行緒會被允許返回一個寫執行緒的值,如果這個寫執行緒對值進行了最後一次寫操作。也就是說一旦滿足happens-before,寫執行緒的動作結果是讀執行緒可見的。
volatile關鍵字
被volatile關鍵字修飾的變數會儲存在主存而不是CPU快取中。那麼被volatile關鍵字修飾的變數對於操作它的所有執行緒都是透明的。而且這些被修飾的變數在操作中不會被重排序。
這樣做就表明經過volatile關鍵字修飾的變數應該在多執行緒中不會出現意外,但是實際上,就算是經過volatile修飾的變數也不一定就能在多執行緒中不出現意外
public class Entity {
volatile int a = 0;
volatile int b = 0;
private static final Object OBJECT = new Object();
private Entity() {}
public void change(int i) {
b = i;
}
public void increase() {
a++;
}
public static Entity instance() {
synchronized (OBJECT) {
return new Entity();
}
}
}
那麼對於change方法,對b的操作是原子性的。對於increase方法,對a的操作是非原子性的。
public class MyThread implements Runnable {
private int var;
private Entity e;
public MyThread(int var, Entity e) {
this.var = var;
this.e = e;
}
@Override
public void run() {
e.change(var);
}
}
public class ThreadDemo {
public static void main(String[] args) {
Entity e = Entity.instance();
for(int i = 0;i<100;i++) {
new Thread(() -> {
e.increase();
}).start();
new Thread(new MyThread(i, e)).start();
}
System.out.println(e.a);
System.out.println(e.b);
}
}
其實結果應該能猜到,結果不一定是99。所以無關乎是否進行了原子性操作,最後都不一定能正常執行。因為在oracle的Java SE文件中的原子許可部分裡專門說到
Using
volatile
variables reduces the risk of memory consistency errors,…
也就是說,經過volatile關鍵字修飾的變數只能減少發生在記憶體一致性錯誤的風險,並不能完全消除。
附上隨機進行兩次的截圖:
synchronized關鍵字
- 首先, 對於同一物件上的兩個同步方法呼叫是不可能交錯的。當一個執行緒正在執行物件的同步方法時, 為同一物件塊 (掛起執行) 呼叫同步方法的所有其他執行緒, 直到第一個執行緒完成物件為止。
- 第二, 當同步方法退出時, 它會自動建立一個happens-before與對同一物件的同步方法的任何後續呼叫的關係。這保證對物件狀態的更改對所有執行緒都可見。
synchronized方法啟用了一種簡單策略來防止執行緒干擾和記憶體一致性錯誤,但這種策略也會帶來併發活動性問題,比如死鎖,餓死和活鎖
也就是說如果在上面的Entity類的兩個方法前用synchronized修飾,應該能得到兩個99。
如果我還在寫,那就證明不是。大家可以自己多試幾次。
所以其實volatile和synchronized都不能保證在高併發場景下不發生記憶體一致性錯誤
那麼ConcurrentHashMap是如何設計在併發場景的使用的?
ConcurrentHashMap的結構(Java version:1.8.0_162)
- Java8和Java7中的ConcurrentHashMap原始碼有很大不同。Java8中刪除了HashEntry類的存在,改用Node類代替。大量刪除了Segment中的程式碼,選擇直接在ConcurrentHashMap中用方法實現。
ConcurrentHashMap中存在一個內部類
Node<K, V>
,Node實現了單向連結串列結構一個Node陣列table,當然,使用volatile關鍵字修飾了
transient volatile Node<K,V>[] table;
重新分配時需要進行輔助的Node陣列next
transient volatile Node<K,V>[] next;
ConcurrentHashMap通過維護一個Node連結串列陣列進行資料的存放,根據每個鍵值對計算出的hashcode值找到陣列對應的位置。那麼當出現hashcode相同的鍵值對,新的鍵值對將放在原來的鍵值對的位置,而原來的鍵值對將作為新鍵值對的下一個值存放在這個連結串列中
瞭解了這些,也就知道了對於一個ConcurrentHashMap物件中資料的增刪改查也就是對table的增刪改查
既然volatile修飾過的變數不能保證不出現記憶體一致性錯誤,為什麼table還是選擇用volatile進行修飾?
上原始碼:
public V put(K key, V value) {
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
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)
tab = initTable();
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
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
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;
}
put方法其實是對putVal的一種封裝,而在putVal方法中,對於新增資料的過程上了鎖。
但其實之前就說過了,synchronized也不管用
所以要看上面程式碼中呼叫了一個非常關鍵的方法:tabAt和casTabAt,他們統稱為Volatile許可方法,還有一個setTabAt(這個方法在replace和remove方法中都被呼叫)
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);
}
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);
}
啊哈,好像有點眉目了。U其實是一個Unsafe物件。
繼續看Unsafe的原始碼
public native Object getObjectVolatile(Object var1, long var2);
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public native void putObjectVolatile(Object var1, long var2, Object var4);
終於到頭了,看到native關鍵字,就知道這個方法不是Java寫的。Java不能直接訪問作業系統底層,而是通過本地方法來訪問。所以那些用native修飾的方法是對底層操作的
那麼Unsafe類提供了硬體級別的原子操作
也就是說這個物件可以直接對記憶體動刀子。所以這個物件的getObjectVolatile方法就一定可以獲取到最新的table。所以無懼高併發帶來的記憶體一致性錯誤的煩惱。
其他的同理可得
問題也就迎刃而解了
如果我說的不對,還請大家及時指出,謝謝。
ps:
至於Java10有沒有刪除Unsafe類,得等我看了原始碼才知道,或者有誰說一聲
更詳細的關於Unsafe類的介紹:http://www.importnew.com/14511.html