前言
當(dāng)共享變量被聲明為volatile后,對這個(gè)變量的讀/寫操作都會(huì)很特別,下面我們就揭開volatile的神秘面紗。
1.volatile的內(nèi)存語義
1.1 volatile的特性
一個(gè)volatile變量自身具有以下三個(gè)特性:
- 可見性:即當(dāng)一個(gè)線程修改了聲明為volatile變量的值,新值對于其他要讀該變量的線程來說是立即可見的。而普通變量是不能做到這一點(diǎn)的,普通變量的值在線程間傳遞需要通過主內(nèi)存來完成。
- 有序性:volatile變量的所謂有序性也就是被聲明為volatile的變量的臨界區(qū)代碼的執(zhí)行是有順序的,即禁止指令重排序。
- 受限原子性:這里volatile變量的原子性與synchronized的原子性是不同的,synchronized的原子性是指只要聲明為synchronized的方法或代碼塊兒在執(zhí)行上就是原子操作的。而volatile是不修飾方法或代碼塊兒的,它用來修飾變量,對于單個(gè)volatile變量的讀/寫操作都具有原子性,但類似于volatile++這種復(fù)合操作不具有原子性。所以volatile的原子性是受限制的。并且在多線程環(huán)境中,volatile并不能保證原子性。
1.2 volatile寫-讀的內(nèi)存語義
volatile寫的內(nèi)存語義:當(dāng)寫線程寫一個(gè)volatile變量時(shí),JMM會(huì)把該線程對應(yīng)的本地內(nèi)存中的共享變量值刷新到主內(nèi)存。
volatile讀的內(nèi)存語義:當(dāng)讀線程讀一個(gè)volatile變量時(shí),JMM會(huì)把該線程對應(yīng)的本地內(nèi)存置為無效,線程接下來將從主內(nèi)存讀取共享變量。
2.volatile語義實(shí)現(xiàn)原理
在介紹volatile語義實(shí)現(xiàn)原理之前,我們先來看兩個(gè)與CPU相關(guān)的專業(yè)術(shù)語:
- 內(nèi)存屏障(memory barriers):一組處理器指令,用于實(shí)現(xiàn)對內(nèi)存操作的順序限制。
- 緩存行(cache line):CPU高速緩存中可以分配的最小存儲(chǔ)單位。處理器填寫緩存行時(shí)會(huì)加載整個(gè)緩存行。
2.1 volatile可見性實(shí)現(xiàn)原理
volatile可見性的內(nèi)存語義是如何實(shí)現(xiàn)的呢?下面我們看一段代碼,并將代碼生成的處理器的匯編指令打印出來(關(guān)于如何打印匯編指令,我會(huì)在文章末尾附上),看下對volatile變量進(jìn)行寫操作時(shí),CPU會(huì)做什么事情:
public class VolatileTest {
private static volatile VolatileTest instance = null;
private VolatileTest(){}
public static VolatileTest getInstance(){
if(instance == null){
instance = new VolatileTest();
}
return instance;
}
public static void main(String[] args) {
VolatileTest.getInstance();
}
}
以上的代碼是一個(gè)我們非常熟悉的在多線程環(huán)境中不能保證線程安全的單例模式代碼,這段代碼中特殊的地方是,我將實(shí)例變量instance加上了volatile修飾,下面看打印的匯編指令:
上面截圖中,我們看到我劃線的一行的末尾有一句匯編注釋:putstatic instance,了解JVM 字節(jié)碼指令的小伙伴都知道,putstatic的含義是給一個(gè)靜態(tài)變量設(shè)置值,在上述代碼中也就是給靜態(tài)變量instance賦值,對應(yīng)代碼:instance = new VolatileTest();在getInstance方法中為instance實(shí)例化,因?yàn)閕nstance加了volatile修飾,所以給靜態(tài)變量instance設(shè)置值也是在寫一個(gè)volatile變量。
看到上述有匯編指令,也有字節(jié)碼指令,大家會(huì)不會(huì)混淆這兩種指令,這里我指明一下字節(jié)碼指令和匯編指令的區(qū)別:
我們都知道java是一種跨平臺(tái)的語言,那么java是如何實(shí)現(xiàn)這種平臺(tái)無關(guān)性的呢?這就需要我們了解JVM和java的字節(jié)碼文件。這里我們需要有一點(diǎn)共識(shí),就是任何一門編程語言都需要轉(zhuǎn)換為與平臺(tái)相關(guān)的匯編指令才能夠最終被硬件執(zhí)行,比如C和C++都將我們的源代碼直接編譯成與CPU相關(guān)的匯編指令給CPU執(zhí)行。 不同系列的CPU的體系架構(gòu)不同,所以它們的匯編指令也有不同,比如X86架構(gòu)的CPU對應(yīng)于X86匯編指令,arm架構(gòu)的CPU對應(yīng)于arm匯編指令。如果將程序源代碼直接編譯成與硬件相關(guān)的底層匯編指令,那么程序的跨平臺(tái)性也就大打折扣,但執(zhí)行性能相對較高。為了實(shí)現(xiàn)平臺(tái)無關(guān)性,java的編譯器javac并不是將java的源程序直接編譯成與平臺(tái)相關(guān)的匯編指令,而是編譯成一種中間語言,即java的class字節(jié)碼文件。字節(jié)碼文件,顧名思義存的就是字節(jié)碼,即一個(gè)一個(gè)的字節(jié)。有打開過java字節(jié)碼文件研讀過的小伙伴可能會(huì)發(fā)現(xiàn),字節(jié)碼文件里面存的并不是二進(jìn)制,而是十六進(jìn)制,這是因?yàn)槎M(jìn)制太長了,一個(gè)字節(jié)要由8位二進(jìn)制組成。所以用十六進(jìn)制表示,兩個(gè)十六進(jìn)制就可以表示一個(gè)字節(jié)。java源碼編譯后的字節(jié)碼文件是不能夠直接被CPU執(zhí)行的,那么該如何執(zhí)行呢?答案是JVM,為了讓java程序能夠在不同的平臺(tái)上執(zhí)行,java官方提供了針對于各個(gè)平臺(tái)的java虛擬機(jī),JVM運(yùn)行于硬件層之上,屏蔽各種平臺(tái)的差異性。javac編譯后的字節(jié)碼文件統(tǒng)一由JVM來加載,最后再轉(zhuǎn)化成與硬件相關(guān)的機(jī)器指令被CPU執(zhí)行。知道了通過JVM來加載字節(jié)碼文件,那么還有一個(gè)問題,就是JVM如何將字節(jié)碼中的每個(gè)字節(jié)和我們寫的java源代碼相關(guān)聯(lián),也就是JVM如何知道我們寫的java源代碼對應(yīng)于class文件中的哪段十六進(jìn)制,這段十六進(jìn)制是干什么的,執(zhí)行了什么功能?并且一大堆的十六進(jìn)制,我們也看不懂啊。所以這就需要定義一個(gè)JVM層面的規(guī)范,在JVM層面抽象出一些我們能夠認(rèn)識(shí)的指令助記符,這些指令助記符就是java的字節(jié)碼指令。
再看上面的截圖,當(dāng)寫instance這個(gè)volatile變量時(shí),發(fā)現(xiàn)add前面加個(gè)一個(gè)lock指令,我在截圖中框了出來,如何不加volatile修飾,是沒有l(wèi)ock的。
lock指令在多核處理器下會(huì)引發(fā)下面的事件:
將當(dāng)前處理器的緩存行的數(shù)據(jù)寫回到系統(tǒng)內(nèi)存,同時(shí)使其他CPU里緩存了該內(nèi)存地址的數(shù)據(jù)置為無效。
為了提高處理速度,處理器一般不直接和內(nèi)存通信,而是先將系統(tǒng)內(nèi)存的數(shù)據(jù)讀到內(nèi)部緩存后再進(jìn)行操作,但操作完成后并不知道處理器何時(shí)將緩存數(shù)據(jù)寫回到內(nèi)存。但如果對加了volatile修飾的變量進(jìn)行寫操作,JVM就會(huì)向處理器發(fā)送一條lock前綴的指令,將這個(gè)變量在緩存行的數(shù)據(jù)寫回到系統(tǒng)內(nèi)存。這時(shí)只是寫回到系統(tǒng)內(nèi)存,但其他處理器的緩存行中的數(shù)據(jù)還是舊的,要使其他處理器緩存行的數(shù)據(jù)也是新寫回的系統(tǒng)內(nèi)存的數(shù)據(jù),就需要實(shí)現(xiàn)緩存一致性協(xié)議。即在一個(gè)處理器將自己緩存行的數(shù)據(jù)寫回到系統(tǒng)內(nèi)存后,其他的每個(gè)處理器就會(huì)通過嗅探在總線上傳播的數(shù)據(jù)來檢查自己緩存的數(shù)據(jù)是否已過期,當(dāng)處理器發(fā)現(xiàn)自己緩存行對應(yīng)的內(nèi)存地址的數(shù)據(jù)被修改后,就會(huì)將自己緩存行緩存的數(shù)據(jù)設(shè)置為無效,當(dāng)處理器要對這個(gè)數(shù)據(jù)進(jìn)行修改操作的時(shí)候,會(huì)重新從系統(tǒng)內(nèi)存中把數(shù)據(jù)讀取到自己的緩存行,重新緩存。
總結(jié)下:volatile可見性的實(shí)現(xiàn)就是借助了CPU的lock指令,通過在寫volatile的機(jī)器指令前加上lock前綴,使寫volatile具有以下兩個(gè)原則:
- 寫volatile時(shí)處理器會(huì)將緩存寫回到主內(nèi)存。
- 一個(gè)處理器的緩存寫回到內(nèi)存會(huì)導(dǎo)致其他處理器的緩存失效。
2.2 volatile有序性的實(shí)現(xiàn)原理
volatile有序性的保證就是通過禁止指令重排序來實(shí)現(xiàn)的。指令重排序包括編譯器和處理器重排序,JMM會(huì)分別限制這兩種指令重排序。
那么禁止指令重排序又是如何實(shí)現(xiàn)的呢?答案是加內(nèi)存屏障。JMM為volatile加內(nèi)存屏障有以下4種情況:
- 在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障,防止寫volatile與后面的寫操作重排序。
- 在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障,防止寫volatile與后面的讀操作重排序。
- 在每個(gè)volatile讀操作的后面插入一個(gè)LoadLoad屏障,防止讀volatile與后面的讀操作重排序。
- 在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障,防止讀volatile與后面的寫操作重排序。
上述內(nèi)存屏障的插入策略是非常保守的,比如一個(gè)volatile的寫操作后面需要加上StoreStore和StoreLoad屏障,但這個(gè)寫volatile后面可能并沒有讀操作,因此理論上只加上StoreStore屏障就可以,的確,有的處理器就是這么做的。但JMM這種保守的內(nèi)存屏障插入策略能夠保證在任意的處理器平臺(tái),volatile變量都是有序的。
3.JSR-133對volatile內(nèi)存語義的增強(qiáng)
在JSR-133之前的舊的java內(nèi)存模型中,雖然不允許對volatile變量之間的操作進(jìn)行重排序,但允許對volatile變量與普通變量之間進(jìn)行重排序。比如內(nèi)存屏障前面是一個(gè)寫volatile變量的操作,內(nèi)存屏障后面的操作是一個(gè)寫普通變量的操作,即使這兩個(gè)寫操作可能會(huì)破壞volatile內(nèi)存語義,但JMM是允許這兩個(gè)操作進(jìn)行重排序的。
在JSR-133以及后面的新的java內(nèi)存模型中,增強(qiáng)了volatile的內(nèi)存語義。只要volatile變量與普通變量之間的重排序可能會(huì)破壞volatile的內(nèi)存語義,這種重排序就會(huì)被編譯器重排序規(guī)則和處理器內(nèi)存屏障出入策略禁止。
附:配置idea打印匯編指令
工具包下載地址:鏈接:https://pan.baidu.com/s/11yRnsOHca5EVRfE9gAuVxA
提取碼:gn8z
將下載的工具包解壓,復(fù)制到j(luò)dk安裝目錄的jre路徑下的bin目錄中,如圖:
?
然后配置idea,在 VM options 選項(xiàng)中輸入:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*類名.方法名
JRE選項(xiàng)選擇已放入工具包的jre路徑。
下圖是我的idea配置:
以上配置好后運(yùn)行就可以打印匯編指令了。