併發程式設計之 Java 記憶體模型 + volatile 關鍵字 + Happen-Before 規則

莫那·魯道發表於2019-03-04

前言

樓主這個標題其實有一種作死的味道,為什麼呢,這三個東西其實可以分開為三篇文章來寫,但是,樓主認為這三個東西又都是高度相關的,應當在一個知識點中。在一次學習中去理解這些東西。才能更好的理解 Java 記憶體模型和 volatile 關鍵字還有 HB 原則。

樓主今天就嘗試著在一篇文章中講述這三個問題,最後總結。

  1. 講併發知識前必須複習的硬體知識。
  2. Java 記憶體模型到底是什麼玩意?
  3. Java 記憶體模型定義了哪些東西?
  4. Java記憶體模型引出的 Happen-Before 原則是什麼?
  5. Happen-Before 引出的 volatile 又是什麼?
  6. 總結這三者。

1. 講併發知識前必須複習的硬體知識。

首先,因為我們需要了解 Java 虛擬機器的併發,而物理硬體的併發和虛擬機器的併發很相似,而且虛擬機器的併發很多看著奇怪的設計都是因為物理機的設計導致的。

什麼是併發?多個CPU同時執行。但請注意:只有CPU是不行的,CPU 只能計算資料,那麼資料從哪裡來?

答案:記憶體。 資料從記憶體中來。需要讀取資料,儲存計算結果。有的同學可能會說,不是有暫存器和多級快取嗎?但是那是靜態隨機訪問記憶體(Static Random Access Memory),太貴了,SRAM 在設計上使用的電晶體數量較多,價格較高,且不易做成大容量,只能用很小的部分整合的CPU中成為CPU的快取記憶體。而正常使用的都是都是動態隨機訪問記憶體(Dynamic Random Access Memory)。intel 的 CPU 外頻 需要從北橋經過訪問記憶體,而AMD 的沒有設計北橋,他與 Intel 不同的地方在於,記憶體是直接與CPU通訊而不通過北橋,也就是將記憶體控制元件整合到CPU中。理論上這樣可以加速CPU和記憶體的傳輸速度。

好了,不管哪一家的CPU,都需要從記憶體中讀取資料,並且自己都有快取記憶體或者說暫存器。快取作什麼用呢?由於CPU的速度很快,記憶體根本跟不上CPU,因此,需要在記憶體和CPU直接加一層快取記憶體讓他們緩衝CPU的資料:將運算需要使用到的資料複製到快取中,讓運算能夠快速執行,當運算結束後再從快取同步到記憶體之中。這樣處理器就無需等待緩慢的記憶體讀寫了。

CPU 和快取

但是這樣引出了另一個問題:快取一致性(Cache Coherence)。什麼意思呢?

在多處理器中,每個處理器都有自己的快取記憶體,而他們又共享同一個主記憶體(Main Memory),當多個處理器的運算任務都涉及到同一塊主記憶體區域時,將可能導致各自的快取資料不一致。如果真的發生這種情況,拿同步到主記憶體時以誰的快取資料為準呢?

在早期的CPU當中,可以通過在匯流排上加 LOCK# 鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。

現在的 CPU 為了解決一致性問題,需要各個CPU訪問(讀或者寫)快取的時候遵循一些協議:MSI,MESI,MOSI,Synapse,Firefly,Dragon Protocol,這些都是快取一致性協議。

那麼,這個時候需要說一個名詞:記憶體模型。

什麼是記憶體模型呢?

記憶體模型可以理解為在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。不同架構的CPU 有不同的記憶體模型,而 Java 虛擬機器遮蔽了不同CPU記憶體模型的差異,這就是Java 的記憶體模型。

那麼 Java 的記憶體模型的結構是什麼樣子的呢?

Java 記憶體模型(Java Memory Model)

好了,關於為什麼會有記憶體模型這件事,我們已經說的差不多了,總體來說就是因為多個CPU的多級快取訪問同一個記憶體條可能會導致資料不一致。所以需要一個協議,讓這些處理器在訪問記憶體的時候遵守這些協議保證資料的一致性。

還有一個問題。CPU 的流水線執行和亂序執行

我們假設我們現在有一段程式碼:


int a = 1;
int b = 2;
int c = a + b;

複製程式碼

上面的程式碼我們能不能不順序動一下並且結果不變呢?可以,第一行和第二行調換沒有任何問題。

實際上,CPU 有時候為了優化效能,也會對程式碼順序進行調換(在保證結果的前提下),專業術語叫重排序。為什麼重排序會優化效能呢?

這個就有點複雜了,我們慢慢說。

我們知道,一條指令的執行可以分為很多步驟的,簡單的說,可以分為以下幾步:

  1. 取指 IF
  2. 譯碼和取暫存器運算元 ID
  3. 執行或者有效地址計算 EX
  4. 儲存器返回 MEM
  5. 寫回 WB

我們的彙編指令也不是一步就可以執行完畢的,在CPU 中實際工作時,他還需要分為多個步驟依次執行,每個步驟涉及到的硬體也可能不同,比如,取指時會用到 PC 暫存器和儲存器,譯碼時會用到指令暫存器組,執行時會使用 ALU,寫回時需要暫存器組。

也就是說,由於每一個步驟都可能使用不同的硬體完成,因此,CPU 工程師們就發明了流水線技術來執行指令。什麼意思呢?

假如你需要洗車,那麼洗車店會執行 “洗車” 這個命令,但是,洗車店會分開操作,比如沖水,打泡沫,洗刷,擦乾,打蠟等,這寫動作都可以由不同的員工來做,不需要一個員工依次取執行,其餘的員工在那乾等著,因此,每個員工都被分配一個任務,執行完就交給下一個員工,就像工廠裡的流水線一樣。

CPU 在執行指令的時候也是這麼做的。

既然是流水線執行,那麼流水線肯定不能中斷,否則,一個地方中斷會影響下游所有的元件執行效率,效能損失很大。

那麼怎麼辦呢?打個比方,1沖水,2打泡沫,3洗刷,4擦乾,5打蠟 本來是按照順序執行的。如果這個時候,水沒有了,那麼沖水後面的動作都會收到影響,但是呢,其實我們可以讓沖水先去打水,和打泡沫的換個位置,這樣,我們就先打泡沫,沖水的會在這個時候取接水,等到第一輛車的泡沫打完了,沖水的就回來了,繼續趕回,不影響工作。這個時候順序就變成了:

1打泡沫 ,2沖水,3洗刷,4擦乾,5打蠟.

但是工作絲毫不受影響。流水線也沒有斷。CPU 中的亂序執行其實也跟這個道理差不多。其最終的目的,還是為了壓榨 CPU 的效能。

好了,對於今天的文章需要的硬體知識,我們已經複習的差不多了。總結一下,主要是2點:

  1. CPU 的多級快取訪問主存的時候需要配合快取一致性協議。這個過程可以抽象為記憶體模型。
  2. CPU 為了效能會讓指令流水線執行,並且會在單個 CPU 的執行結構不混亂的情況下亂序執行。

那麼,接下來就要好好說說Java 的記憶體模型了。

2. Java 記憶體模型到底是什麼玩意?

回憶下上面的內容,我們說從硬體的層面什麼是記憶體模型?

記憶體模型可以理解為在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。不同架構的CPU 有不同的記憶體模型。

Java 作為跨平臺語言,肯定要遮蔽不同CPU記憶體模型的差異,構造自己的記憶體模型,這就是Java 的記憶體模型。實際上,根源來自硬體的記憶體模型。

Java 記憶體模型(Java Memory Model)

還是看這個圖片,Java 的記憶體模型和硬體的記憶體模型幾乎一樣,每個執行緒都有自己的工作記憶體,類似CPU的快取記憶體,而 java 的主記憶體相當於硬體的記憶體條。

Java 記憶體模型也是抽象了執行緒訪問記憶體的過程。

JMM(Java 記憶體模型)規定了所有的變數都儲存在主記憶體(這個很重要)中,包括例項欄位,靜態欄位,和構成資料物件的元素,但不包括區域性變數和方法引數,因為後者是執行緒私有的。不會被共享。自然就沒有競爭問題。

什麼是工作記憶體呢?每個執行緒都有自己的工作記憶體(這個很重要),執行緒的工作記憶體儲存了該執行緒使用到的變數和主記憶體副本拷貝,執行緒對變數的所有操作(讀寫)都必須在工作記憶體中進行。而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法訪問對方工作記憶體中的變數。執行緒之間變數值的傳遞均需要通過主記憶體來完成。

總結一下,Java 記憶體模型定義了兩個重要的東西,1.主記憶體,2.工作記憶體。每個執行緒的工作記憶體都是獨立的,執行緒運算元據只能在工作記憶體中計算,然後刷入到主存。這是 Java 記憶體模型定義的執行緒基本工作方式。

3. Java 記憶體模型定義了哪些東西?

實際上,整個 Java 記憶體模型圍繞了3個特徵建立起來的。這三個特徵是整個Java併發的基礎。

原子性,可見性,有序性。

原子性(Atomicity)

什麼是原子性,其實這個原子性和事務處理中的原子性定義基本是一樣的。指的是一個操作是不可中斷的,不可分割的。即使在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒干擾。

我們大致可以認為基本資料型別的訪問讀寫是具備原子性的(但是,如果你在32位虛擬機器上計算 long 和 double 就不一樣了),因為 java 虛擬機器規範中,對 long 和 double 的操作沒有強制定義要原子性的,但是強烈建議使用原子性的。因此,大部分商用的虛擬機器基本都實現了原子性。

如果使用者需要操作一個更到的範圍保證原子性,那麼,Java 記憶體模型提供了 lock 和 unlock (這是8種記憶體操操作中的2種)操作來滿足這種需求,但是沒有提供給程式設計師這兩個操作,提供了更抽象的 monitorenter 和 moniterexit 兩個位元組碼指令,也就是 synchronized 關鍵字。因此在 synchronized 塊之間的操作都是原子性的。

可見性(Visibility)

可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改,Java 記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值,這種依賴主記憶體作為傳遞媒介的方式來實習那可見性的。無論是普通變數還是 volatile 變數都是如此。他們的區別在於:volatile 的特殊規則保證了新值能立即同步到主記憶體,以及每次是使用前都能從主記憶體重新整理,因此,可以說 volatile 保證了多執行緒操作時變數的可見性,而普通變數則不能保證這一點。

除了 volatile 之外, synchronized 和 final 也能實現可見性。同步塊的可見性是由 對一個變數執行 unlock 操作之前,必須先把此變數同步回主記憶體種(執行 store, write 操作)

有序性(Ordering)

有序性這個問題我們在最上面說硬體的時候說過,CPU 會調整指令順序,同樣的 Java 虛擬機器同樣也會調整位元組碼順序,但這種調整在單執行緒裡時感知不到的,除非在多執行緒程式中,這種調整會帶來一些意想不到的錯誤。

Java 提過了兩個關鍵字來保證多個執行緒之間操作的有序性,volatile 關鍵字本身就包含了禁止重排序的語義,而 synchronized 則是由 “一個變數同一時刻只允許一條執行緒對其進行 lock 操作”這個規則獲得的。這條規則決定了同一個鎖的兩個同步塊只能序列的進入。

好了,介紹完了 JMM 的三種基本特徵。不知道大家有沒有發現,volatile 保證了可見性和有序性,synchronized 則3個特性都保證了,堪稱萬能。而且 synchronized 使用方便。但是,仍然要警惕他對效能的影響。

4. Java記憶體模型引出的 Happen-Before 原則是什麼?

說到有序性,注意,我們說有序性可以通過 volatile 和 synchronized 來實現,但是我們不可能所有的程式碼都靠這兩個關鍵字。實際上,Java 語言已對重排序或者說有序性做了規定,這些規定在虛擬機器優化的時候是不能違背的。

  1. 程式次序原則:一個執行緒內,按照程式程式碼順序,書寫在前面的操作先發生於書寫在後面的操作。
  2. volatile 規則:volatile 變數的寫,先發生於讀,這保證了 volatile 變數的可見性。
  3. 鎖規則:解鎖(unlock) 必然發生在隨後的加鎖(lock)前。
  4. 傳遞性:A先於B,B先於C,那麼A必然先於C。
  5. 執行緒的 start 方法先於他的每一個動作。
  6. 執行緒的所有操作先於執行緒的終結。
  7. 執行緒的中斷(interrupt())先於被中斷的程式碼。
  8. 物件的建構函式,結束先於 finalize 方法。

5. Happen-Before 引出的 volatile 又是什麼?

我們在前面,說了很多的 volatile 關鍵字,可見這個關鍵字非常的重要,但似乎他的使用頻率比 synchronized
少多了,我們知道了這個關鍵字可以做什麼呢?

volatile 可以實現執行緒的可見性,還可以實現執行緒的有序性。但是不能實現原子性。

我們還是直接寫一段程式碼吧!

package cn.think.in.java.two;

/**
 * volatile 不能保證原子性,只能遵守 hp 原則 保證單執行緒的有序性和可見性。
 */
public class MultitudeTest {

  static volatile int i = 0;

  static class PlusTask implements Runnable {

    @Override
    public void run() {
      for (int j = 0; j < 10000; j++) {
//        plusI();
        i++;
      }
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread[] threads = new Thread[10];
    for (int j = 0; j < 10; j++) {
      threads[j] = new Thread(new PlusTask());
      threads[j].start();
    }

    for (int j = 0; j < 10; j++) {
      threads[j].join();
    }

    System.out.println(i);
  }

//  static synchronized void plusI() {
//    i++;
//  }

}

複製程式碼

我們啟動了10個執行緒分別對一個 int 變數進行 ++ 操作,注意,++ 符號不是原子的。然後,主執行緒等待在這10個執行緒上,執行結束後列印 int 值。你會發現,無論怎麼執行都到不了10000,因為他不是原子的。怎麼理解呢?

i++ 等於 i = i + 1;

虛擬機器首先讀取 i 的值,然後在 i 的基礎上加1,請注意,volatile 保證了執行緒讀取的值是最新的,當執行緒讀取 i 的時候,該值確實是最新的,但是有10個執行緒都去讀了,他們讀到的都是最新的,並且同時加1,這些操作不違法 volatile 的定義。最終出現錯誤,可以說是我們使用不當。

樓主也在測試程式碼中加入了一個同步方法,同步方法能夠保證原子性。當for迴圈中執行的不是i++,而是 plusI 方法,那麼結果就會準確了。

那麼,什麼時候用 volatile 呢?

運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。
我們程式的情況就是,運算結果依賴 i 當前的值,如果改為 原子操作: i = j,那麼結果就會是正確的 9999.

比如下面這個程式就是使用 volatile 的範例:

package cn.think.in.java.two;

/**
 * java 記憶體模型:
 * 單執行緒下會重排序。
 * 下面這段程式再 -server 模式下會優化程式碼(重排序),導致永遠死迴圈。
 */
public class JMMDemo {

  //  static boolean ready;
  static volatile boolean ready;
  static int num;

  static class ReaderThread extends Thread {

    public void run() {
      while (!ready) {
      }
      System.out.println(num);

    }
  }

  public static void main(String[] args) throws InterruptedException {
    new ReaderThread().start();
    Thread.sleep(1000);
    num = 32;
    ready = true;
    Thread.sleep(1000);
    Thread.yield();
  }

}
複製程式碼

這段程式很有意思,我們使用 volatile 變數來控制流程,最終的正確結果是32,但是請注意,如果你沒有使用 volatile 關鍵字,並且虛擬機器啟動的時候加入了 -server引數,這段程式將永遠不會結束,因為他會被 JIT 優化並且另一個執行緒永遠無法看到變數的修改(JIT 會忽略他認為無效的程式碼)。當然,當你修改為 volatile 就沒有任何問題了。

通過上面的程式碼,我們知道了,volatile 確實不能保證原子性,但是能保證有序性和可見性。那麼是怎麼實現的呢?

怎麼保證有序性呢?實際上,在操作 volatile 關鍵字變數前後的彙編程式碼中,會有一個 lock 字首,根據 intel IA32 手冊,lock 的作用是 使得 本 CPU 的Cache 寫入了記憶體,該寫入動作也會引起別的CPU或者別的核心無效化其Cache,別的CPU需要重新獲取Cache。這樣就實現了可見性。可見底層還是使用的 CPU 的指令。

如何實現有序性呢?同樣是lock 指令,這個指令還相當於一個記憶體屏障(大多數現代計算機為了提高效能而採取亂序執行,這使得記憶體屏障成為必須。語義上,記憶體屏障之前的所有寫操作都要寫入記憶體;記憶體屏障之後的讀操作都可以獲得同步屏障之前的寫操作的結果。因此,對於敏感的程式塊,寫操作之後、讀操作之前可以插入記憶體屏障),指的是,重排序時不能把後面的指令重排序到記憶體屏障之前的位置。只有一個CPU訪問記憶體時,並不需要記憶體屏障;但如果有兩個或者更多CPU訪問同一塊記憶體,且其中有一個在觀測另一個,就需要記憶體屏障來保證了。

因此請不要隨意使用 volatile 變數,這會導致 JIT 無法優化程式碼,並且會插入很多的記憶體屏障指令,降低效能。

6. 總結

首先 JMM 是抽象化了硬體的記憶體模型(使用了多級快取導致出現快取一致性協議),遮蔽了各個 CPU 和作業系統的差異。

Java 記憶體模型指的是:在特定的協議下對記憶體的訪問過程。也就是執行緒的工作記憶體和主存直接的操作順序。

JMM 主要圍繞著原子性,可見性,有序性來設定規範。

synchronized 可以實現這3個功能,而 volatile 只能實現可見性和有序性。final 也能是實現可見性。

Happen-Before 原則規定了哪些是虛擬機器不能重排序的,其中包括了鎖的規定,volatile 變數的讀與寫規定。

而 volatile 我們也說了,不能保證原子性,所以使用的時候需要注意。volatile 底層的實現還是 CPU 的 lock 指令,通過重新整理其餘的CPU 的Cache 保證可見性,通過記憶體柵欄保證了有序性。

總的來說,這3個概念可以說息息相關。他們之間互相依賴。所以樓主放在了一篇來寫,但這可能會導致有所疏漏,但不妨礙我們瞭解整個的概念。可以說,JMM 是所有併發程式設計的基礎,如果不瞭解 JMM,根本不可能高效併發。

當然,我們這篇文章還是不夠底層,並沒有剖析 JVM 內部是怎麼實現的,今天已經很晚了,有機會,我們一起進入 JVM 原始碼檢視他們的底層實現。

good luck!!!!

相關文章