1. 程式人生 > 實用技巧 >Java多執行緒之volatile詳解

Java多執行緒之volatile詳解

本文目錄

  • 從多執行緒交替列印A和B開始
  • Java 記憶體模型中的可見性、原子性和有序性
  • Volatile原理
    • volatile的特性
    • volatile happens-before規則
    • volatile 記憶體語義
    • volatile 記憶體語義的實現
  • CPU對於Volatile的支援
    • 快取一致性協議
  • 工作記憶體(本地記憶體)並不存在
  • 總結

    -參考資料

從多執行緒交替列印A和B開始

面試中經常會有一道多執行緒交替列印A和B的問題,可以通過使用Lock和一個共享變數來完成這一操作,程式碼如下,其中使用num來決定當前執行緒是否列印

public class ABTread {

    private static int num=0;
private static Lock lock=new ReentrantLock(); public static void main(String[] args) throws InterruptedException { Thread A=new Thread(new Runnable() {
@Override
public void run() {
while (true){
lock.lock();
if (num==0){
System.out.println("A");
num=1;
}
lock.unlock();
}
}
},"A");
Thread B=new Thread(new Runnable() {
@Override
public void run() {
while (true){
lock.lock();
if (num==1){
System.out.println("B");
num=0;
}
lock.unlock();
}
}
},"B");
A.start();
B.start();
}
}

這一過程使用了一個可重入鎖,在以前可重入鎖的獲取流程中有分析到,當鎖被一個執行緒持有時,後繼的執行緒想要再獲取鎖就需要進入同步佇列還有可能會被阻塞。

現在假設當A執行緒獲取了鎖,B執行緒再來獲取鎖且B執行緒獲取失敗則會呼叫LockSupport.park()導致執行緒B阻塞,執行緒A釋放鎖時再還行執行緒B

是否會經常存在阻塞執行緒和還行執行緒的操作呢,阻塞和喚醒的操作是比較費時間的。是否存在一個執行緒剛釋放鎖之後這一個執行緒又再一次獲取鎖,由於共享變數的存在,

則獲取鎖的執行緒一直在做著毫無意義的事情。

可以使用volatile關鍵字來修飾共享變數來解決,程式碼如下:

public class ABTread {

    private static volatile  int num=0;
public static void main(String[] args) throws InterruptedException { Thread A=new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (num==0){ //讀取num過程記作1
System.out.println("A");
num=1; //寫入num記位2
}
}
}
},"A");
Thread B=new Thread(new Runnable() {
@Override
public void run() {
while (true){
if (num==1){ //讀取num過程記作3
System.out.println("B");
num=0; ////寫入num記位4
}
}
}
},"B");
A.start();
B.start();
}
}

Lock可以通過阻止同時訪問來完成對共享變數的同時訪問和修改,必要的時候阻塞其他嘗試獲取鎖的執行緒,那麼volatile關鍵字又是如何工作,

在這個例子中,是否效果會優於Lock呢。

Java 記憶體模型中的可見性、原子性和有序性

  • 可見性:指執行緒之間的可見性,一個執行緒對於狀態的修改對另一個執行緒是可見的,也就是說一個執行緒修改的結果對於其他執行緒是實時可見的。

    可見性是一個複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺(JMM決定),通常情況下,我們無法保證執行讀操作的執行緒能實時的看到其他執行緒的寫入的值。

    為了保證執行緒的可見性必須使用同步機制。退一步說,最少應該保證當一個執行緒修改某個狀態時,而這個修改時程式設計師希望能被其他執行緒實時可見的,

    那麼應該保證這個狀態實時可見,而不需要保證所有狀態的可見。在 Javavolatilesynchronizedfinal 實現可見性。

  • 原子性:如果一個操作是不可以再被分割的,那麼我們說這個操作是一個原子操作,即具有原子性。但是例如i++實際上是i=i+1這個操作是可分割的,他不是一個原子操作。

    非原子操作在多執行緒的情況下會存線上程安全性問題,需要是我們使用同步技術將其變為一個原子操作。javaconcurrent包下提供了一些原子類,

    我們可以通過閱讀API來瞭解這些原子類的用法。比如:AtomicIntegerAtomicLongAtomicReference等。在 Javasynchronized 和在 lockunlock 中操作保證原子性

  • 有序性:一系列操作是按照規定的順序發生的。如果在本執行緒之內觀察,所有的操作都是有序的,如果在其他執行緒觀察,所有的操作都是無序的;前半句指“執行緒內表現為序列語義”後半句指“指令重排序”和“工作記憶體和主存同步延遲”

    Java 語言提供了 volatilesynchronized 兩個關鍵字來保證執行緒之間操作的有序性。volatile 是因為其本身包含“禁止指令重排序”的語義,

    synchronized 是由“一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個物件鎖的兩個同步塊只能序列執行。

Volatile原理

volatile定義:Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該通過獲取排他鎖單獨獲取這個變數;

java提供了volatile關鍵字在某些情況下比鎖更好用。

  • Java語言提供了volatile了關鍵字來提供一種稍弱的同步機制,他能保證操作的可見性和有序性。當把變數宣告為volatile型別後,

    編譯器與執行時都會注意到這個變數是一個共享變數,並且這個變數的操作禁止與其他的變數的操作重排序。

  • 訪問volatile變數時不會執行加鎖操作。因此也不會存在阻塞競爭的執行緒,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。

volatile的特性

volatile具有以下特性:

  • 可見性:對於一個volatile的讀總能看到最後一次對於這個volatile變數的寫
  • 原子性:對任意單個volatile變數的讀/寫具有原子性,但對於類似於i++這種複合操作不具有原子性。
  • 有序性:

volatile happens-before規則

根據JMM要求,共享變數儲存在共享記憶體當中,工作記憶體儲存一個共享變數的副本,

執行緒對於貢獻變數的修改其實是對於工作記憶體中變數的修改,如下圖所示:



從多執行緒交替列印A和B開始章節中使用volatile關鍵字的實現為例來研究volatile關鍵字實現了什麼:

假設執行緒A在執行num=1之後B執行緒讀取num指,則存在以下happens-before關係

1)  1 happens-before 2,3 happens-before 4
2) 根據volatile規則有:2 happens-before 3
3) 根據heppens-before傳遞規則有: 1 happens-before 4

至此執行緒的執行順序是符合我們的期望的,那麼volatile是如何保證一個執行緒對於共享變數的修改對於其他執行緒可見的呢?

volatile 記憶體語義

根據JMM要求,對於一個變數的獨寫存在8個原子操作。對於一個共享變數的獨寫過程如下圖所示:

對於一個沒有進行同步的共享變數,對其的使用過程分為readloaduseassign以及不確定的storewrite過程。

整個過程的語言描述如下:

- 第一步:從共享記憶體中讀取變數放入工作記憶體中(`read`、`load`)
- 第二步:當執行引擎需要使用這個共享變數時從本地記憶體中載入至**CPU**中(`use`)
- 第三步:值被更改後使用(`assign`)寫回工作記憶體。
- 第四步:若之後執行引擎還需要這個值,那麼就會直接從工作記憶體中讀取這個值,不會再去共享記憶體讀取,除非工作記憶體中的值出於某些原因丟失。
- 第五步:在不確定的某個時間使用`store`、`write`將工作記憶體中的值回寫至共享記憶體。

由於沒有使用鎖操作,兩個執行緒可能同時讀取或者向共享記憶體中寫入同一個變數。或者在一個執行緒使用這個變數的過程中另一個執行緒讀取或者寫入變數。

上圖中1和6兩個操作可能會同時執行,或者線上程1使用num過程中6過程執行,那麼就會有很嚴重的執行緒安全問題,

一個執行緒可能會讀取到一個並不是我們期望的值。

那麼如果希望一個執行緒的修改對後續執行緒的讀立刻可見,那麼只需要將修改後儲存在本地記憶體中的值回寫到共享記憶體

並且在另一個執行緒讀的時候從共享記憶體重新讀取而不是從本地記憶體中直接讀取即可;事實上

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中共享變數值重新整理會共享記憶體;

而當讀取一個volatile變數時,JMM會從主存中讀取共享變數
,這也就是volatile的寫-讀記憶體語義。

volatile的寫-讀記憶體語義:

  • volatile寫的記憶體語義:當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中共享變數值重新整理會共享記憶體
  • volatile讀的記憶體語義:當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效,執行緒接下來將從主記憶體中讀取共享變數。

如果將這兩個步驟綜合起來,那麼執行緒3讀取一個volatile變數後,寫執行緒1在寫這個volatile變數之前所有可見的共享變數的值都將樂客變得對執行緒3可見。

volatile變數的讀寫過程如下圖:

需要注意的是:在各個執行緒的工作記憶體中是存在volatile變數的值不一致的情況的,只是每次使用都會從共享記憶體讀取並重新整理,執行引擎看不到不一致的情況,

所以認為volatile變數在本地記憶體中不存在不一致問題。

volatile 記憶體語義的實現

在前文Java記憶體模型中有提到重排序。為了實現volatile的記憶體語義,JMM會限制重排序的行為,具體限制如下表:

是否可以重排序 第二個操作 第二個操作 第二個操作
第一個操作 普通讀/寫 volatile volatile
普通讀/寫 NO
volatile NO NO NO
volatile NO NO

說明:

- 若第一個操作時普通變數的讀寫,第二個操作時volatile變數的寫操作,則編譯器不能重排序這兩個操作
- 若第一個操作是volatile變數的讀操作,不論第二個變數是什麼操作不餓能重排序這兩個操作
- 若第一個操作時volatile變數的寫操作,除非第二個操作是普通變數的獨寫,否則不能重排序這兩個操作

為了實現volatile變數的記憶體語義,編譯器生成位元組碼檔案時會在指令序列中插入記憶體屏障來禁止特定型別的處理器排序。

為了實現volatile變數的記憶體語義,插入了以下記憶體屏障,並且在實際執行過程中,只要不改變volatile的記憶體語義,

編譯器可以根據實際情況省略部分不必要的記憶體屏障

- 在每個volatile寫操作前面插入StoreStore屏障
- 在每個volatile寫操作後面插入StoreLoad屏障
- 在每個volatile讀操作後面插入LoadLoad屏障
- 在每個volatile讀操作後面插入LoadStore屏障

插入記憶體屏障後volatile寫操作過程如下圖:

插入記憶體屏障後volatile讀操作過程如下圖:

至此在共享記憶體和工作記憶體中的volatile的寫-讀的工作過程全部完成

但是現在的CPU中存在一個快取,CPU讀取或者修改資料的時候是從快取中獲取並修改資料,那麼如何保證CPU快取中的資料與共享記憶體中的一致,並且修改後寫回共享記憶體呢?

CPU對於Volatile的支援

快取行:cpu快取儲存資料的基本單位,cpu不能使資料失效,但是可以使快取行失效。

對於CPU來說,CPU直接操作的記憶體時快取記憶體,而每一個CPU都有自己L1、L2以及共享的L3級快取,如下圖:

那麼當CPU修改自身快取中的被volatile修飾的共享變數時,如何保證對其他CPU的可見性。

快取一致性協議

在多處理器的情況下,每個處理器總是嗅探匯流排上傳播的資料來檢查自己的快取是否過期,當處理器發現自己對應的快取對應的地址被修改,

就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行操作的時候,會重新從系統中把資料督導處理器的快取裡。這個協議被稱之為快取一致性協議。

快取一致性協議的實現又MEIMESIMOSI等等。

MESI協議快取狀態

狀態 描述
M(modified)修改 該快取指被快取在該CPU的快取中並且是被修改過的,即與主存中的資料不一致,該快取行中的資料需要在未來的某個時間點寫回主存,當寫回註冊年之後,該快取行的狀態會變成E(獨享)
E(exclusive)獨享 該快取行只被快取在該CPU的快取中,他是未被修改過的,與主存中資料一致,該狀態可以在任何時候,當其他的CPU讀取該記憶體時程式設計共享狀態,同樣的,當CPU修改該快取行中的內容時,該狀態可以變為M(修改)
S(share)共享 該狀態意味著該快取行可能被多個CPU快取,並且各個快取中的資料與主存中的資料一致,當有一個CPU修改自身對應的快取的資料,其它CPU中該資料對應的快取行被作廢
I(Invalid)無效 該快取行無效

MESI協議可以防止快取不一致的情況,但是當一個CPU修改了快取中的資料,但是沒有寫入主存,也會存在問題,那麼如何保證CPU修改共享被volatile修飾的共享變數後立刻寫回主存呢。

在有volatile修飾的共享變數進行寫操作的時候會多出一條帶有lock字首的彙編程式碼,而這個lock操作會做兩件事:

  1. 將當前處理器的快取行的資料協會到系統記憶體。lock訊號確保聲言該訊號期間CPU可以獨佔共享記憶體。在之前通過鎖匯流排的方式,現在採用鎖快取的方式。
  2. 這個寫回操作會使其他處理器的快取中快取了該地址的快取行無效。在下一次這些CPU需要使用這些地址的值時,強制要求去共享記憶體中讀取。

如果對宣告瞭volatile的共享變數進行寫,JVM會向CPU傳送一條lock指令,使得將這個變數所在的快取行快取的資料寫回到記憶體中。而其他CPU通過嗅探匯流排上傳播的資料,

使得自身快取行失效,下一次使用時會從主存中獲取對應的變數。

工作記憶體(本地記憶體)並不存在

根據JAVA記憶體模型描述,各個執行緒使用自身的工作記憶體來儲存共享變數,那麼是不是每個CPU快取的資料就是從工作記憶體中獲取的。這樣的話,在CPU快取寫回主存時,

協會的是自己的工作記憶體地址,而各個執行緒的工作記憶體地址並不一樣。CPU嗅探匯流排時就嗅探不到自身的快取中快取有對應的共享變數,從而導致錯誤?

事實上,工作記憶體並不真實存在,只是JMM為了便於理解抽象出來的概念,它涵蓋了快取,寫緩衝區、暫存器及其他的硬體編譯器優化。所以快取是直接和共享記憶體互動的。

每個CPU快取的共享資料的地址是一致的。

總結

  • volatile提供了一種輕量級同步機制來完成同步,它可以保操作的可見性、有序性以及對於單個volatile變數的讀/寫具有原子性,對於符合操作等非原子操作不具有原子性。

  • volatile通過新增記憶體屏障及快取一致性協議來完成對可見性的保證。

最後Lock#lock()是如何保證可見性的呢??

Lock#lock()使用了AQSstate來標識鎖狀態,而statevolatile標記的,由於對於volatile的獨寫操作時添加了記憶體屏障的,所以在修改鎖狀態之前,

一定會將之前的修改寫回共享記憶體。