深入解析與應(yīng)用掌握 Java 并發(fā)編程之 volatile 變量
volatile被稱之為輕量級(jí)的synchronized,即通過無鎖的方式保證可見性,而本文將通過自頂向下的方式深入剖析這個(gè)關(guān)鍵字的底層實(shí)現(xiàn),希望對(duì)你有幫助。
共享變量操作不可見案例介紹
我們編寫一段多線程讀寫一個(gè)變量的代碼,t1一旦感知num被t2修改,就會(huì)結(jié)束循環(huán),然而事實(shí)卻是這段代碼即使在t2完成修改之后,t1也像是感知不到變化一樣一直無限循環(huán)阻塞著:
private static int num = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread t1 = new Thread(() -> {
while (num == 0) {
}
log.info("num已被修改為:1");
countDownLatch.countDown();
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
num++;
log.info("t2修改num為1");
countDownLatch.countDown();
});
t1.start();
t2.start();
countDownLatch.await();
log.info("執(zhí)行結(jié)束");
}
volatile保證可見性
于是我們將代碼增一個(gè)本文所引出的關(guān)鍵字volatile 加以修飾:
private volatile static int num = 0;
對(duì)應(yīng)的我們給出輸出結(jié)果,如預(yù)期一樣線程修改完之后線程1就會(huì)感知到變化而結(jié)束循環(huán):
23:54:04.040 [Thread-0] INFO MultiApplication - num已被修改為:1
23:54:04.040 [Thread-1] INFO MultiApplication - t2修改num為1
23:54:04.042 [main] INFO MultiApplication - 執(zhí)行結(jié)束
詳解volatile工作原理
volatile底層實(shí)現(xiàn)和JMM內(nèi)存模型息息相關(guān),該模型規(guī)范了線程的本地變量(各個(gè)線程拿到共享變量num的副本)和主存(內(nèi)存中的變量num)的關(guān)系,其規(guī)范通過happens-before等規(guī)約強(qiáng)制規(guī)范了JVM需要針對(duì)這幾個(gè)要求要做出不同的處理來配合處理器保證共享變量操作的可見性和有序性,這一點(diǎn)感興趣的讀者可以移步下面這篇文章了解一下JMM內(nèi)存規(guī)范和避免指令重排序的實(shí)際落地實(shí)現(xiàn):
按照J(rèn)MM模型抽象的各種happens-before及其內(nèi)存模型8大操作:volatile的變量的寫操作, happens-before后續(xù)讀該變量的代碼。
這就要求t1和t2修改num的時(shí)候,都必須從主存中先加載才能進(jìn)行修改,以上述代碼為例,假設(shè)t1修改了num的值,完成后就必須將最新的結(jié)果寫回主存中,而t2收到這個(gè)修改的通知后必須從主內(nèi)存中拉取最新的結(jié)果才能進(jìn)行操作:
上述這個(gè)流程只是JMM模型的抽象,也就是JVM便于讓程序員理解的一種模型,不是實(shí)際的實(shí)現(xiàn), 對(duì)應(yīng)的我們通過jitwatch查看volatile修飾的變量num進(jìn)行累加的代碼:
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
num++;
}
從匯編碼可以看出,匯編指令用到了一個(gè)lock的關(guān)鍵字,這就是保證并發(fā)編程可見性的關(guān)鍵:
0x00000000038ca0a1: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@5 (line 10)
0x00000000038ca0a6: mov 0x68(%r10),%r11d
0x00000000038ca0aa: inc %r11d
0x00000000038ca0ad: mov %r11d,0x68(%r10)
0x00000000038ca0b1: lock addl $0x0,(%rsp) ;*putstatic num
通過查IA-32架構(gòu)軟件開發(fā)者手冊(cè)可知,Lock前綴的指令在多核處理器下會(huì)引發(fā)了兩件事情:
- 將當(dāng)前變量num從當(dāng)前處理器的緩存行(cache-line)寫回內(nèi)存。
- 通知其他處理器該變量已被修改,其他處理器cache-line中的num值全部變?yōu)閕nvalid(無效)。
這也就是我們Intel 64著名的MESI協(xié)議,將該實(shí)現(xiàn)代入我們的代碼,假設(shè)線程1的num被CPU-0的處理,線程2被CPU-1處理,實(shí)際上底層的實(shí)現(xiàn)是:
- t1獲取共享變量num的值,此時(shí)并沒有其他核心上的線程獲取,狀態(tài)為E(exclusive)。
- t2啟動(dòng)也獲取到num的值,此時(shí)總線嗅探到另一個(gè)CPU也有這個(gè)變量的緩存,所以兩個(gè)CPU緩存行都設(shè)置為S(shard)。
- t2修改num的值,通過總線嗅探機(jī)制發(fā)起通知,t1的線程收到消息后,將緩存行變量設(shè)置為I(invalid)。
- t1需要輸出結(jié)果,因?yàn)榭吹阶约鹤兞渴菬o效的,于是通知總線讓t1將結(jié)果寫回內(nèi)存,自己重新加載。
更多關(guān)于MESI協(xié)議的實(shí)現(xiàn)細(xì)節(jié),感興趣的讀者可以參考筆者的這篇文章:《CPU 緩存一致性問題深度解析》
volatile如何禁止指令重排序
而volatile不僅可以保證可見性,還可以避免指令重排序,底層同樣是通過JMM規(guī)約,禁止特定編譯器進(jìn)行有風(fēng)險(xiǎn)的重排序,以及在生成字節(jié)序列時(shí)插入內(nèi)存屏障避免CPU重排序解決問題。
我們不妨看一段雙重鎖校驗(yàn)的單例模式代碼,代碼如下所示可以看到經(jīng)過雙重鎖校驗(yàn)后,會(huì)進(jìn)行new Singleton();
public class Singleton {
private static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判斷對(duì)象是否已經(jīng)實(shí)例過,沒有實(shí)例化過才進(jìn)入加鎖代碼
if (uniqueInstance == null) {
//類對(duì)象加鎖
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
這一操作,這個(gè)對(duì)象創(chuàng)建的操作乍一看是原子性的,實(shí)際上編譯后再執(zhí)行的機(jī)器碼會(huì)將其分為3個(gè)動(dòng)作:
- 為引用uniqueInstance分配內(nèi)存空間
- 初始化uniqueInstance
- uniqueInstance指向分配的內(nèi)存空間
所以如果沒有volatile 禁止指令重排序的話,1、2、3的順序操作很可能變成1、3、2,進(jìn)而可能出現(xiàn)下面這種情況:
- 線程1執(zhí)行步驟1分配內(nèi)存空間。
- 線程1執(zhí)行步驟3讓引用指向這個(gè)內(nèi)存空間。
- 線程2進(jìn)入邏輯判斷發(fā)現(xiàn)uniqueInstance不為空直接返回,導(dǎo)致外部操作異常。
極端情況下,這種情況可能導(dǎo)致線程2外部操作到的可能是未初始化的對(duì)象,導(dǎo)致一些業(yè)務(wù)上的操作異常:
所以針對(duì)這種情況,我們需要增加volatile 關(guān)鍵字讓禁止這種指令重排序:
private volatile static Singleton uniqueInstance;
按照J(rèn)MM的happens-before原則volatile的變量的寫操作, happens-before后續(xù)讀該變量的代碼,這就會(huì)使的volatile操作可能實(shí)現(xiàn)如下幾點(diǎn):
- 第二個(gè)針對(duì)volatile寫操作時(shí),不管第一個(gè)操作是任何操作,都不能發(fā)生重排序。
- 第一個(gè)針對(duì)volatile讀的操作,后續(xù)volatile任何操作都不能重排序。
- 第一個(gè)volatile寫操作,后續(xù)volatile讀,不能進(jìn)行重排序。
基于這套規(guī)范,在編譯器生成字節(jié)碼時(shí),就會(huì)通過內(nèi)存屏障的方式告知處理器禁止特定的重排序:
- 每個(gè)volatile寫后插入storestore,讓第一個(gè)寫優(yōu)先于第二個(gè)寫,避免重排序后的寫(可以理解未變量計(jì)算)順序重排序?qū)е碌挠?jì)數(shù)結(jié)果異常。
- 每個(gè)volatile寫后插入storeload,讓第一個(gè)寫先于后續(xù)讀,避免讀取異常。
- 每個(gè)volatile讀后加個(gè)loadstore,讓第一個(gè)讀操作先于第二個(gè)寫,避免讀寫重排序的異常。
- 每個(gè)volatile讀后加個(gè)loadload,讓第一個(gè)讀先于第二個(gè)讀,避免讀取順序重排序的異常。
volatile無法保證原子性
我們不妨看看下面這段代碼,首先我們需要了解一下num++這個(gè)操作在底層是如何實(shí)現(xiàn)的:
- 讀取num的值
- 對(duì)num進(jìn)行+1
- 寫回內(nèi)存中
基于jitwatch,我們看到的對(duì)應(yīng)的匯編碼如下:
0x00000000038ca096: mov 0x68(%r10),%r8d
0x00000000038ca09a: inc %r8d
0x00000000038ca09d: mov %r8d,0x68(%r10)
這里蠻補(bǔ)充一句,關(guān)于jitwatch的安裝使用,感興趣的讀者可以參考這篇文章:《初探 JITWatch 從零開始的流程優(yōu)化之旅》
我們查看代碼的運(yùn)行結(jié)果,可以看到最終的值不一定是10000,由此可以得出volatile并不能保證原子性
public class VolatoleAdd {
private static int num = 0;
public void increase() {
num++;
}
public static void main(String[] args) {
int size = 10000;
CountDownLatch downLatch = new CountDownLatch(1);
ExecutorService threadPool = Executors.newFixedThreadPool(size);
VolatoleAdd volatoleAdd = new VolatoleAdd();
for (int i = 0; i < size; i++) {
threadPool.submit(() -> {
try {
downLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
volatoleAdd.increase();
});
}
downLatch.countDown();
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
System.out.println(VolatoleAdd.num);//9998
}
}
而對(duì)應(yīng)的解決方案我們可以通過synchronized、原子類、或者Lock相關(guān)實(shí)現(xiàn)類解決問題。
并發(fā)編程中三個(gè)重要特性
即原子性、有序性、可見性:
- 原子性:一組操作要么全部都完成,要么全部失敗,Java就是基于synchronized或者各種Lock實(shí)現(xiàn)原則性。
- 可見性:線程對(duì)于某些變量的操作,對(duì)于后續(xù)操作該變量的線程是立即可見的。Java基于synchronized或者各種Lock、volatile實(shí)現(xiàn)可見性,例如聲明volatile變量這就意味著Java代碼在操作該變量時(shí)每次都會(huì)從主內(nèi)存中加載。
- 有序性:指令重排序只能保證串行語義一致性,并不能保證多線程情況下也一致,Java常常使用volatile禁止指令進(jìn)行重排序優(yōu)化。