前言
在講述Volatile關(guān)鍵字之前,我們先大概講一下cpu多核并發(fā)緩存架構(gòu),再到JMM,即java內(nèi)存模型,最后到volatile關(guān)鍵字。
JMM(Java內(nèi)存模型)
多核并發(fā)緩存架構(gòu)的引入
為了解決CPU和主內(nèi)存速度交互的不匹配問題,計算機(jī)在設(shè)計的時候在中間加幾級緩存(一般放在CPU內(nèi)部的,這里是為了好看畫到中間了),高速緩存讀取速度非???,CPU和高速緩存交互,程序結(jié)束后,會把緩存中的數(shù)據(jù)同步到主內(nèi)存再回寫到硬盤。

而Java線程的內(nèi)存模型和CPU緩存模型是類似的,是基于CPU緩存模型建立起來的。Java線程的內(nèi)存模型是標(biāo)準(zhǔn)化的,屏蔽掉了底層不同計算機(jī)的區(qū)別。如下圖顯示:

和CPU一樣,線程A為了解決跟主內(nèi)存速度不匹配問題,會把這個共享變量copy到線程的工作內(nèi)存。線程讀取共享變量數(shù)據(jù)是和工作內(nèi)存中變量的副本做交互。這里的工作內(nèi)存類似于緩存。
JMM數(shù)據(jù)原子操作
JMM有8個原子操作,按照使用流程來排序,分別如下:

在這里介紹java的數(shù)據(jù)原子操作,是為了更好的為下面的問題鋪墊。
CPU緩存不一致問題
對于多核CPU而言,當(dāng)共享變量同時被加載到緩存中并在多個核心中都同時進(jìn)行操作時,當(dāng)核心A修改了變量a后,核心B不知道a已經(jīng)做了修改,繼續(xù)推進(jìn)核心B的線程工作,這樣子,程序就會出現(xiàn)問題,因此就存在了緩存不一致問題。
為了解決CPU緩存不一致問題,工程師主要使用了兩種方式。早期主要使用總線加鎖方式。
總線加鎖:即cpu從主內(nèi)存讀取數(shù)據(jù)到高速緩存,會在總線對這個數(shù)據(jù)加鎖,這樣其他cpu核心沒有辦法去讀或者寫這個數(shù)據(jù),直到這個cpu使用完數(shù)據(jù)并釋放鎖之后,其他cpu核心才能讀取該數(shù)據(jù)。該方式可以用java內(nèi)存模型和java數(shù)據(jù)原子操作來體現(xiàn),如下圖:

當(dāng)一個線程讀取主內(nèi)存中某個變量的時候,就會對這個變量加鎖,其他CPU(線程)想從主內(nèi)存讀這個變量的數(shù)據(jù)是讀不到的,直到這把鎖釋放了才能讀取到該變量的值,并在其他CPU中做運(yùn)算。在read之前會執(zhí)行l(wèi)ock操作,標(biāo)識為線程獨(dú)占狀態(tài),在write寫回主內(nèi)存的時候會做個unlock操作,解鎖后其他線程可以鎖定該變量。早期的CPU為了解決可見性,一致性問題,把一個并行執(zhí)行的程序最終變成串行執(zhí)行。
顯然該方案不可行,后來,工程師使用了MESI緩存一致性協(xié)議來解決該問題
MESI緩存一致性協(xié)議:多個cpu從主內(nèi)存讀取同一個數(shù)據(jù)到各自的高速緩存中,當(dāng)其中某個cpu修改了緩存里的數(shù)據(jù),該數(shù)據(jù)會馬上同步回主內(nèi)存,其他cpu通過總線嗅探機(jī)制可以感知到數(shù)據(jù)的變化從而將自己緩存里的數(shù)據(jù)失效.
如下圖:

CPU和內(nèi)存之間通過總線相連接。各個線程都從主內(nèi)存中讀數(shù)據(jù),實(shí)現(xiàn)了并行。當(dāng)線程2修改initFlag變量后,執(zhí)行store操作時,此時會把這個工作內(nèi)存中修改的數(shù)據(jù)initFlag=true變量的值回寫到主內(nèi)存中,最后執(zhí)行write替換主內(nèi)存中的值。一旦執(zhí)行store此原子操作,該數(shù)據(jù)會通過總線回寫到主內(nèi)存,MESI緩存一致性協(xié)議有個CPU總線嗅探機(jī)制(通過硬件實(shí)現(xiàn)):當(dāng)其中某個線程(這里是線程2)把修改的變量的值從工作內(nèi)存往主內(nèi)存回寫的時候,只要數(shù)據(jù)通過總線,其他的CPU(這里是線程1)會對這個總線做一個監(jiān)聽,對總線中感興趣的變量不斷監(jiān)聽數(shù)據(jù)流動,發(fā)現(xiàn)有其他CPU(這里是線程1)感興趣的變量的時候,MESI緩存一致性協(xié)議就會通過總線嗅探機(jī)制把這個其他CPU(這里是線程1)中工作內(nèi)存中的同一個變量的值置為無效。然后線程1再執(zhí)行循環(huán)操作的時候,發(fā)現(xiàn)initFlag失效了,就重新從主內(nèi)存去readinitFlag,而此時主內(nèi)存中的initFlag已經(jīng)被修改過了(為true),線程1就能拿到最新的值了。就能通過MESI緩存一致性協(xié)議和總線嗅探機(jī)制可以讓程序達(dá)到緩存一致性。
java代碼演示不可見性
說完了CPU緩存不一致解決方案,接下來,我們通過java代碼演示一下多線程下緩存不一致性的問題,也稱為不可見性。
public class JMM {
private static boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!initFlag) {
}
System.out.println("hello...");
}).start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
System.out.println("......");
initFlag = true;
System.out.println("修改成功....");
}).start();
}
}
查看輸出結(jié)果:代碼運(yùn)行后結(jié)果只輸出線程二的信息。主要原因在于兩個核CPU不可見性。


可以看出,多線程情況下,java代碼的共享變量initFlag也存在不可見性,那么,java是怎么解決緩存不一致問題的呢?引入了Volatile關(guān)鍵字
Volatile的作用
我們通過使用volatile修飾變量initFlag,查看代碼運(yùn)行狀態(tài)。
public class JMM {
private static volatile boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!initFlag) {
}
System.out.println("hello...");
}).start();
TimeUnit.SECONDS.sleep(2);
new Thread(() -> {
System.out.println("......");
initFlag = true;
System.out.println("修改成功....");
}).start();
}
}
線程2對initFlag的修改,線程1中的initFlag是可以感知到的,即java的關(guān)鍵字volatile可以解決緩存一致性問題。

那volatile是如何解決緩存一致性問題的呢?
Volatile緩存可見性實(shí)現(xiàn)原理:
底層實(shí)現(xiàn)主要是通過匯編lock前綴指令,該指令會鎖定這塊內(nèi)存區(qū)域的緩存(緩存行鎖定)并寫回主內(nèi)存。
IA-32架構(gòu)軟件開發(fā)者手冊對lock指令的解釋:
1)會將當(dāng)前處理器緩存行的數(shù)據(jù)立即寫回系統(tǒng)內(nèi)存
2)這個寫回內(nèi)存的操作會引起其他CPU里緩存了該內(nèi)存地址的數(shù)據(jù)無效(MESI)
看不懂上面在說什么?沒關(guān)系,記住3點(diǎn)一共:
1)將當(dāng)前處理器緩存行的數(shù)據(jù)立即寫回系統(tǒng)內(nèi)存
2)這個寫回內(nèi)存的操作會引起其他CPU里緩存了該內(nèi)存地址的數(shù)據(jù)無效(MESI)
3)在store前加鎖lock,write后unlock
通過對上面的Java程序轉(zhuǎn)為匯編代碼查看(之前看b站的老師轉(zhuǎn)過,具體我也沒轉(zhuǎn),挺麻煩的,這里保留了他的截圖)


Java實(shí)現(xiàn)緩存一致性問題
由Java內(nèi)存模型可以看到,參考了CPU的緩存模型,因此多核多線程情況下存在緩存一致性問題。由第5點(diǎn)可知,java在處理緩存一致性問題的時候,使用了volatile關(guān)鍵字進(jìn)行處理。那么,java是如何通過實(shí)現(xiàn)volatile解決緩存不一致問題呢?java參考了CPU解決思路,同時把總線加鎖和MESI緩存一致性協(xié)議進(jìn)行了結(jié)合, 結(jié)合MESI緩存一致性協(xié)議的加鎖的實(shí)現(xiàn) = volatile,也就解決了緩存一致性問題。
具體如下:
線程1和線程2可以同時從主內(nèi)存中讀取共享變量initFlag到各自工作內(nèi)存,然后調(diào)用各種執(zhí)行引擎處理該變量,而對該共享變量加上volatile指令后,在線程二執(zhí)行initFlag= true的時候,會加上lock的前綴匯編指令,該指令使得CPU底層會把修改的工作內(nèi)存副本變量的值立即寫回系統(tǒng)內(nèi)存。而且這個數(shù)據(jù)經(jīng)過總線時,讓CPU總線上MESI緩存一致性協(xié)議以及結(jié)合CPU總線嗅探機(jī)制讓其他CPU緩存里面那個相同的副本變量失效,同時會鎖定這塊內(nèi)存區(qū)域的緩存(也就是即將store到內(nèi)存區(qū)域的時候先鎖一下), 在store回主內(nèi)存的時候,會先做個lock操作,然后回寫完了后,做一個unlock(write后)操作。 這樣子,就可以解決緩存一致性問題了。
和總線加鎖的區(qū)別
volatile的底層實(shí)現(xiàn)是:結(jié)合MESI緩存一致性協(xié)議的加鎖的實(shí)現(xiàn),該實(shí)現(xiàn)和總線加鎖的區(qū)別在哪里?
volatile把這個鎖的密度大大減小,性能非常高,一開始read的時候各個CPU都能read,但是在回寫主內(nèi)存的時候其他CPU沒法運(yùn)算。若volatile不加lock操作和unlock操作的話,只使用緩存一致性協(xié)議和總線嗅探機(jī)制,是否有問題??
不加lock,數(shù)據(jù)剛往總線這邊同步(即剛剛回寫主內(nèi)存),這個數(shù)據(jù)還沒寫到主內(nèi)存中的變量中(即這個變量initFlag還沒改為true),而其他CPU通過MESI緩存一致性協(xié)議里面的總線嗅探機(jī)制監(jiān)聽到這個initFlag的值的變動,馬上把其他線程中的工作內(nèi)存的值失效。而其他CPU(線程1)還在持續(xù)執(zhí)行while操作,發(fā)現(xiàn)initFlag失效,就馬上從主內(nèi)存中讀initFlag,這個線程2還沒馬上把initFlag修改過的值寫到主內(nèi)存,因此此時其他CPU(線程1)讀的還是原來的老數(shù)據(jù)。所以lock前綴指令必須對store之前加一把鎖,在真正write到主內(nèi)存后,再去把這把鎖釋放掉,就是為了防止數(shù)據(jù)還是有些誤讀(時間差的問題),這個鎖的密度非常小,只是對主內(nèi)存賦一個值,對內(nèi)存操作,速度得多塊,內(nèi)存級別的并發(fā)量至少每秒幾十萬上百萬的操作。只是做變量地址的賦值操作,在這么一個短時間內(nèi)加一把鎖非??欤。?!
volatile不保證原子性
講到這了,大家應(yīng)該都清楚并發(fā)編程三大特性:可見性、原子性、有序性
而Volatile可以保證可見性和有序性,但是不能保證原子性,原子性可以借助synchronized的鎖機(jī)制或者并發(fā)包下的原子類進(jìn)行處理,這個原子性下一篇博客會進(jìn)行總結(jié)。
代碼演示一下volatile不保證原子性。
public class VolatileAtomicTest {
public static volatile int num = 0;
public static void increase() {
num++;// num = num + 1
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < 100000; j++) {
increase();
}
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println(num) ;// 結(jié)果是小于或等于1000000
}
}

不保證原子性原因
線程1做完++操作,然后一旦做完assign操作后,就會寫主內(nèi)存。但是出現(xiàn)一種情況,當(dāng)線程1做完++后,剛assign值的時候,這個回寫操作還沒做的時候,線程2 也做了num++了,同時也assign結(jié)束了,兩個線程就同時向主內(nèi)存回寫。誰先回寫的(哪個線程的數(shù)據(jù)先到達(dá)總線),那個線程就會通過volatile關(guān)鍵字給該數(shù)據(jù)加一把鎖,后到達(dá)的回寫的操作看到該數(shù)據(jù)有鎖之后,就不能加鎖了,同時線程1加鎖成功了后,執(zhí)行store的時候數(shù)據(jù)經(jīng)過總線,MESI緩存一致性協(xié)議結(jié)合CPU總線嗅探機(jī)制把線程2的工作內(nèi)存的值失效掉。那么線程2做的num++的操作已經(jīng)沒有意義了(丟失了),下次線程2再做num++的時候,重新從主內(nèi)存中read到這個線程1寫回的值。這個時候上一次線程2做的num++的操作丟失了,也就丟失了一次加1操作。
網(wǎng)絡(luò)上有些博客說是因?yàn)閕++不是一個原子操作,但是我更覺得這種方式才是解釋為什么不保證原子性的根本原因。

volatile保證有序性
volatile主要是通過內(nèi)存屏障來防止指令重排達(dá)到解決有序性問題!
最后
文章的最后為大家準(zhǔn)備了一些Java架構(gòu)學(xué)習(xí)資料,學(xué)習(xí)技術(shù)內(nèi)容包含有:Spring,Dubbo,MyBatis, RPC, 源碼分析,高并發(fā)、高性能、分布式,性能優(yōu)化,微服務(wù) 高級架構(gòu)開發(fā)等等,祝大家都能拿到心儀的offer!歡迎大家關(guān)注公眾號:前程有光,自行領(lǐng)?。?/p>