目錄
一. volatile保證可見性
二. Java記憶體模型(JMM)
1. JMM(Java Memory Model)
2. JMM 的抽象示意圖
3. 資料同步的八大原子操作
4. 流程圖解釋例1
二. volatile無法保證原子性
三. volatile禁止指令重排(保證有序性)
1. 通過例子窺探指令重排
2. 指令重排
3. as-if-serial語意
4. happens-before原則
5. 記憶體屏障
6. JMM提供的4種記憶體屏障指令
3. volatile的記憶體語意及其實作
四. 阿里巴巴Java開發手冊對餓漢式單例模式的規范
參考和參考
一. 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("=======回圈之前=======");
while (flag) {
}
System.out.println("=======回圈之后=======");
}).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 上面的執行緒沒有跳出回圈
}).start();
}
}
在這里,我們通過一個最簡單的例子,來引入可見性,
在運行程式之前,我們先來分析一下上述代碼的邏輯,推測一下結果,第1個執行緒啟動后,將flag置為true,然后會陷入死回圈,稍后,第2個執行緒啟動后,將flag置為false,按理來說,此時第1個執行緒應該會跳出死回圈才對,但運行結果卻不是這樣!flag是兩個執行緒的共享變數,但是第2個執行緒將flag置為false之后,并沒有被第1個執行緒所感知(不可見),
如何解決這個問題?只需要在用 volatile 來修飾flag,保證flag多個執行緒之間可見即可,

二. Java記憶體模型(JMM)
為了更容易理解可見性,有必要簡單引入一下JMM(Java Memory Model),對記憶體模型有大概的抽象了解,
1. JMM(Java Memory Model)
Java 記憶體模型,是 Java 虛擬機規范中所定義的一種記憶體模型,是一種抽象的概念,并不真實存在!它描述的是一組規則或規范,通過這組規范定義了程式中各個變數(包括實體欄位,靜態欄位和構成陣列物件的元素)的訪問方式,它屏蔽掉了底層不同計算機硬體架構下記憶體的區別,也就是說,JMM 是 JVM 中定義的一種并發編程的底層模型機制,
2. JMM 的抽象示意圖

JMM 規定:
- 所有的共享變數都存盤于主記憶體,這里所說的變數指的是實體變數和類變數,不包含區域變數,因為區域變數是執行緒私有的,因此不存在競爭問題,
- 每一個執行緒還存在自己的作業記憶體,執行緒的作業記憶體,保留了被執行緒使用的變數的作業副本,
- 執行緒對變數的所有的操作(讀,取)都必須在作業記憶體中完成,而不能直接讀寫主記憶體中的變數,
- 不同執行緒之間也不能直接訪問對方作業記憶體中的變數,執行緒間變數的值的傳遞需要通過主記憶體中轉來完成,
由于快取的存在,就可能會出現以下兩種情況而導致快取不一致:
- 執行緒對共享變數的修改沒有即時更新到主記憶體
- 執行緒沒能夠即時將共享變數的最新值同步到作業記憶體中,從而使得執行緒在使用共享變數的值時,該值并不是最新的
3. 資料同步的八大原子操作
以上關于主記憶體與作業記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到作業記憶體、如何從作業記憶體同步到主記憶體之間的實作細節,Java記憶體模型定義了以下八種操作來完成,
簡單了解一下即可,方便我們畫圖來解釋上述的第一個例子,
- lock (鎖定):作用于主記憶體的變數,把一個變數標記為一條執行緒獨占狀態
- unlock (解鎖):作用于主記憶體的變數,把一個處于鎖定狀態的變數釋放出來,釋放后的變數才可以被其他執行緒鎖定
- read (讀取):作用于主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的作業記憶體中,以便隨后的load動作使用
- load (載入):作用于作業記憶體的變數,它把read操作從主記憶體中得到的變數值放入作業記憶體的變數副本中
- use (使用):作用于作業記憶體的變數,把作業記憶體中的一個變數值傳遞給執行引擎
- assign (賦值):作用于作業記憶體的變數,它把一個從執行引擎接收到的值賦給作業記憶體的變數
- store (存盤):作用于作業記憶體的變數,把作業記憶體中的一個變數的值傳送到主記憶體中,以便隨后的write的操作
- write (寫入):作用于作業記憶體的變數,它把store操作從作業記憶體中的一個變數的值傳送到主記憶體的變數中

4. 流程圖解釋例1

如果對宣告了volatile的變數進行寫操作,JVM就立即會向處理器發送一條Lock前綴(硬體級別)的指令,立即將這個變數所在快取行的資料寫回到系統記憶體,但是,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題,所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實作快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了(總線嗅探機制,這是實作快取一致性的常見機制), 當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,發現快取無效,會重新從系統記憶體中重新讀取并更新到快取,
除了volatile,加鎖也能保證變數的記憶體可見性, 因為當一個執行緒進入 synchronized 代碼塊后,執行緒獲取到鎖,會清空本地記憶體,然后從主記憶體中拷貝共享變數的最新值到本地記憶體作為副本,執行代碼,又將修改后的副本值重繪到主記憶體中,最后執行緒釋放鎖,除了 synchronized 外,其它鎖也能保證變數的記憶體可見性,
二. 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(); // 新建執行緒,并開始執行
}
latch.await(); // 阻塞,直到10個執行緒全部運行完成
System.out.println(i);
}
}
通過上述例子,可以證明volatile并不保證原子性!
上述代碼中,開啟了10個執行緒,每個執行緒對 i 自增 10 0000(10萬),如果不出現執行緒安全問題,那么最后的結果應該是 10 * 10 0000 = 100 萬,但運行結果總是不足100萬,并具有隨機性,說明了,代碼中出現了執行緒不安全的問題,
在并發場景下,變數 i 的任何改變都會立即被其他執行緒所感知,但是如果存在多條執行緒同時執行i++,仍然會出現執行緒安全問題,畢竟i++的操作,并不是原子操作,該操作是先讀取 i 的值,將 i 加1,然后將新值寫回主記憶體,如果第2個執行緒在第1個執行緒 讀取舊值 和 寫回新值 期間讀取 i 的值,那么第2個執行緒就會與第1個執行緒一起看到同一個值,并執行相同值的加1操作,因此對于 i++ 這個非原子操作必須使用synchronized修飾,以便保證執行緒安全,需要注意的是一旦使用synchronized修飾方法后,由于synchronized本身也具備與volatile相同的特性,即可見性,因此在這種情況下就完全可以省去volatile修飾變數,
三. volatile禁止指令重排(保證有序性)
1. 通過例子窺探指令重排
我們先通過一個代碼例子,來證明一下在底層,是有可能發現指令重排的,
注:程式執行可能要花個十分鐘左右才能出結果,因為有100萬次回圈,而每次回圈都要創建執行緒(這是一個比較費時的操作)
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);
}
}
分析以下上述代碼,對于每一次回圈,a和b可能的值如下:
a=0,b=0:此時代碼的執行順序可能是這樣,① a=y,② b=x,③ x=1,④ y=1
a=0,b=1:此時代碼的執行順序可能是這樣,① a=y,② x=1,③ b=x,④ y=1
a=1,b=0:此時代碼的執行順序可能是這樣,① b=x,② y=1,③ a=y,④ x=1
a=1,b=1:從代碼上來看,是不可能出現的,因為從代碼上來看,代碼1 先于 代碼2,代碼3 先于 代碼4,這兩個先后次序,是我們從代碼中可以直觀看出來的,上面3種情況,代碼的執行順序都蘊含了這兩種先后次序!a為1,說明y必然為1(代碼4必然執行了),由于我們認為 “代碼3 先于 代碼4”,所以代碼3必然已經提前執行完了,那么b應該為0,不可能為1,
所以我們如果認為 “代碼1 先于 代碼2,代碼3 先于 代碼4”,那么就不可能會出現 a=1,b=1 的情況,但是程式運行結果卻出現了這種情況,說明了底層發生了指令重排!

2. 指令重排
Java語言規范規定JVM執行緒內部維持順序化語意,即只要程式的最終結果與它順序化情況的結果相等,那么指令的執行順序可以與代碼順序不一致,此程序叫指令的重排序,指令重排序的意義是什么?JVM能根據處理器特性(CPU多級快取系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能,
從 Java 源代碼到最終執行的指令序列,會分別經歷下面3種重排序:

int a = 0;
// 執行緒 A
a = 1; // 1
flag = true; // 2
// 執行緒 B
if (flag) { // 3
int i = a; // 4
}
單看上面的程式好像沒有問題,最后 i 的值是 1,但是為了提高性能,編譯器和處理器常常會在不改變資料依賴的情況下對指令做重排序,假設執行緒 A 在執行時被重排序成先執行代碼 2,再執行代碼 1;而執行緒 B 在執行緒 A 執行完代碼 2 后,讀取了 flag 變數,由于條件判斷為真,執行緒 B 將讀取變數 a,此時,變數 a 還根本沒有被執行緒 A 寫入,那么 i 最后的值是 0,導致執行結果不正確,那么如何程式執行結果正確呢?這里仍然可以使用 volatile 關鍵字,
這個例子中, 使用 volatile 不僅保證了變數的記憶體可見性,還禁止了指令的重排序,即保證了 volatile 修飾的變數編譯后的順序與程式的執行順序一樣,那么使用 volatile 修飾 flag 變數后,在執行緒 A 中,保證了代碼 1 的執行順序一定在代碼 2 之前,
3. as-if-serial語意
不管怎么重排序,單執行緒下程式的執行結果不能被改變,編譯器、runtime和處理器都必須遵守as-if-serial語意,為了遵守as-if-serial語意,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因為這種重排序會改變執行結果,如果操作之間不存在資料依賴關系,這些操作就可能被編譯器和處理器重排序,
4. happens-before原則
只靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那么撰寫并發程式可能會顯得十分麻煩,幸運的是,從JDK 5開始,Java使用新的JSR-133記憶體模型,提供了happens-before 原則來輔助保證程式執行的原子性、可見性以及有序性的問題,它是判斷資料是否存在競爭、執行緒是否安全的依據,happens-before 原則內容如下:
- 程式順序原則:即在一個執行緒內必須保證語意串行性,也就是說按照代碼順序執行,
- 鎖規則:解鎖(unlock)操作必然發生在后續的同一個鎖的加鎖(lock)之前,也就是說,如果對于一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個鎖),
- volatile規則:volatile變數的寫,先發生于讀,這保證了volatile變數的可見性,簡單的理解就是,volatile變數在每次被執行緒訪問時,都強迫從主記憶體中讀該變數的值,而當該變數發生變化時,又會強迫將最新的值重繪到主記憶體,任何時刻,不同的執行緒總是能夠看到該變數的最新值,
- 執行緒啟動規則:執行緒的start()方法先于它的每一個動作,即如果執行緒A在執行執行緒B的start方法之前修改了共享變數的值,那么當執行緒B執行start方法時,執行緒A對共享變數的修改對執行緒B可見
- 傳遞性:A先于B ,B先于C 那么A必然先于C
- 執行緒終止規則:執行緒的所有操作先于執行緒的終結,Thread.join()方法的作用是等待當前執行的執行緒終止,假設在執行緒B終止之前,修改了共享變數,執行緒A從執行緒B的join方法成功回傳后,執行緒B對共享變數的修改將對執行緒A可見,
- 執行緒中斷規則:對執行緒 interrupt()方法的呼叫先行發生于被中斷執行緒的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測執行緒是否中斷,
- 物件終結規則:物件的建構式執行,結束先于finalize()方法
5. 記憶體屏障
volatile關鍵字另一個作用就是禁止指令重排優化,從而避免多執行緒環境下程式出現亂序執行的現象,關于指令重排優化前面已詳細分析過,這里主要簡單說明一下volatile是如何實作禁止指令重排優化的,先了解一個概念,記憶體屏障(Memory Barrier),
記憶體屏障,又稱記憶體柵欄(Barrier),是一個CPU指令,它的作用有兩個:
- 如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說禁止在記憶體屏障前后的指令執行重排序優化,
- 強制刷出各種CPU的快取資料,因此任何CPU上的執行緒都能讀取到這些資料的最新版本
6. JMM提供的4種記憶體屏障指令
由于硬體層面的記憶體屏障的實作,不同的硬體架構,對應有不同的機器指令,JMM為了屏蔽了這種底層硬體平臺的差異,提供了四類記憶體屏障指令,來為不同的硬體架構生成相應的記憶體屏障的機器碼,
| 屏障型別 | 指令示例 | 說明 |
| LoadLoad | Load1; LoadLoad; Load2 | 保證load1的讀取操作在load2及后續讀取操作之前執行 |
| StoreStore | Store1; StoreStore; Store2 | 在store2及其后的寫操作執行前,保證store1的寫操作已重繪到主記憶體 |
| LoadStore | Load1; LoadStore; Store2 | 在stroe2及其后的寫操作執行前,保證load1的讀操作已讀取結束 |
| StoreLoad | Store1; StoreLoad; Load2 | 保證store1的寫操作已重繪到主記憶體之后,load2及其后的讀操作才能執行 |
3. volatile的記憶體語意及其實作
volatile關鍵字的記憶體語意如下:
- 【可見性】保證被volatile修飾的共享變數對所有執行緒總數可見的,也就是當一個執行緒修改了一個被volatile修飾共享變數的值,新值總是可以被其他執行緒立即得知,
- 【有序性】禁止指令重排序優化,
Java編譯器會在生成指令系列時在適當的位置會插入記憶體屏障指令來禁止特定型別的處理器重排序,為了實作volatile記憶體語意,JMM針對編譯器制定的volatile重排序規則表
| 第一個操作 | 第二個操作:普通讀寫 | 第二個操作:volatile讀 | 第二個操作:volatile寫 |
| 普通讀寫 | 可以重排 | 可以重排 | 不可以重排 |
| volatile讀 | 不可以重排 | 不可以重排 | 不可以重排 |
| volatile寫 | 可以重排 | 不可以重排 | 不可以重排 |
舉例來說,第二行最后一個單元格的意思是:在程式中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作,
從上圖可以看出:
- 當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序,這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之后,
- 當第一個操作是volatile讀時,不管第二個操作是什么,都不能重排序,這個規則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前,
- 當第一個操作是volatile寫,第二個操作是volatile讀或寫時,不能重排序,
為了實作volatile的記憶體語意,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序,對于編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能,為此,JMM采取保守策略,下面是基于保守策略的JMM記憶體屏障插入策略,
- 在每個volatile寫操作的前面插入一個StoreStore屏障,禁止上面的普通寫和下面的volatile寫重排序,
- 在每個volatile寫操作的后面插入一個StoreLoad屏障,防止上面的volatile寫與下面可能有的volatile讀/寫重排序,
- 在每個volatile讀操作的后面插入一個LoadLoad屏障,禁止上面的volatile讀和下面所有的普通讀操作重排序,
- 在每個volatile讀操作的后面插入一個LoadStore屏障,禁止上面的volatile讀和下面所有的普通寫操作重排序,
上述記憶體屏障插入策略非常保守,但它可以保證在任意處理器平臺,任意的程式中都能得到正確的volatile記憶體語意,【在理解4種屏障指令的含義,應該也容易理解為什么要這么插入,之后也會有例子來幫助理解】
下面是保守策略下,volatile寫插入記憶體屏障后生成的指令序列示意圖

上圖中StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見了,這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前重繪到主記憶體,
這里比較有意思的是,volatile寫后面的StoreLoad屏障,此屏障的作用是避免volatile寫與 后面可能有的volatile讀/寫操作重排序,因為編譯器常常無法準確判斷在一個volatile寫的后面 是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即return),為了保證能正確 實作volatile的記憶體語意,JMM在采取了保守策略:在每個volatile寫的后面,或者在每個volatile 讀的前面插入一個StoreLoad屏障,從整體執行效率的角度考慮,JMM最終選擇了在每個 volatile寫的后面插入一個StoreLoad屏障,因為volatile寫-讀記憶體語意的常見使用模式是:一個 寫執行緒寫volatile變數,多個讀執行緒讀同一個volatile變數,當讀執行緒的數量大大超過寫執行緒時,選擇在volatile寫之后插入StoreLoad屏障將帶來可觀的執行效率的提升,從這里可以看到JMM 在實作上的一個特點:首先確保正確性,然后再去追求執行效率,
下圖是在保守策略下,volatile讀插入記憶體屏障后生成的指令序列示意圖

上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序,LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序,
上述volatile寫和volatile讀的記憶體屏障插入策略非常保守,在實際執行時,只要不改變 volatile寫-讀的記憶體語意,編譯器可以根據具體情況省略不必要的屏障,、
下面通過具體的示例代碼進行說明,
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()方法,編譯器在生成位元組碼時可以做如下的優化,

注意,最后的StoreLoad屏障不能省略,因為第二個volatile寫之后,方法立即return,此時編 譯器可能無法準確斷定后面是否會有volatile讀或寫,為了安全起見,編譯器通常會在這里插 入一個StoreLoad屏障,
上面的優化針對任意處理器平臺,由于不同的處理器有不同“松緊度”的處理器記憶體模 型,記憶體屏障的插入還可以根據具體的處理器記憶體模型繼續優化,以X86處理器為例,圖3-21 中除最后的StoreLoad屏障外,其他的屏障都會被省略,
前面保守策略下的volatile讀和寫,在X86處理器平臺可以優化成如下圖所示,前文提到過,X86處理器僅會對寫-讀操作做重排序,X86不會對讀-讀、讀-寫和寫-寫操作 做重排序,因此在X86處理器中會省略掉這3種操作型別對應的記憶體屏障,在X86中,JMM僅需 在volatile寫后面插入一個StoreLoad屏障即可正確實作volatile寫-讀的記憶體語意,這意味著在 X86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執行StoreLoad屏障開銷會比
較大),

四. 阿里巴巴Java開發手冊對餓漢式單例模式的規范
public class DoubleCheckLock {
// 阿里巴巴Java開發手冊建議在該變數前加上volatile修飾
private volatile static DoubleCheckLock instance;
private DoubleCheckLock(){}
public static DoubleCheckLock getInstance(){
//第一次檢測
if (instance==null){
//同步
synchronized (DoubleCheckLock.class){
if (instance == null){
//多執行緒環境下可能會出現問題的地方
instance = new DoubleCheckLock();
}
}
}
return instance;
}
}
這種餓漢式單例模式有個很著名的名字 “雙重檢測鎖”,有兩個思考點:
1. 為什么需要兩個if?
2. 為什么阿里巴巴Java開發手冊建議在單例變數前加上volatile修飾?
略......待補...... 大家可以在評論區討論一下,
參考和參考
https://zhuanlan.zhihu.com/p/138819184
https://www.jianshu.com/p/157279e6efdb
《Java并發編程藝術》
圖靈學院課程資料
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/275111.html
標籤:其他
上一篇:Servlet的生命周期
