JMM - 玩轉 happens-before

要玩轉 happens-before 咱們須要先簡單介紹下幾個基本概念java

高速緩存

隨着 CPU 的快速發展它的計算速度和內存的讀寫速度差距愈來愈大,若是仍是去讀寫內存的話那麼 CPU 的處理速度就會收到內存讀寫速度的限制,爲了彌補這種差距,爲了保證 CPU 的快速處理就出現了高速緩存。程序員

高速緩存特色是讀寫速度快,容量小,照價昂貴。編程

隨着 CPU 的快速發展,所依賴的高速緩存的讀寫速度也在不斷提高,爲了知足更高的要求就發展出了工藝更好也更加快速的緩存,它的照價也更加昂貴。緩存

對於 CPU 來講按照讀寫速度和緊密程度來講依次分爲 L1(一級緩存)、L2(二級緩存)、L3(三級緩存)他們之間的處理速度依次遞減,對於現代的計算機來講至少會存在一個 L1 緩存。安全

Java 內存模型

Java 線程之間的通訊是由 Java 內存模型(JMM)來控制的,JMM 定義了多個線程之間的共享變量存儲在主內存中,每一個線程私有的數據則存儲在線程的本地內存當中,本地內存中又存儲了多線程共享變量在主內存中的副本(本地內存是一個虛擬的概念並不存在,指的是緩存區,寄存器等概念)。抽象模型圖以下:bash

什麼是 happens-before

happens-before 的概念最初是由 Leslie Lamport 在一篇影響深遠的論文 (《Time,Clocks and thhe Ordering of Events in a Distributed System》)中提出。它用 happens-before 來描述分佈式系統中事件的偏序關係。多線程

從 JDK5 開始,Java 使用 JSR-133 內存模型,JSR-133 使用了 happens-before 的概念來爲單線程或者多線程提供內存可見性保證。架構

happens-before 爲程序員提供了多線程之間的內存可見性併發

happens-before 的規則以下app

  • 程序順序規則:一個線程中的每一個操做 happens-before 該線程中任意的後續操做
  • 監視器鎖規則:對一個線程的解鎖 happens-before 於隨後該線程或者其它線程對這個對象的加鎖
  • volatile 變量規則:對一個 volatile 域的寫 happens-before 於任意後續對這個 volatile 域的讀
  • 傳遞性規則:若是 A happens-before B, B happens-before C 那麼 A happens-before C

根據這個規則咱們就可以保證線程之間的內存可見性,後面會詳細分析,這裏先將定義放出來

內存可見性

上面說了 happens-before 主要是爲單線程或者多線程提供內存可見性保證,那麼內存可見性又是什麼呢,咱們先看下下面的定義

堆內存是線程之間共享的,棧內存是線程私有的。堆內存中的數據存在內存可見性問題,棧內存不受內存可見性影響。

內存可見性:其實就是一種多線程可以看到的共享內存的數據狀態,這個狀態有多是正確的也有多是錯誤的(固然咱們的目的就是爲了保證內存可見性正確)。

下面咱們來分析說明下何時會出現內存可見性問題(也就是在什麼狀況下,不正確的內存可見性狀態會致使多線程程序訪問錯誤)

高速緩存致使的內存可見性問題

咱們知道每一個 CPU 都有本身的高速緩存,那麼在有多個 CPU 的計算機上,讀寫一個數據的時候,由於處理器會往高速緩存中寫數據(對應的就是 JMM 中的線程私有內存),而高速緩存不會立馬刷到內存中(JMM 抽象模型中的主內存),這樣就會形成多個 CPU 之間的讀寫數據不一致,以下

class Test {
    int val = 0;
    void f() {
        val = val + 1;
        // ...
    }
}
複製代碼

上圖只是其中一種可能出錯的狀態,也有多是正確的,多線程未同步就存在不肯定性

  • T1 時刻,線程 A 運行,將主內存中 val = 0 裝入私有的工做內存,而後再 T2 時刻處理器 + 1 處理完畢,T3 時刻寫入了本地緩存中,T6 時刻纔將本地緩存刷新到主內存中
  • T4 時刻,線程 B 運行,發現主內存仍是 val = 0 (由於線程 A 尚未將數據刷如主內存),而後繼續處理 + 1 後返回,線程 B 的工做內存中 val = 1
  • 最終刷入主內存中的數據 val = 1

能夠看到程序員本意是使用 2 個線程對 val 分別執行 + 1 操做,想要獲得的結果 val = 2 結果程序運行完畢獲得的結果是 val = 1

指令重排序致使的內存可見性問題

咱們先來看下什麼是指令重排序

void f() {
        int a = 1;
        int b = 2;
        int c = a + b;
    }
複製代碼

通過編譯器或者處理器重排序後,執行的順序可能變爲先執行 b = 2 後執行 a = 1 而 C 是不可能排在上面 2 步以前的,下面會說明。

指令重排序又分爲編譯器指令重排序、處理器指令重排序。

編譯器和處理器爲了提升指令運行的並行度而進行指令重排序,它們的目的都是爲了加速程序的運行速度,可是不管怎麼重排序都必須保證單線程最終的執行結果不能改變,可是若是是在多線程狀況下就沒法保證了,因此就有可能出現執行結果不正確的狀況。

爲了保證單線程程序最終的正確性,有一點能夠肯定的是若是操做之間存在依賴性,那麼不管是編譯器仍是處理器都不容許對其進行重排序,這一點如今的編譯器和處理都是實現了的。以下

void f() {
        int a = 1;
        // 這個操做依賴上一步操做 a = 1,因此他們不會被重排序
        int b = a + 1;
    }
複製代碼

那麼指令重排序又是如何致使了內存可見性問題的呢?咱們來看一個例子

class Test {
    private static Instance instance;
    
    public static Instance getInstance() {
        if (instance != null) {
            synchronized(Test.class) {
                if (instance != null) {
                    // 錯在這裏
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}
複製代碼

這是一個常見雙重檢查鎖定的單列模型(錯誤的),它錯就錯在指令重排序可能致使返回未被初始化的 instance,咱們來分析下爲何。

instance = new Instance(); 在處理器執行的時候實際上是拆解爲了幾步執行的,僞代碼以下

// 步驟1 分配內存空間
memory = allocate();
// 步驟2 初始化對象
ctorInstance(memory);
// 步驟3 設置對象的內存地址
instance = memory;
複製代碼

咱們能夠看到上面這 3 步驟在單線程的場景下對於步驟 2 和步驟 3這兩部是沒有依賴性的,咱們能夠先設置了它的地址再給他初始化對象內容也能夠,因此可能會指令重排序以下:

// 步驟1 分配內存空間
memory = allocate();
// 步驟2 設置對象的內存地址
instance = memory;
// 步驟3 初始化對象
ctorInstance(memory);
複製代碼

那麼在多線程場景下,線程 A 執行到了步驟 2(尚未初始化),而且正好將工做內存刷新到了主內存中,那麼線程 B 就看到了 instance,認爲已經建立初始化完畢,就直接 return 了,就致使線程 B 可能拿到的是未被初始化的對象,那麼後續使用的時候就會出現問題。

解決內存可見性問題

正是因爲這些緣由致使了內存可見性問題,在多線程的場景下可能會出現意外的狀況,咱們要正確獲得正確的多線程程序執行的結果,那麼咱們就要保證內存可見性的正確性。

內存可見性的正確性保證主要是經過如下一些技術來實現的

  • volatile
    • 解決內存可見性和指令重排序問題
  • final
  • 監視器鎖
    • 解決內存可見性問題
    • 鎖之間的互斥訪問
  • happen-before
    • 採用 happen-before 規則結合上述 3 種或者多種技術,來保證多線程程序執行的正確性。咱們也能夠人爲的用這個規則和對應的代碼推算出多線程程序是否存在產生的結果是否和單線程執行的結果一致(也就是能夠推算是否能獲得正確的結果)

volatile

當寫一個 volatile 變量的時候,JMM 會把線程對應的本地內存中的共享變量值刷新到主內存中去。

volatile 兩大特性

  • 可見性:對一個 volatile 的讀,老是可以看到任意線程對這個 volatile 最後的寫
  • 原子性:對任意單個 volatile 變量讀/寫具備原子性,例如對 64 位的 long、double 等

JMM 經過限制 volatile 讀/寫的重排序,針對編譯器制定了以下 volatile 重排序規則

是否能重排序 第二個操做
第一個操做 普通讀 / 寫 volatile 讀 volatile 寫
普通讀 / 寫 NO
volatile 讀 NO NO NO
volatile 寫 NO NO

從表能夠總結出:

  • 第二個操做爲 volatile 寫的時候,不論上一個操做是什麼都不能重排序
  • 第二個操做爲 volatile 讀的時候,只有上一個操做爲普通讀寫才能進行重排序
  • 當第二個操做爲普通讀寫的時候,只有 volatile 讀不能進行重排序

看完這幾個規則腦子是否是有點暈,那是由於不知道爲何要這麼作,咱們先從一個方面去思考。

就是當寫一個 volatile 變量的時候,會把線程對應的本地內存變量值刷新到內存中去,意味着若是 volatile 寫以前有一個或者多個操做也寫了共享變量,那麼這個時候會將以前全部修改的共享變量所有刷新到主內存中去,這個特性是否是感受特別重要!

看完後面的內容再來看這個表格就能沉底夠理解爲何要這麼作了。

咱們如今再來看一下以前個單例錯誤的例子,是因爲指令重排序致使的,可是咱們把程序作以下更改就能夠保證正確了

class Test {
    private static volatile Instance instance;
    
    public static Instance getInstance() {
        if (instance != null) {
            synchronized(Test.class) {
                if (instance != null) {
                    // 錯在這裏
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}
複製代碼

能夠看到加了個 volatile,加了它以後就可以保證下面這段帶啊不能被重排序的了,意識就是隻能以步驟 1 - > 2 - > 3 的順序執行了,也就保證了這個單列模型的正確性了。

// 步驟1 分配內存空間
memory = allocate();
// 步驟2 初始化對象
ctorInstance(memory);
// 步驟3 設置對象的內存地址
instance = memory;
複製代碼

那麼編譯器是如何實現這個規則的呢,也就說編譯器是用什麼技術實現的這樣的重排序規則,來限制 volatile 的重排序的呢。

編譯器在生成字節碼的時候,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序,因爲插入最優屏障策略過於繁瑣幾乎難以作到,因此 JMM 採起保守策略插入內存屏障以下

  • 在每一個 volatile 寫操做的前面插入一個 StoreStore 屏障
  • 在每一個 volatile 寫操做的後面插入一個 StoreLoad 屏障
  • 在每一個 volatile 讀操做的後面插入一個 LoadLoad 屏障
  • 在每一個 volatile 讀操做的後面插入一個 LoadStore 屏障

內存屏障解釋以下

基於這個策略這個策略,就能夠保證在任意處理器平臺,任意程序都能獲得正確的 volaile 內存語義了,看下圖是 volatile 寫的場景

StoreStore 可以保證上面全部的普通寫在 volatile 寫以前刷新到主內存中。

StoreLoad 若是上面這個 volatile 在方法末尾,它就很難確認調用它的方法是否有 volatile 讀或者寫因此,若是在方法末尾或者 volatile 寫後面真的有 volatile 讀寫這兩種狀況下都會插入 StoreLoad 屏障。

總結個記憶方法

  • StoreStore 屏障:Store 有着存儲的意思,StoreStore 意味着 2個都須要存儲,Volatile 寫前面的普通寫要先一步刷入主內存,它自身也要刷入主內存這不就是 "Store 而後 Store" StoreStore 嗎
  • StoreLoad 屏障:前一個是 Volatile 須要 Store,後續操做不肯定多是 Volatile 寫或者讀,假設是讀,那麼咱們就插入一個 StoreLoad 屏障,防止上面的 volatile 寫和下面的 volatile 讀寫重排序了

下面是 volatile 讀的場景

總結個記憶方法

  • LoadLoad 屏障:2 個 Load 意味着須要禁止 volatile 讀和下面全部的普通讀重排序
  • LoadStore 屏障:先 Load 後 Store 意味是須要禁止 volatile 讀和下面全部的普通寫

而後咱們來看個代碼用 volatile 和 happen-before 規則來分析一下

class Test {
    int num = 10;
    boolean volatile flag = false;
    
    void writer() {
        num = 100;     // 1
        flag = true;   // 2
    }
    
    void reader() {
        if (flag) {   // 3
            System.out.println(num);   // 4
        }
    }
}
複製代碼

假設有線程 A 執行完了 writer 方法後,線程 B 執行去執行 reader 方法。(忘了規則的上面翻一下)

  • 程序順序規則,由於是針對單線程的咱們分別來看
    • 對於線程 A,1 happens-before 2
    • 對於線程 B,3 happens-before 4
  • volatile 變量規則,2 happens-before 3
    • 在 3 這裏有一個 volatile 讀,咱們知道後面會插入 LoadLoad 和 LoadStore 屏障來保證下面的普通讀寫不會重排序到上面去
    • 這裏就保證了 3 和 4 不會重排序,只有 flag = true 才能看到 num 的值
  • 傳遞性規則,1 happens-before 2,2 happens-before 3,3 happens-before 4,那麼就可以獲得 1 happens-before 4

最後強調一下就是,關於這些 volatile 讀寫這些屏障並不必定非得所有按照要求插入,編譯器會進行優化發現不須要插入的時候就不會去插入內存屏障,可是它可以保證和咱們這種插入屏障方式獲得同樣的正確的結果。

好比咱們最經常使用的服務 Linux_x86 架構下它禁止了大量的重排序,它只會在 volatile 寫後面插入一個 StoreLoad 屏障,而這個屏障就能保證 volatile 寫讀語義,它會保證在這屏障以前寫入緩存的數據所有刷入主存再執行後續的指令

監視器鎖

對於加鎖了的代碼塊或者方法來講,他們是互斥執行的,一個線程釋放了鎖,另一個線程得到了這個鎖以後才能執行。

它有着和 volatile 類似的內存語義

當線程釋放鎖的時候會把該線程對應的本地內存共享變量刷新到主內存中去。

當線程獲取鎖的時候,JMM 會把當前線程對應的本地內存置位無效,從而使得被監視器保護的臨界區的代碼必須重新從主內存中獲取共享變量。

咱們來看一段代碼

int a = 0;
    
    public synchronized void writer() {  // 1
        a++;  // 2
    }  // 3
    
    public void synchronized reader() { // 4
        int i = a; // 5
    } // 6
複製代碼

假設線程 A 執行了 writer() 方法後線程 B 執行了 reader() 方法,繼續用 happens-before 來分析下

  • 程序順序規則(因爲監視器鎖涉及到了臨界區因此和上面的分析多了 2 步臨界區的分析)
    • 線程 A,1 happens-before 2, 2 happens-before 3
    • 線程 B,4 happens-before 5,5 happens-before 6
  • 監視器鎖規則,3 happens-before 4
    • 線程 A 釋放鎖的時候會把 a 刷新到主內存中去
    • 由於線程 B 在獲取鎖的時候,JMM 會把當前線程對應的本地內存置位無效,會重新去主內存中獲取共享變量 a = 1
  • 傳遞性規則,1 happens-before 2, 2 happens-before 3,3 happens-before 4,4 happens-before 5,5 happens-before 6。最終獲得 2 happens-before 5,因此 i 可以正確賦值。

順序一致性模型

順序一致性模型,JMM,在設計的時候就參考了順序一致性模型。

咱們來看下順序一致性模型的定義

  • 一個線程中的全部操做必須按照順序執行
  • 無論線程之間是否同步,全部線程都只能看到同一個執行順序,而且每一個操做都必須原子執行且立馬對全部線程可見

第一點和 JMM 中的差異相信能很容易看出來,JMM 中是容許指令重排序的,他們的執行順序有可能改變,只不過最終的獲得的結果是一致的。

對於未同步的程序來講在順序一致性模型中是這樣的

順序一致性模型要求對於未同步的模型必須達到這樣的效果,這其實意義不大,爲何呢?由於就算達到了這種效果未同步的程序最終的結果也是不肯定的。因此 JMM 從設計上來講並無這麼作。具體怎麼作的咱們以前已經通過詳細的分析了。

而 JMM 對爲同步的多線程最了最小化安全性,即線程看到的數據要麼是默認值,要麼是其它線程寫入的值。

最後其實還有 final 的內存語義和 final 帶來的內存可見性問題 因爲篇幅太長了後面單獨寫。

每次看 <<Java 併發編程的藝術>> 都有不同的感觸,此次結合本身的思考寫篇文章加深下本身的理解。

參考:

  • Java 併發編程的藝術
相關文章
相關標籤/搜索