併發編程之 Java 內存模型 + volatile 關鍵字 + Happen-Before 規則

前言

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

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

  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能使用這個變量的內存。app

如今的 CPU 爲了解決一致性問題,須要各個CPU訪問(讀或者寫)緩存的時候遵循一些協議:MSI,MESI,MOSI,Synapse,Firefly,Dragon Protocol,這些都是緩存一致性協議。dom

那麼,這個時候須要說一個名詞:內存模型。

什麼是內存模型呢?

內存模型能夠理解爲在特定的操做協議下,對特定的內存或高速緩存進行讀寫訪問的過程抽象。不一樣架構的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!!!!

相關文章
相關標籤/搜索