詳解 Java 并發(fā)編程 volatile 關鍵字
一、詳解volatile關鍵字
1. 普通變量并發(fā)操作的不可見性
我們編寫一段多線程讀寫一個變量的代碼,t1一旦感知num被t2修改,就會結(jié)束循環(huán),然而事實卻是這段代碼即使在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é)束");
}
2. 最低線程安全和64位變量的風險
針對上述的情況,多線程在沒有正確的同步情況下,可能拿到一個失效的變量值,但它并非是沒有任何修改操作,我們稱這種變量為最低線程安全,當然這種概念也僅僅是針對一些例如int這樣的基本類型。
若是64位例如double和long,因為JMM內(nèi)存模型上規(guī)定了該變量操作在不同的處理器上進行運算操作,這就是的64位操作無法保證原子性,更談不上最低線程安全性了。
3. 通過volatile修飾保證可見性
于是我們將代碼增一個本文所引出的關鍵字volatile 加以修飾:
private volatile static int num = 0;
對應的我們給出輸出結(jié)果,如預期一樣線程修改完之后線程1就會感知到變化而結(jié)束循環(huán),由此可知volatile關鍵字的第一個語義——保證并發(fā)場景下共享變量的可見性:
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é)束
4. 基于JMM模型詳解volatile的可見性
實際上,volatile底層實現(xiàn)和JMM內(nèi)存模型規(guī)范息息相關,該模型規(guī)范了線程的本地變量(各個線程拿到共享變量num的副本)和主存(內(nèi)存中的變量num)的關系,其規(guī)范通過happens-before等規(guī)約強制規(guī)范了JVM需要針對這幾個原則要求做出相應的處理來配合處理器保證共享變量操作的可見性和有序性。
這就要求t1和t2修改num的時候,都必須從主存中先加載才能進行修改,以上述代碼為例,假設t1修改了num的值,完成后就必須將最新的結(jié)果寫回主存中,而t2收到這個修改的通知后必須從主內(nèi)存中拉取最新的結(jié)果才能進行操作:
關于JMM更多知識,感興趣的讀者可以看看筆者這篇文章:《詳解 JMM 內(nèi)存模型》。
上述這個流程只是JMM模型的抽象,也就是JVM便于讓程序員理解的一種抽象模型而實際的落地, 所以為了更好理解volatile關鍵字修飾的變量,我們還是以上述的例子了解一下:
private volatile static int num = 0;
public static void main(String[] args) throws InterruptedException {
num = 24;
num++;
}
對應的我們給出JIT后的匯編碼:
# num = 24;
0x000000000368cd76: mov $0x18,%edi # 將24加載到edi寄存器
0x000000000368cd7b: mov %edi,0x68(%rsi) # 將edi寄存器的值存儲到內(nèi)存地址為[rsi + 0x68] 也就是變量num
0x000000000368cd7e: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@2 (line 13) # lock前綴起到類似內(nèi)存屏障的作用,保證num=24這個寫操作對內(nèi)存中所有的處理器可見
# num++;
0x000000000368cd83: mov 0x68(%rsi),%edi ;*getstatic num
; - org.example.Main::main@5 (line 14) # 將num值加載到edi寄存器
0x000000000368cd86: inc %edi # 基于increase將寄存器上的值也就是24加上1
0x000000000368cd88: mov %edi,0x68(%rsi) # 將edi寄存器上的值賦值給num
0x000000000368cd8b: lock addl $0x0,(%rsp) ;*putstatic num
; - org.example.Main::main@10 (line 14) # 基于lock前綴實現(xiàn)JMM規(guī)范中的寫回主存中,保證所有線程可見
針對num賦值為24這操作,匯編指令執(zhí)行了如下三步:
- 通過mov $0x18,%edi將24(0x18)加載到edi寄存器。
- mov %edi,0x68(%rsi)將這個24復制給num。
- 重點來了,num=24即位于main 23行的代碼,它的字節(jié)碼為putstatic num這步本質(zhì)就是完成變量的賦值,實際上在完成變量賦值之后,它通過lock前綴指令起到一個內(nèi)存屏障的作用,保證上述的賦值操作對于所有的處理器可見,也就是實現(xiàn)JMM規(guī)范中的寫入主存操作(下文會從硬件層面分析該指令),由此保證num++操作時會先通過getstatic 到主存中獲取最新值到本地內(nèi)存中完成自增操作。
同樣的num++也是同理,可以看到對應注釋的匯編碼,在完成自增即inc 操作后,同樣執(zhí)行l(wèi)ock前綴指令將數(shù)據(jù)寫入主存。
5. 關于volatile可見性在硬件層面的分析
上文我們以JMM規(guī)范粗略的講解了lock前綴在規(guī)范層面上的可見性,查閱IA-32架構(gòu)軟件開發(fā)者手冊可知,lock前綴的指令在多核處理器下會引發(fā)了兩件事情:
- 將當前變量num從當前處理器的緩存行(cache-line)數(shù)據(jù)寫回內(nèi)存。
- 此時,硬件層面上執(zhí)行當前的CPU會通知其他處理器該變量已被修改,其他處理器cache-line中的num值全部變?yōu)閕nvalid(無效)。
這也就是我們Intel 64著名的MESI協(xié)議,將該實現(xiàn)代入我們的代碼,假設線程1的num被CPU-0的處理,線程2被CPU-1處理,實際上底層的實現(xiàn)是:
- t1獲取共享變量num的值,此時并沒有其他核心上的線程獲取,狀態(tài)為E(exclusive)。
- t2啟動也獲取到num的值,此時總線嗅探到另一個CPU也有這個變量的緩存,所以兩個CPU緩存行都設置為S(shard)。
- t2修改num的值,通過總線嗅探機制發(fā)起通知,t1的線程收到消息后,將緩存行變量設置為I(invalid)。
- t1需要輸出結(jié)果,因為看到自己變量是無效的,于是通知總線讓t1將結(jié)果寫回內(nèi)存,自己重新加載。
更多關于MESI協(xié)議的實現(xiàn)細節(jié),感興趣的讀者可以參考筆者的這篇文章:《CPU 緩存一致性問題深度解析》
volatile無法保證原子性
我們不妨看看下面這段代碼,首先我們需要了解一下的:
private static volatile int num;
public static void main(String[] args) throws InterruptedException {
num++;
}
因為這段代碼位于筆者IDE的13行,基于該信息筆者拿到對應的字節(jié)碼,可以看到num++這個操作在底層實現(xiàn)如下,大體來說分為三步:
- GETSTATIC 讀取num的值推到棧頂。
- ICONST_1將常量1壓入操作數(shù)棧。
- IADD將棧頂?shù)膎um和1進行相加。
寫回內(nèi)存中PUTSTATIC 寫回主存。
LINENUMBER 13 L0
GETSTATIC org/example/Main.num : I
ICONST_1
IADD
PUTSTATIC org/example/Main.num : I
更進一步,基于jitwatch,我們看到的對應的匯編碼如下,同樣可以看到讀取、自增、寫回操作:
0x00000000038ca096: mov 0x68(%r10),%r8d
0x00000000038ca09a: inc %r8d
0x00000000038ca09d: mov %r8d,0x68(%r10)
很明顯一個自增操作是由多條指令完成,這也就意味著,在上述指令執(zhí)行期間,很可能出現(xiàn)其他線程讀取到自增后但是還未寫到內(nèi)存的過期值:
這里蠻補充一句,關于jitwatch的安裝使用,感興趣的讀者可以參考這篇文章:《初探 JITWatch 從零開始的流程優(yōu)化之旅》
我們查看代碼的運行結(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
}
}
而對應的解決方案我們可以通過synchronized、原子類、或者Lock相關實現(xiàn)類解決問題。
6. volatile如何禁止指令重排序
而volatile不僅可以保證可見性,還可以避免指令重排序,底層同樣是通過JMM規(guī)約,禁止特定編譯器進行有風險的重排序,以及在生成字節(jié)序列時插入內(nèi)存屏障避免CPU重排序解決問題。
我們不妨看一段雙重鎖校驗的單例模式代碼,代碼如下所示可以看到經(jīng)過雙重鎖校驗后,會進行new Singleton();
public class Singleton {
private static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
//先判斷對象是否已經(jīng)實例過,沒有實例化過才進入加鎖代碼
if (uniqueInstance == null) {
//類對象加鎖
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
這一操作,這個對象創(chuàng)建的操作乍一看是原子性的,實際上編譯后再執(zhí)行的機器碼會將其分為3個動作:
- 為引用uniqueInstance分配內(nèi)存空間
- 初始化uniqueInstance
- uniqueInstance指向分配的內(nèi)存空間
所以如果沒有volatile 禁止指令重排序的話,1、2、3的順序操作很可能變成1、3、2,進而可能出現(xiàn)下面這種情況:
- 線程1執(zhí)行步驟1分配內(nèi)存空間。
- 線程1執(zhí)行步驟3讓引用指向這個內(nèi)存空間。
- 線程2進入邏輯判斷發(fā)現(xiàn)uniqueInstance不為空直接返回,導致外部操作異常。
極端情況下,這種情況可能導致線程2外部操作到的可能是未初始化的對象,導致一些業(yè)務上的操作異常:
所以針對這種情況,我們需要增加volatile 關鍵字讓禁止這種指令重排序:
private volatile static Singleton uniqueInstance;
按照JMM的happens-before原則volatile的變量的寫操作, happens-before后續(xù)讀該變量的代碼,這就會使的volatile操作可能實現(xiàn)如下幾點:
- 第二個針對volatile寫操作時,不管第一個操作是任何操作,都不能發(fā)生重排序。
- 第一個針對volatile讀的操作,后續(xù)volatile任何操作都不能重排序。
- 第一個volatile寫操作,后續(xù)volatile讀,不能進行重排序。
基于這套規(guī)范,在編譯器生成字節(jié)碼時,就會通過內(nèi)存屏障的方式告知處理器禁止特定的重排序:
- 每個volatile寫后插入storestore,讓第一個寫優(yōu)先于第二個寫,避免重排序后的寫(可以理解未變量計算)順序重排序?qū)е碌挠嫈?shù)結(jié)果異常。
- 每個volatile寫后插入storeload,讓第一個寫先于后續(xù)讀,避免讀取異常。
- 每個volatile讀后加個loadstore,讓第一個讀操作先于第二個寫,避免讀寫重排序的異常。
- 每個volatile讀后加個loadload,讓第一個讀先于第二個讀,避免讀取順序重排序的異常。
回過頭來,對于內(nèi)存屏障的實現(xiàn),以我們的單例模式初始化對象實例來說,其硬件架構(gòu)的實現(xiàn)上,這個new的操作涉及多條指令,在處理器執(zhí)行時可能會不按照規(guī)定順序交由不同的電路單元執(zhí)行,這就可能出現(xiàn)上述所謂1、3、2的情況。
對應的我們給出相應的匯編指令,可能看到其核心執(zhí)行步驟為如下三步:
- 調(diào)用JVM內(nèi)部函數(shù),在堆內(nèi)存上分配Singleton內(nèi)存并完成對象創(chuàng)建,也就是在堆內(nèi)存中創(chuàng)建單例instance對象。
- 獲取靜態(tài)變量存儲位置到r11上,即將元空間的靜態(tài)變量instance放到寄存器上為后續(xù)將步驟1所new的對象分配給該引用做好準備。
- 通過cmpxchg 源自指令比對r11對應的引用instance是否為null,若為null則說明沒有被其他線程初始化過,則將r10創(chuàng)建的對象分配到該引用上,同時基于lock前綴將該引用的最近創(chuàng)建結(jié)果寫入內(nèi)存,交由CPU硬件層面的MESI協(xié)議讓其他處理器可以看到最新結(jié)果。
對于避免指令重排序的語義,我們同第三條指令就能理解,即lock需要將更新操作寫入內(nèi)存這一特性,保證lock前綴之上的步驟1和步驟2的操作都必須完成之后,才能執(zhí)行原子性的將創(chuàng)建的對象賦值給靜態(tài)變量instance的操作,即通過硬件層面的lock前綴保證有數(shù)據(jù)的情況下才能完成對象復制,從而形成一種指令無法超越內(nèi)存屏障的效果,由此具備避免指令重排序的語義:
# 調(diào)用JVM內(nèi)部函數(shù),在堆內(nèi)存上分配Singleton內(nèi)存并完成對象創(chuàng)建
0x0000000003d9300f: callq 0x00000000039057a0 ; OopMap{off=372}
;*new ; - org.example.Singleton::getUniqueInstance@17 (line 16)
; {runtime_call}
0x0000000003d93014: int3 ;*new ; - org.example.Singleton::getUniqueInstance@17 (line 16)
# 獲取靜態(tài)變量存儲位置到r11上,即將元空間的靜態(tài)變量instance放到寄存器上
L0009: movabs $0x76b95d828,%r11 ; {oop(a 'java/lang/Class' = 'org/example/Singleton')}
# 保證上述操作完成后,通過cmpxchg 源自指令比對r11對應的引用instance是否為null,若為null則說明沒有被其他線程初始化過,則將r10創(chuàng)建的對象分配到該引用上,同時基于lock前綴做到一個類似內(nèi)存屏障的作用,由此避免指令重排序
0x0000000003d9301f: lock cmpxchg %r10,(%r11)
# 執(zhí)行后續(xù)操作
二、關于volatile一些更進一步的理解
1. volatile在并發(fā)場景中的性能表現(xiàn)和運用
關于volatile性能的討論,實際上在jdk8以上synchronized 關鍵字的鎖升級的優(yōu)化機制上很說明兩者的差異,我們大體只能得出如下三個結(jié)論:
- 相較于普通變量num和加上volatile修飾后的普通變量num,因為后者存在一致性問題需要lock前綴寫回主存,所以后者性能表現(xiàn)比普通變量表現(xiàn)差。
- 對于單線程修改,多線程讀取并發(fā)共享變量的場景,我們更建議使用volatile,盡可能避免高并發(fā)場景下單修改多讀取變量的重量級鎖開銷。
- 對于并發(fā)修改,建議使用volatile配合鎖來保證可見性和數(shù)據(jù)一致性。
2. volatile與并發(fā)編程中三個重要特性
即原子性、有序性、可見性:
- 原子性:一組操作要么全部都完成,要么全部失敗,Java就是基于synchronized或者各種Lock實現(xiàn)原則性。
- 可見性:線程對于某些變量的操作,對于后續(xù)操作該變量的線程是立即可見的。Java基于synchronized或者各種Lock、volatile實現(xiàn)可見性,例如聲明volatile變量這就意味著Java代碼在操作該變量時每次都會從主內(nèi)存中加載。
- 有序性:指令重排序只能保證串行語義一致性,并不能保證多線程情況下也一致,Java常常使用volatile禁止指令進行重排序優(yōu)化。
三、小結(jié)
至此我們從幾個簡單的實踐案例和volatile底層匯編碼等多個角度為該關鍵字進行深入分析,希望對你有幫助。