從案例到底層原理,徹底理解volatile可見性和禁止指令重排

一. volatile保證可見性
public class TestMain {
private static boolean flag = false;
//private volatile static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
flag = true;
System.out.println("=======循環(huán)之前=======");
while (flag) {
}
System.out.println("=======循環(huán)之后=======");
}).start();
Thread.sleep(2000);
new Thread(() -> {
System.out.println("修改flag之前...");
System.out.println(flag); // true
flag = false;
System.out.println("修改flag之后...");
System.out.println(flag); // false 上面的線程沒有跳出循環(huán)
}).start();
}
}在這里,我們通過一個最簡單的例子,來引入可見性。
在運(yùn)行程序之前,我們先來分析一下上述代碼的邏輯,推測一下結(jié)果。第1個線程啟動后,將flag置為true,然后會陷入死循環(huán)。稍后,第2個線程啟動后,將flag置為false。按理來說,此時第1個線程應(yīng)該會跳出死循環(huán)才對。但運(yùn)行結(jié)果卻不是這樣!flag是兩個線程的共享變量,但是第2個線程將flag置為false之后,并沒有被第1個線程所感知(不可見)。
如何解決這個問題?只需要在用 volatile 來修飾flag,保證flag多個線程之間可見即可。
二. Java內(nèi)存模型(JMM)
為了更容易理解可見性,有必要簡單引入一下JMM(Java Memory Model),對內(nèi)存模型有大概的抽象了解。
1. JMM(Java Memory Model)
Java 內(nèi)存模型,是 Java 虛擬機(jī)規(guī)范中所定義的一種內(nèi)存模型,是一種抽象的概念,并不真實(shí)存在!它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對象的元素)的訪問方式。它屏蔽掉了底層不同計算機(jī)硬件架構(gòu)下內(nèi)存的區(qū)別。也就是說,JMM 是 JVM 中定義的一種并發(fā)編程的底層模型機(jī)制。
2. JMM 的抽象示意圖

JMM 規(guī)定:
所有的共享變量都存儲于主內(nèi)存。這里所說的變量指的是實(shí)例變量和類變量,不包含局部變量,因?yàn)榫植孔兞渴蔷€程私有的,因此不存在競爭問題。
每一個線程還存在自己的工作內(nèi)存,線程的工作內(nèi)存,保留了被線程使用的變量的工作副本。
線程對變量的所有的操作(讀,?。┒急仨氃诠ぷ鲀?nèi)存中完成,而不能直接讀寫主內(nèi)存中的變量。
不同線程之間也不能直接訪問對方工作內(nèi)存中的變量,線程間變量的值的傳遞需要通過主內(nèi)存中轉(zhuǎn)來完成。
由于緩存的存在,就可能會出現(xiàn)以下兩種情況而導(dǎo)致緩存不一致:
線程對共享變量的修改沒有即時更新到主內(nèi)存;
線程沒能夠即時將共享變量的最新值同步到工作內(nèi)存中,從而使得線程在使用共享變量的值時,該值并不是最新的;
3. 數(shù)據(jù)同步的八大原子操作
以上關(guān)于主內(nèi)存與工作內(nèi)存之間的具體交互協(xié)議,即一個變量如何從主內(nèi)存拷貝到工作內(nèi)存、如何從工作內(nèi)存同步到主內(nèi)存之間的實(shí)現(xiàn)細(xì)節(jié),Java內(nèi)存模型定義了以下八種操作來完成。
簡單了解一下即可,方便我們畫圖來解釋上述的第一個例子。
lock (鎖定):作用于主內(nèi)存的變量,把一個變量標(biāo)記為一條線程獨(dú)占狀態(tài)
unlock (解鎖):作用于主內(nèi)存的變量,把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定
read (讀取):作用于主內(nèi)存的變量,把一個變量值從主內(nèi)存?zhèn)鬏數(shù)骄€程的工作內(nèi)存中,以便隨后的load動作使用
load (載入):作用于工作內(nèi)存的變量,它把read操作從主內(nèi)存中得到的變量值放入工作內(nèi)存的變量副本中
use (使用):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量值傳遞給執(zhí)行引擎
assign (賦值):作用于工作內(nèi)存的變量,它把一個從執(zhí)行引擎接收到的值賦給工作內(nèi)存的變量
store (存儲):作用于工作內(nèi)存的變量,把工作內(nèi)存中的一個變量的值傳送到主內(nèi)存中,以便隨后的write的操作
write (寫入):作用于工作內(nèi)存的變量,它把store操作從工作內(nèi)存中的一個變量的值傳送到主內(nèi)存的變量中

4. 流程圖解釋例1

如果對聲明了volatile的變量進(jìn)行寫操作,JVM就立即會向處理器發(fā)送一條Lock前綴(硬件級別)的指令,立即將這個變量所在緩存行的數(shù)據(jù)寫回到系統(tǒng)內(nèi)存。但是,就算寫回到內(nèi)存,如果其他處理器緩存的值還是舊的,再執(zhí)行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的緩存是一致的,就會實(shí)現(xiàn)緩存一致性協(xié)議。每個處理器通過嗅探在總線上傳播的數(shù)據(jù)來檢查自己緩存的值是不是過期了(總線嗅探機(jī)制,這是實(shí)現(xiàn)緩存一致性的常見機(jī)制)。 當(dāng)處理器發(fā)現(xiàn)自己緩存行對應(yīng)的內(nèi)存地址被修改,就會將當(dāng)前處理器的緩存行設(shè)置成無效狀態(tài)。當(dāng)處理器對這個數(shù)據(jù)進(jìn)行修改操作的時候,發(fā)現(xiàn)緩存無效,會重新從系統(tǒng)內(nèi)存中重新讀取并更新到緩存。
除了volatile,加鎖也能保證變量的內(nèi)存可見性。因?yàn)楫?dāng)一個線程進(jìn)入 synchronized 代碼塊后,線程獲取到鎖,會清空本地內(nèi)存,然后從主內(nèi)存中拷貝共享變量的最新值到本地內(nèi)存作為副本,執(zhí)行代碼,又將修改后的副本值刷新到主內(nèi)存中,最后線程釋放鎖。除了 synchronized 外,其它鎖也能保證變量的內(nèi)存可見性。
二. volatile無法保證原子性
public class TestMain1 {
public volatile static int i = 0;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int k = 0; k < 10; k++) { // 10個線程
new Thread(() -> {
for (int j = 0; j < 100000; j++) { // 10萬
// synchronized (TestMain1.class) {
i++;
// }
}
latch.countDown();
}).start(); // 新建線程,并開始執(zhí)行
}
latch.await(); // 阻塞,直到10個線程全部運(yùn)行完成
System.out.println(i);
}
}通過上述例子,可以證明volatile并不保證原子性!
上述代碼中,開啟了10個線程,每個線程對 i 自增 10 0000(10萬)。如果不出現(xiàn)線程安全問題,那么最后的結(jié)果應(yīng)該是 10 * 10 0000 = 100 萬。但運(yùn)行結(jié)果總是不足100萬,并具有隨機(jī)性。說明了,代碼中出現(xiàn)了線程不安全的問題。
在并發(fā)場景下,變量 i 的任何改變都會立即被其他線程所感知,但是如果存在多條線程同時執(zhí)行i++,仍然會出現(xiàn)線程安全問題。畢竟i++的操作,并不是原子操作。該操作是先讀取 i 的值,將 i 加1,然后將新值寫回主內(nèi)存。如果第2個線程在第1個線程 讀取舊值 和 寫回新值 期間讀取 i 的值,那么第2個線程就會與第1個線程一起看到同一個值,并執(zhí)行相同值的加1操作。因此對于 i++ 這個非原子操作必須使用synchronized修飾,以便保證線程安全,需要注意的是一旦使用synchronized修飾方法后,由于synchronized本身也具備與volatile相同的特性,即可見性,因此在這種情況下就完全可以省去volatile修飾變量。
三. volatile禁止指令重排(保證有序性)
1. 通過例子窺探指令重排
我們先通過一個代碼例子,來證明一下在底層,是有可能發(fā)現(xiàn)指令重排的。
注:程序執(zhí)行可能要花個十分鐘左右才能出結(jié)果,因?yàn)橛?00萬次循環(huán),而每次循環(huán)都要創(chuàng)建線程(這是一個比較費(fèi)時的操作)
public class TestMain3 {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Set<String> resultSet = new HashSet<>();
for (int i = 0; i < 1000000; i++) { // 100萬
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(() -> {
a = y; // 1
x = 1; // 2
});
Thread t2 = new Thread(() -> {
b = x; // 3
y = 1; // 4
});
t1.start();
t2.start();
t1.join();
t2.join();
resultSet.add(String.format("a=%d,b=%d", a, b));
}
System.out.println(resultSet);
}
}分析以下上述代碼。對于每一次循環(huán),a和b可能的值如下:
a=0,b=0:此時代碼的執(zhí)行順序可能是這樣,① a=y,② b=x,③ x=1,④ y=1
a=0,b=1:此時代碼的執(zhí)行順序可能是這樣,① a=y,② x=1,③ b=x,④ y=1
a=1,b=0:此時代碼的執(zhí)行順序可能是這樣,① b=x,② y=1,③ a=y,④ x=1
a=1,b=1:從代碼上來看,是不可能出現(xiàn)的。因?yàn)閺拇a上來看,代碼1 先于 代碼2,代碼3 先于 代碼4。這兩個先后次序,是我們從代碼中可以直觀看出來的。上面3種情況,代碼的執(zhí)行順序都蘊(yùn)含了這兩種先后次序!a為1,說明y必然為1(代碼4必然執(zhí)行了),由于我們認(rèn)為 “代碼3 先于 代碼4”,所以代碼3必然已經(jīng)提前執(zhí)行完了,那么b應(yīng)該為0,不可能為1。
所以我們?nèi)绻J(rèn)為 “代碼1 先于 代碼2,代碼3 先于 代碼4”,那么就不可能會出現(xiàn) a=1,b=1 的情況。但是程序運(yùn)行結(jié)果卻出現(xiàn)了這種情況,說明了底層發(fā)生了指令重排!

2. 指令重排
Java語言規(guī)范規(guī)定JVM線程內(nèi)部維持順序化語義。即只要程序的最終結(jié)果與它順序化情況的結(jié)果相等,那么指令的執(zhí)行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什么?JVM能根據(jù)處理器特性(CPU多級緩存系統(tǒng)、多核處理器等)適當(dāng)?shù)膶C(jī)器指令進(jìn)行重排序,使機(jī)器指令能更符合CPU的執(zhí)行特性,最大限度的發(fā)揮機(jī)器性能。
從 Java 源代碼到最終執(zhí)行的指令序列,會分別經(jīng)歷下面3種重排序:

int a = 0;
// 線程 A
a = 1; // 1
flag = true; // 2
// 線程 B
if (flag) { // 3
int i = a; // 4
}單看上面的程序好像沒有問題,最后 i 的值是 1。但是為了提高性能,編譯器和處理器常常會在不改變數(shù)據(jù)依賴的情況下對指令做重排序。假設(shè)線程 A 在執(zhí)行時被重排序成先執(zhí)行代碼 2,再執(zhí)行代碼 1;而線程 B 在線程 A 執(zhí)行完代碼 2 后,讀取了 flag 變量。由于條件判斷為真,線程 B 將讀取變量 a。此時,變量 a 還根本沒有被線程 A 寫入,那么 i 最后的值是 0,導(dǎo)致執(zhí)行結(jié)果不正確。那么如何程序執(zhí)行結(jié)果正確呢?這里仍然可以使用 volatile 關(guān)鍵字。
這個例子中, 使用 volatile 不僅保證了變量的內(nèi)存可見性,還禁止了指令的重排序,即保證了 volatile 修飾的變量編譯后的順序與程序的執(zhí)行順序一樣。那么使用 volatile 修飾 flag 變量后,在線程 A 中,保證了代碼 1 的執(zhí)行順序一定在代碼 2 之前。
3. as-if-serial語義
不管怎么重排序,單線程下程序的執(zhí)行結(jié)果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為了遵守as-if-serial語義,編譯器和處理器不會對存在數(shù)據(jù)依賴關(guān)系的操作做重排序,因?yàn)檫@種重排序會改變執(zhí)行結(jié)果。如果操作之間不存在數(shù)據(jù)依賴關(guān)系,這些操作就可能被編譯器和處理器重排序。
4. happens-before原則
只靠sychronized和volatile關(guān)鍵字來保證原子性、可見性以及有序性,那么編寫并發(fā)程序可能會顯得十分麻煩。幸運(yùn)的是,從JDK 5開始,Java使用新的JSR-133內(nèi)存模型,提供了happens-before 原則來輔助保證程序執(zhí)行的原子性、可見性以及有序性的問題,它是判斷數(shù)據(jù)是否存在競爭、線程是否安全的依據(jù),happens-before 原則內(nèi)容如下:
程序順序原則:即在一個線程內(nèi)必須保證語義串行性,也就是說按照代碼順序執(zhí)行。
鎖規(guī)則:解鎖(unlock)操作必然發(fā)生在后續(xù)的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖)。
volatile規(guī)則:volatile變量的寫,先發(fā)生于讀,這保證了volatile變量的可見性,簡單的理解就是,volatile變量在每次被線程訪問時,都強(qiáng)迫從主內(nèi)存中讀該變量的值,而當(dāng)該變量發(fā)生變化時,又會強(qiáng)迫將最新的值刷新到主內(nèi)存,任何時刻,不同的線程總是能夠看到該變量的最新值。
線程啟動規(guī)則:線程的start()方法先于它的每一個動作,即如果線程A在執(zhí)行線程B的start方法之前修改了共享變量的值,那么當(dāng)線程B執(zhí)行start方法時,線程A對共享變量的修改對線程B可見
傳遞性:A先于B ,B先于C 那么A必然先于C
線程終止規(guī)則:線程的所有操作先于線程的終結(jié),Thread.join()方法的作用是等待當(dāng)前執(zhí)行的線程終止。假設(shè)在線程B終止之前,修改了共享變量,線程A從線程B的join方法成功返回后,線程B對共享變量的修改將對線程A可見。
線程中斷規(guī)則:對線程 interrupt()方法的調(diào)用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過Thread.interrupted()方法檢測線程是否中斷。
對象終結(jié)規(guī)則:對象的構(gòu)造函數(shù)執(zhí)行,結(jié)束先于finalize()方法
5. 內(nèi)存屏障
volatile關(guān)鍵字另一個作用就是禁止指令重排優(yōu)化,從而避免多線程環(huán)境下程序出現(xiàn)亂序執(zhí)行的現(xiàn)象,關(guān)于指令重排優(yōu)化前面已詳細(xì)分析過,這里主要簡單說明一下volatile是如何實(shí)現(xiàn)禁止指令重排優(yōu)化的。先了解一個概念,內(nèi)存屏障(Memory Barrier)。
內(nèi)存屏障,又稱內(nèi)存柵欄(Barrier),是一個CPU指令,它的作用有兩個:
如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說禁止在內(nèi)存屏障前后的指令執(zhí)行重排序優(yōu)化。
強(qiáng)制刷出各種CPU的緩存數(shù)據(jù),因此任何CPU上的線程都能讀取到這些數(shù)據(jù)的最新版本
6. JMM提供的4種內(nèi)存屏障指令
由于硬件層面的內(nèi)存屏障的實(shí)現(xiàn),不同的硬件架構(gòu),對應(yīng)有不同的機(jī)器指令。JMM為了屏蔽了這種底層硬件平臺的差異,提供了四類內(nèi)存屏障指令,來為不同的硬件架構(gòu)生成相應(yīng)的內(nèi)存屏障的機(jī)器碼。

7. volatile的內(nèi)存語義及其實(shí)現(xiàn)
volatile關(guān)鍵字的內(nèi)存語義如下:
【可見性】保證被volatile修飾的共享變量對所有線程總數(shù)可見的,也就是當(dāng)一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。
【有序性】禁止指令重排序優(yōu)化。
Java編譯器會在生成指令系列時在適當(dāng)?shù)奈恢脮迦雰?nèi)存屏障指令來禁止特定類型的處理器重排序。為了實(shí)現(xiàn)volatile內(nèi)存語義,JMM針對編譯器制定的volatile重排序規(guī)則表

舉例來說,第二行最后一個單元格的意思是:在程序中,當(dāng)?shù)谝粋€操作為普通變量的讀或?qū)憰r,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。
從上圖可以看出:
當(dāng)?shù)诙€操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規(guī)則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后。
當(dāng)?shù)谝粋€操作是volatile讀時,不管第二個操作是什么,都不能重排序。這個規(guī)則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前。
當(dāng)?shù)谝粋€操作是volatile寫,第二個操作是volatile讀或?qū)憰r,不能重排序。
為了實(shí)現(xiàn)volatile的內(nèi)存語義,編譯器在生成字節(jié)碼時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序。對于編譯器來說,發(fā)現(xiàn)一個最優(yōu)布置來最小化插入屏障的總數(shù)幾乎不可能。為此,JMM采取保守策略。下面是基于保守策略的JMM內(nèi)存屏障插入策略。
在每個volatile寫操作的前面插入一個StoreStore屏障。禁止上面的普通寫和下面的volatile寫重排序。
在每個volatile寫操作的后面插入一個StoreLoad屏障。防止上面的volatile寫與下面可能有的volatile讀/寫重排序。
在每個volatile讀操作的后面插入一個LoadLoad屏障。禁止上面的volatile讀和下面所有的普通讀操作重排序。
在每個volatile讀操作的后面插入一個LoadStore屏障。禁止上面的volatile讀和下面所有的普通寫操作重排序。
上述內(nèi)存屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程序中都能得到正確的volatile內(nèi)存語義?!驹诶斫?種屏障指令的含義,應(yīng)該也容易理解為什么要這么插入。之后也會有例子來幫助理解】
下面是保守策略下,volatile寫插入內(nèi)存屏障后生成的指令序列示意圖

上圖中StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經(jīng)對任意處理器可見了。這是因?yàn)镾toreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內(nèi)存。
這里比較有意思的是,volatile寫后面的StoreLoad屏障。此屏障的作用是避免volatile寫與 后面可能有的volatile讀/寫操作重排序。因?yàn)榫幾g器常常無法準(zhǔn)確判斷在一個volatile寫的后面 是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即return)。為了保證能正確 實(shí)現(xiàn)volatile的內(nèi)存語義,JMM在采取了保守策略:在每個volatile寫的后面,或者在每個volatile 讀的前面插入一個StoreLoad屏障。從整體執(zhí)行效率的角度考慮,JMM最終選擇了在每個 volatile寫的后面插入一個StoreLoad屏障。因?yàn)関olatile寫-讀內(nèi)存語義的常見使用模式是:一個 寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當(dāng)讀線程的數(shù)量大大超過寫線程時,選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執(zhí)行效率的提升。從這里可以看到JMM 在實(shí)現(xiàn)上的一個特點(diǎn):首先確保正確性,然后再去追求執(zhí)行效率。
下圖是在保守策略下,volatile讀插入內(nèi)存屏障后生成的指令序列示意圖

上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內(nèi)存屏障插入策略非常保守。在實(shí)際執(zhí)行時,只要不改變 volatile寫-讀的內(nèi)存語義,編譯器可以根據(jù)具體情況省略不必要的屏障。、
下面通過具體的示例代碼進(jìn)行說明。
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一個volatile讀
int j = v2; // 第二個volatile讀
a = i + j; // 普通寫
v1 = i + 1; // 第一個volatile寫
v2 = j * 2; // 第二個 volatile寫
}
}針對readAndWrite()方法,編譯器在生成字節(jié)碼時可以做如下的優(yōu)化。

注意,最后的StoreLoad屏障不能省略。因?yàn)榈诙€volatile寫之后,方法立即return。此時編 譯器可能無法準(zhǔn)確斷定后面是否會有volatile讀或?qū)?,為了安全起見,編譯器通常會在這里插 入一個StoreLoad屏障。
上面的優(yōu)化針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器內(nèi)存模 型,內(nèi)存屏障的插入還可以根據(jù)具體的處理器內(nèi)存模型繼續(xù)優(yōu)化。以X86處理器為例,圖3-21 中除最后的StoreLoad屏障外,其他的屏障都會被省略。
前面保守策略下的volatile讀和寫,在X86處理器平臺可以優(yōu)化成如下圖所示。前文提到過,X86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀、讀-寫和寫-寫操作 做重排序,因此在X86處理器中會省略掉這3種操作類型對應(yīng)的內(nèi)存屏障。在X86中,JMM僅需 在volatile寫后面插入一個StoreLoad屏障即可正確實(shí)現(xiàn)volatile寫-讀的內(nèi)存語義。這意味著在 X86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因?yàn)閳?zhí)行StoreLoad屏障開銷會比較大)。

四. 阿里巴巴Java開發(fā)手冊對餓漢式單例模式的規(guī)范
public class DoubleCheckLock {
// 阿里巴巴Java開發(fā)手冊建議在該變量前加上volatile修飾
private volatile static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次檢測
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多線程環(huán)境下可能會出現(xiàn)問題的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}出處:https://blog.csdn.net/qq_43290318/article/details/115588218
關(guān)注GitHub今日熱榜,專注挖掘好用的開發(fā)工具,致力于分享優(yōu)質(zhì)高效的工具、資源、插件等,助力開發(fā)者成長!
點(diǎn)個在看 你最好看
