新聞中心
上周我在極客時(shí)間某個(gè)課程看到某個(gè)講師在討論 ConcurrentHashMap(以下簡(jiǎn)稱 CHM)是強(qiáng)一致性還是弱一致性時(shí),提到這么一段話。

創(chuàng)新互聯(lián)是一家專注于成都網(wǎng)站制作、成都網(wǎng)站建設(shè)與策劃設(shè)計(jì),廣東網(wǎng)站建設(shè)哪家好?創(chuàng)新互聯(lián)做網(wǎng)站,專注于網(wǎng)站建設(shè)十年,網(wǎng)設(shè)計(jì)領(lǐng)域的專業(yè)建站公司;建站業(yè)務(wù)涵蓋:廣東等地區(qū)。廣東做網(wǎng)站價(jià)格咨詢:13518219792
這個(gè)解釋網(wǎng)上也是流傳甚廣,那么到底對(duì)不對(duì)呢,在回答這個(gè)問題之前,我們得想清楚兩個(gè)問題。
- 什么是強(qiáng)一致性,什么是弱一致性。
- 上文提到 get 沒有加鎖,所以沒法即時(shí)獲取 put 的數(shù)據(jù),也就意味著如果加鎖就可以立即獲取到 put 的值了?那么除了加鎖之外,還有其他辦法可以立即獲取到 put 的值嗎?
強(qiáng)一致性與弱一致性
強(qiáng)一致性
首先我們先來(lái)看第一個(gè)問題,什么是強(qiáng)一致性。
一致性(Consistency)是指多副本(Replications)問題中的數(shù)據(jù)一致性。可以分為強(qiáng)一致性、弱一致性。
強(qiáng)一致性也被可以被稱做原子一致性(Atomic Consistency)或線性一致性(Linearizable Consistency),必須符合以下兩個(gè)要求。
任何一次讀都能立即讀到某個(gè)數(shù)據(jù)的最近一次寫的數(shù)據(jù)。
系統(tǒng)中的所有進(jìn)程,看到的操作順序,都和全局時(shí)鐘下的順序一致。
簡(jiǎn)單地說(shuō)就是假定對(duì)同一個(gè)數(shù)據(jù)集合,分別有兩個(gè)線程 A、B 進(jìn)行操作,假定 A 首先進(jìn)行了修改操作,那么從時(shí)序上在 A 這個(gè)操作之后發(fā)生的所有 B 的操作都應(yīng)該能立即(或者說(shuō)實(shí)時(shí))看到 A 修改操作的結(jié)果。
弱一致性
與強(qiáng)一致性相對(duì)的就是弱一致性,即數(shù)據(jù)更新之后,如果立即訪問的話可能訪問不到或者只能訪問部分的數(shù)據(jù)。如果 A 線程更新數(shù)據(jù)后 B 線程經(jīng)過(guò)一段時(shí)間后都能訪問到此數(shù)據(jù),則稱這種情況為最終一致性,最終一致性也是弱一致性,只不過(guò)是弱一致性的一種特例而已。
那么在 Java 中產(chǎn)生弱一致性的原因有哪些呢,或者說(shuō)有哪些方式可以保證強(qiáng)一致呢,這就得先了解兩個(gè)概念,可見性和有序性。
一致性的根因:可見性與有序性
可見性
首先我們需要了解一下 Java 中的內(nèi)存模型。
上圖是 JVM 中的 Java 內(nèi)存模型,可以看到,它主要由兩部分組成,一部分是線程獨(dú)有的程序計(jì)數(shù)器,虛擬機(jī)棧,本地方法棧,這部分的數(shù)據(jù)由于是線程獨(dú)有的,所以不存在一致性問題(我們說(shuō)的一致性問題往往指多線程間的數(shù)據(jù)一致性),一部分是線程共享的堆和方法區(qū),我們重點(diǎn)看一下堆內(nèi)存。
我們知道,線程執(zhí)行是要占用 CPU 的,CPU 是從寄存器里取數(shù)據(jù)的,寄存器里沒有數(shù)據(jù)的話,就要從內(nèi)存中取,而眾所周知這兩者的速度差異極大,可謂是一個(gè)天上一個(gè)地上,所以為了緩解這種矛盾,CPU 內(nèi)置了三級(jí)緩存,每次線程執(zhí)行需要數(shù)據(jù)時(shí),就會(huì)把堆內(nèi)存的數(shù)據(jù)以 cacheline(一般是 64 Byte) 的形式先加載到 CPU 的三級(jí)緩存中來(lái),這樣之后取數(shù)據(jù)就可以直接從緩存中取從而極大地提升了 CPU 的執(zhí)行效率(如下圖示)。
但是這樣的話由于線程加載執(zhí)行完數(shù)據(jù)后數(shù)據(jù)往往會(huì)緩存在 CPU 的寄存器中而不會(huì)馬上刷新到內(nèi)存中,從而導(dǎo)致其他線程執(zhí)行如果需要堆內(nèi)存中共享數(shù)據(jù)的話取到的就不會(huì)是最新數(shù)據(jù)了,從而導(dǎo)致數(shù)據(jù)的不一致。
舉個(gè)例子,以執(zhí)行以下代碼為例。
//線程1執(zhí)行的代碼
int i = 0;
i = 10;
//線程2執(zhí)行的代碼
j = i;
在線程 1 執(zhí)行完后 i 的值為 10,然后 2 開始執(zhí)行,此時(shí) j 的值很可能還是 0,因?yàn)榫€程 1 執(zhí)行時(shí),會(huì)先把 i = 0 的值從內(nèi)存中加載到 CPU 緩存中,然后給 i 賦值 10,此時(shí)的 10 是更新在 CPU 緩存中的,而未刷新到內(nèi)存中,當(dāng)線程 2 開始執(zhí)行時(shí),首先會(huì)將 i 的值從內(nèi)存中(其值為 0)加載到 CPU 中來(lái),故其值依然為 0,而不是 10,這就是典型的由于 CPU 緩存而導(dǎo)致的數(shù)據(jù)不一致現(xiàn)象。
那么怎么解決可見性導(dǎo)致的數(shù)據(jù)不一致呢,其實(shí)只要讓 CPU 修改共享變量時(shí)立即寫回到內(nèi)存中,同時(shí)通過(guò)總線協(xié)議(比如 MESI)通過(guò)其他 CPU 所讀取的此數(shù)據(jù)所在 cacheline 無(wú)效以重新從內(nèi)存中讀取此值即可。
有序性
除了可見性造成的數(shù)據(jù)不一致外,指令重排序也會(huì)造成數(shù)據(jù)不一致。
public class Reordering {
private static boolean flag;
private static int num;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
Thread.yield();
}
System.out.println(num);
}
}, "t1");
t1.start();
num = 5; ①
flag = true; ②
}
}以上代碼執(zhí)行步驟可能很多人認(rèn)為是按正常的 ①,②,③ 執(zhí)行的,但實(shí)際上很可能編譯器會(huì)將其調(diào)換一下位置,實(shí)際的執(zhí)行順序可能是 ①③②,或 ②①③,也就是說(shuō) ①③ 是緊鄰的,為什么會(huì)這樣呢,因?yàn)閳?zhí)行 1 后,CPU 會(huì)把 x = 1 從內(nèi)存加載到寄存器中,如果此時(shí)直接調(diào)用 ③ 執(zhí)行,那么 CPU 就可以直接讀取 x 在寄存器中的值 1 進(jìn)行計(jì)算,反之,如果先執(zhí)行了語(yǔ)句 ②,那么有可能 x 在寄存器中的值被覆蓋掉從而導(dǎo)致執(zhí)行 ③ 后又要重新從內(nèi)存中加載 x 的值,有人可能會(huì)說(shuō)這樣的指令重排序貌似也沒有多大問題呀,那么考慮如下代碼:
public class Reordering {
private static boolean flag;
private static int num;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
Thread.yield();
}
System.out.println(num);
}
}, "t1");
t1.start();
num = 5; ①
flag = true; ②
}
}以上代碼最終輸出的值正常情況下是 5,但如果上述 ① ,② 兩行指令發(fā)生重排序,那么結(jié)果是有可能為 0 的,從而導(dǎo)致我們觀察到的數(shù)據(jù)不一致的現(xiàn)象發(fā)生,所以顯然解決方案是避免指令重排序的發(fā)生,也就是保證指令按我們看到的代碼的順序有序執(zhí)行,也就是我們常說(shuō)的有序性,一般是通過(guò)在指令之間添加內(nèi)存屏障來(lái)避免指令的重排序。
那么如何保證可見性與有序性呢?
相信大家都非常熟悉了,使用 volatile 可以保證可見性與有序性,只要在聲明屬性變量時(shí)添加上 volatile 就可以讓此變量實(shí)現(xiàn)強(qiáng)一致性,也就是說(shuō)上述的 Reordering 類的 flag 只要聲明為 volatile,那么打印結(jié)果就永遠(yuǎn)是 5!
好了,現(xiàn)在問題來(lái)了,CHM 到底是不是強(qiáng)一致性呢,首先我們以 Java 8 為例來(lái)看下它的設(shè)計(jì)結(jié)構(gòu)(和之前的版本相差不大,主要加上了紅黑樹提升了查詢效率)。
來(lái)看下這個(gè) table 數(shù)組和節(jié)點(diǎn)的聲明方式(以下定義 8 和 之前的版本中都是一樣的):
public class ConcurrentHashMapextends AbstractMap
implements ConcurrentMap, Serializable {
transient volatile Node[] table;
...
}
static class Nodeimplements Map.Entry {
final int hash;
final K key;
volatile V val;
volatile Nodenext;
...
}
可以看到 CHM 的 table 數(shù)組,Node 中的 值 val,下一個(gè)節(jié)點(diǎn) next 都聲明為了 volatile,于是有學(xué)員就提出了一個(gè)疑問?
講師的回答也提到 CHM 為弱一致性的重要原因:即如果 table 中的某個(gè)槽位為空,此時(shí)某個(gè)線程執(zhí)行了 key,value 的賦值操作,那么此槽位會(huì)新增一個(gè) Node 節(jié)點(diǎn),在 JDK 8 以前,CHM 是通過(guò)以下方式給槽位賦 Node 的。
V put(K key, int hash, V value, boolean onlyIfAbsent) {
lock();
...
tab[index] = new HashEntry(...);
...
unlock();
} 然后是通過(guò)以下方式來(lái)根據(jù) key 來(lái)讀取 value 的。
V get(Object key, int hash) {
if (count != 0) { // read-volatile
HashEntry e = getFirst(hash);
while (e != null) {
if (e.hash == hash && key.equals(e.key)) {
V v = e.value;
if (v != null)
return v;
return readValueUnderLock(e); // recheck
}
e = e.next;
}
}
return null;
} 可以看到 put 時(shí)是直接給數(shù)組中的元素賦值的,而由于 get 沒有加鎖,所以無(wú)法保證線程 A put 的新元素對(duì)執(zhí)行 get 的線程可見。
put 是有加鎖的,所以其實(shí)如果 get 也加鎖的話,那么毫無(wú)疑問 get 是可以立即拿到 put 的值的。為什么加鎖也可以呢,其實(shí)這是 JLS(Java Language Specification Java 語(yǔ)言規(guī)范) 規(guī)定的幾種情況,簡(jiǎn)單地說(shuō)就是支持 happens before 語(yǔ)義的可以保證數(shù)據(jù)的強(qiáng)一致性,在官網(wǎng)(https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html)中列出了幾種支持 Happens before 的情況,其中指出使用 volatile,synchronize,lock 是可以確保 happens before 語(yǔ)義的,也就是說(shuō)使用這三者可以保證數(shù)據(jù)的強(qiáng)一致性,可能有人就問了,到底什么是 happens before 呢,其實(shí)本質(zhì)是一種能確保線程及時(shí)刷新數(shù)據(jù)到內(nèi)存,另一線程能實(shí)時(shí)從內(nèi)存讀取最新數(shù)據(jù)以保證數(shù)據(jù)在線程之間保持一致性的一種機(jī)制,我們以 lock 為例來(lái)簡(jiǎn)單解釋下:
public class LockDemo {
private int x = 0;
private void test() {
lock();
x++;
unlock();
}
}如果線程 1 執(zhí)行 test,由于拿到了鎖,所以首先會(huì)把數(shù)據(jù)(此例中為 x = 0)從內(nèi)存中加載到 CPU 中執(zhí)行,執(zhí)行 x++ 后,x 在 CPU 中的值變?yōu)?1,然后解鎖,解鎖時(shí)會(huì)把 x = 1 的值立即刷新到內(nèi)存中,這樣下一個(gè)線程再執(zhí)行 test 方法時(shí)再次獲取相同的鎖時(shí)又從內(nèi)存中獲取 x 的最新值(即 1),這就是我們通常說(shuō)的對(duì)一個(gè)鎖的解鎖, happens-before 于隨后對(duì)這個(gè)鎖的加鎖,可以看到,通過(guò)這種方式可以保證數(shù)據(jù)的一致性。
至此我們明白了:在 Java 8 以前,CHM 的 get,put 確實(shí)是弱一致性的,可能有人會(huì)問為什么不對(duì) get 加鎖呢,加上了鎖不就可以確保數(shù)據(jù)的一致性了嗎,可以是可以,但別忘了 CHM 是為高并發(fā)設(shè)計(jì)而生的,加了鎖不就導(dǎo)致并發(fā)性大幅度下降了么,那 CHM 存在的意義是啥?
所以 put,get 就無(wú)法做到強(qiáng)一致性了嗎?
我們?cè)谏衔闹幸呀?jīng)知道,使用 volatile,synchronize,lock 是可以確保 happens before 語(yǔ)義的,同時(shí)經(jīng)過(guò)分析我們知道使用 synchronize,lock 加鎖的設(shè)計(jì)是不滿足我們?cè)O(shè)計(jì) CHM 的初衷的,那么只剩下 volatile 了,遺憾的是由于 Java 數(shù)組在元素層面的元數(shù)據(jù)設(shè)計(jì)上的缺失,是無(wú)法表達(dá)元素是 final、volatile 等語(yǔ)義的,所以 volatile 可以修飾變量,卻無(wú)法修飾數(shù)組中的元素,還有其他辦法嗎?來(lái)看看 Java 8 是怎么處理的(這里只列出了寫和讀方法中的關(guān)鍵代碼)。
private static final sun.misc.Unsafe U;
// 寫
final V putVal(K key, V value, boolean onlyIfAbsent) {
...
for (Node[] tab = table;;) {
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node(hash, key, value, null)))
break;
}
}
...
}
static finalboolean casTabAt(Node [] tab, int i,
Nodec, Node v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
// 讀
public V get(Object key) {
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
...
}
return null;
}
@SuppressWarnings("unchecked")
static finalNode tabAt(Node [] tab, int i) {
return (Node)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
可以看到在 Java 8 中,CHM 使用了 unsafe 類來(lái)實(shí)現(xiàn)讀寫操作。
- 對(duì)于寫首先使用 compareAndSwapObject(即我們熟悉的 CAS)來(lái)更新內(nèi)存中數(shù)組中的元素。
- 對(duì)于讀則使用了 getObjectVolatile 來(lái)讀取內(nèi)存中數(shù)組中的元素(在底層其實(shí)是用了 C++ 的 volatile 來(lái)實(shí)現(xiàn) java 中的 volatile 效果,有興趣可以看看)。
由于讀寫都是直接對(duì)內(nèi)存操作的,所以通過(guò)這樣的方式可以保證 put,get 的強(qiáng)一致性,至此真相大白!Java 8 以后 put,get 是可以保證強(qiáng)一致性的!CHM 是通過(guò) compareAndSwapObject 來(lái)取代對(duì)數(shù)組元素直接賦值的操作,通過(guò) getObjectVolatile 來(lái)補(bǔ)上無(wú)法表達(dá)數(shù)組元素是 volatile 的坑來(lái)實(shí)現(xiàn)的。
注意并不是說(shuō) CHM 所有的操作都是強(qiáng)一致性的,比如 Java 8 中計(jì)算容量的方法 size() 就是弱一致性(Java 7 中此方法反而是強(qiáng)一致性),所以我們說(shuō)強(qiáng)/弱一致性一定要確定好前提(比如指定 Java 8 下 CHM 的 put,get 這種場(chǎng)景)。
總結(jié)其實(shí) Java 8 對(duì) CHM 進(jìn)行了一番比較徹底的重構(gòu),讓它的性能大幅度得到了提升,比如棄用 segment 這種設(shè)計(jì),改用對(duì)每個(gè)槽位做分段鎖,使用紅黑樹來(lái)降低查詢時(shí)的復(fù)雜度,擴(kuò)容時(shí)多個(gè)線程可以一起參與擴(kuò)容等等,可以說(shuō) Java 8 的 CHM 的設(shè)計(jì)非常精妙,集 CAS,synchroinize,泛型等 Java 基礎(chǔ)語(yǔ)法之大成,又有巧妙的算法設(shè)計(jì),讀后確實(shí)讓人大開眼界,有機(jī)會(huì)我會(huì)再和大家分享一下其中的設(shè)計(jì)精髓,另外我們對(duì)某些知識(shí)點(diǎn)一定要多加思考,最好能自己去翻翻源碼驗(yàn)證一下真?zhèn)?,相信你?huì)對(duì)網(wǎng)上的一些謬誤會(huì)更容易看穿。
網(wǎng)頁(yè)名稱:一文澄清網(wǎng)上對(duì)ConcurrentHashMap的一個(gè)流傳甚廣的誤解!
URL鏈接:http://www.5511xx.com/article/dhscesi.html


咨詢
建站咨詢
