天天看點

java線程-Java記憶體模型

多線程程式設計bug源頭

cpu,記憶體,I/O裝置都在不斷的疊代,不斷朝着更快的方向努力,但是,在這個快速發展的過程中,有一個核心沖突一直存在,就是這三者的速度差異。cpu > 記憶體 > i/0。根據木桶理論,程式整體的性能取決于最慢的操作-i/o裝置的讀寫,也就是說單方面提高cpu性能是無效的。 為了平衡這三者的速度差異,計算機體系機構,作業系統,編譯程式都做出了貢獻,主要展現在:

  1. cpu增加了緩存,以均衡與記憶體的速度差異;
  2. 作業系統增加了程序,線程,以分時複用cpu,進而均衡cpu與i/o裝置的速度差異;
  3. 編譯程式優化指令執行次序,使得緩存能夠得到更加合理地利用。

CPU緩存-可見性問題

在單核時代,所有的線程都在一顆cpu上運作,cpu緩存與記憶體的資料一緻性容易解決,因為所有線程都操作同一顆cpu的緩存,一個線程對緩存的寫,對另外一個線程來說一定是可見的。 一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱之為可見性。

在多核時代,每顆cpu都有自己的緩存,這時cpu緩存與記憶體的資料一緻性就沒那麼容易解決了。當多個線程在不同的cpu上運作時,這些線程操作的是不同的cpu緩存。假設線程a在cpu-1上運作,線程b在CPU-2上運作,此時線程a對變量v的操作對線程b來說是不可見的。

public class Test{
    private long count = 0;
    private void add(){
        int i = 0;
        while(i++ <= 10000){
            count += 1;
        }
    }
    
    public static long calc(){
        final Test test = new Test();
        Thread t1 = new Thread(()->{
            test.add();
        });
        
        Thread t2 = new Thread(()->{
            test.add();
        });
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        retun count;
    }
}
複制代碼           

上面的程式,如果在單核時代,那麼結果毋庸置疑是20000,但在多核時代,最後結果是10000-20000之間的随機數。 我們假設t1和t2線程同時開始執行,那麼第一次都會将count=0讀到各自的cpu緩存裡,執行完count += 1之後,各自的cpu緩存裡的值都是1,而不是我們期望的2.之後由于各自的cpu緩存裡都有count值,是以導緻最終count的計算結果小于20000.這就是緩存的可見性問題。

java線程-Java記憶體模型

線程切換-原子性問題

java并發程式都是基于多線程的,這樣也會涉及到線程切換。執行count += 1的操作,至少需要三條cpu指令:

  1. 首先,需要把變量count 從記憶體中加載到cpu的寄存器。
  2. 在寄存器中執行 +1 操作。
  3. 将結果寫入記憶體,緩存機制導緻可能寫入的是cpu的緩存還不是記憶體。

對于上面的三個指令來說,如果線程a剛剛執行完指令1,就和線程b發生了線程切換,導緻count的值還是為0,而不是+1之後的值,就會導緻其結果不是我們希望的2.

我們把一個或者多個操作在cpu執行的過程中不被中斷的特性稱為原子性。

java線程-Java記憶體模型

編譯執行-有序性問題

有序性指的是程式按照代碼的先後順序執行。 但是編譯器為了優化性能,有時候會改變程式中語句的先後順序。例如程式:“a = 6; b = 7”,編譯優化後的順序可能為:”b=7;a=6;“。 java中最經典的案例就是單例模式,利用雙重檢查建立單例對象,保證線程安全。

public class Singleton{
    static Singleton bean;
    static Singleton getInstance(){
        if(bean == null){
            synchronized(Singleton.class){
                if(bean == null){
                    bean = new Singleton();
                }
            }
        }
        return bean;
    }
}
複制代碼           

假設有兩個線程a b同時調用getInstance()方法,于是同時對Singleton.class加鎖,此時jvm保證隻有一個線程能夠加鎖成功,假設是a,那麼b就會處于等待狀态,當a執行完,釋放鎖之後,B被喚醒,繼續執行,發現已經有bean對象,是以直接return了。我們以為jvm建立對象的順序是這樣的:

  1. 配置設定一塊記憶體m;
  2. 在記憶體m上初始化singleton對象;
  3. 然後m的位址指派給bean變量;

但是實際上優化後的執行路徑是這樣的:

  1. 配置設定一塊記憶體m;
  2. 将m的位址指派給bean變量;
  3. 在記憶體M上初始化singleton對象;

當線程a先執行完getinstance()方法,當執行完指令2的時候,恰好發生了線程切換,切換到了線程b,如果此時線程B,也執行了getinstance方法,那麼線程B在執行第一個判斷的時候,就會發現bean!=null,直接return,如果這個時候我們通路bean對象的時候,就會報空指針異常。

java線程-Java記憶體模型

實際上,在代碼的編譯執行過程中,有三種情況可能會導緻指令重排

  1. 編譯優化導緻的重排序
  2. CPU指令并行執行導緻的重排序
  3. 硬體記憶體模型導緻的重排序

Java記憶體模型

從上面的分析,CPU緩存導緻了可見性問題,線程切換導緻了原子性問題,編譯執行導緻了有序性問題。那如何解決這三個問題呢?很直接的做法就是禁止使用CPU緩存,禁止線程切換,禁止指令重排。但是CPU緩存,線程切換,指令重排都是為了提高代碼運作的效率,但為了保證多線程程式設計不會出現問題,過度禁止使用這些技術,也會影響代碼執行效率,是以java記憶體模型就應運而生,對應的規範就是JSR-133。之是以叫java記憶體模型,是因為要解決的問題,都是跟記憶體有關。

Java記憶體模型解決多線程的3個問題,主要依靠3個關鍵詞和1個規則,3個關鍵詞分别是:volatile、synchronized、final,1個規則是:happens-before規則。

volatile

volatile關鍵字可以解決可見性、有序性和部分原子性問題。

volatile如何解決可見性問題

對于用volatile修飾的變量,在編譯成機器指令時,會在寫操作後面,加上一條特殊的指令:“lock addl #0x0, (%rsp)”,這條指令會将CPU對此變量的修改,立即寫入記憶體,并通知其他CPU更新緩存資料。

volatile如何解決有序性問題

禁止指令重排序又分為完全禁止指令重排序和部分禁止指令重排序。完全禁止指令重排是指volatile修飾的變量的讀寫指令不可以跟其前面的讀寫指令重排,也不可以跟後面的讀寫指令重排。

java線程-Java記憶體模型

指令重排是為了優化代碼的執行效率,過于嚴格的限制指令重排,顯然會降低代碼的執行效率。是以,Java記憶體模型将volatile的語義定義為:部分禁止指令重排序。

對volatile修飾的變量執行寫操作,Java記憶體模型隻禁止位于其前面的讀寫操作與其進行重排序,位于其後面的讀寫操作可以與其進行指令重排序。

對volatile修飾的變量執行讀操作,Java記憶體模型隻禁止位于其後面的讀寫操作與其進行重排序,位于其前面的讀寫操作可以與其進行指令重排序。

java線程-Java記憶體模型

為了能實作上述細化之後的指令重排禁止規則,Java記憶體模型定義了4個細粒度的記憶體屏障(Memory Barrier),也叫做記憶體栅欄(Memory Fence),它們分别是:StoreStore、StoreLoad、LoadLoad、LoadStore。

  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。
  • 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障。
  • 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。
  • 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障。
# other ops
[StoreStore]
x = 1 # volatile修飾x變量,volatile寫操作
[StoreLoad]
y = x # volatile讀操作
[LoadLoad]
[LoadStore]
# other ops
複制代碼           
java線程-Java記憶體模型

volatile如何解決部分原子性問題

兩類原子性問題,一類是64位long和double類型資料的讀寫的原子性問題,另一類是自增語句(例如count++)的原子性問題。volatile可以解決第一類原子性問題,但是無法解決第二類原子性問題。

synchronized

synchronized也可以解決可見性、有序性、原子性問題。隻不過,它的解決方式比較簡單粗暴,讓原本并發執行的代碼串行執行,并且,每次加鎖和釋放鎖,都會同步CPU緩存和記憶體中的資料。

final

final修飾變量時,初衷是告訴編譯器:這個變量生而不變,可以可勁兒優化,這就導緻在1.5版本之前優化的很努力,以至于都優化錯了,雙重檢索方法建立單例,構造函數的錯誤重排導緻線程可能看到final變量的值會變化。但是1.5以後已經對final修飾的變量的重排進行了限制。

happens-before

概念:前面一個操作的結果對後續操作是可見的。也就是說,happens-before限制了編譯器的優化行為,雖允許編譯器優化,但是要求編譯器優化後一定要遵守happens-before原則。

happens-before一共有六項規則:

程式的順序性規則

前面的操作happens-before于後續的任意操作。

例如上面的代碼 x=42happens-before于v=true;,比較符合單線程的思維,程式前面對某個變量的修改一定是對後續操作可見的。

volatile變量規則

對一個volatile變量的寫操作,happens-befores與後續對這個變量的讀操作。 這怎麼看都有禁用緩存的意思,貌似和1.5版本之前的語義沒有變化,這個時候我們需要關聯下一個規則來看這條規則。

傳遞性

a happens-before于 b,b happens-before于c,那麼a happens-before與c。

  1. x = 42 happens-before于 寫變量v = true; —-規則1
  2. 寫變量v = true happens-before于 讀變量v==true。—-規則2
  3. 是以x = 42 happens-before于 讀變量v == true;—-規則3

如果線程b讀到了v= true,那麼線程a設定的x=42對線程b是可見的。也就是說線程b能看到x=42。

管程中鎖的原則

對一個鎖的解鎖happens-before于對後續這個鎖的加鎖操作。 synchronized是java裡對管程的實作。

管程中的鎖在java裡是隐式實作的,例如下面的代碼,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖。加鎖和解鎖的操作都是編譯器幫我們實作的。

synchronzied(this){// 此處自動加鎖
    // x 是共享變量,初始值是10;
    if(this.x < 12){
        this.x = 12;
    }
}// 此處自動解鎖
複制代碼           

根據管程中鎖的原則,線程a執行完代碼塊後x的值變成12,線程B進入代碼塊時,能夠看到線程a對x的寫的操作,也就是線程b能看到x=12。

start()

主線程a啟動子線程b後,子線程B能夠看到主線程a在啟動子線程b前的操作。也就是線上程a中啟動了線程b,那麼該start()操作happens-before于線程b中任意操作。

int x = 0;
Thread B = new Thread(()->{
    // 這裡能看到變量x的值,x = 12;
});
x = 12;
B.start();
複制代碼           

join

主線程a等待子線程b完成(主線程a調用子線程b的join方法實作),當b完成後(主線程a中join方法傳回),主線程a能夠看到子線程b的任意操作。這裡都是對共享變量的操作。

如果線上程a中調用了線程b的join()并成功傳回,那麼線程b中任意操作happens-before于該join操作的傳回。

int x = 0;
Thread b = new Thread(()->{
    x = 11;
});

x = 12;
b.start();
b.join();
// x = 11;
複制代碼           

線程中斷規則

線程a調用了線程b的interrupt()方法,happens-before于線程b的代碼檢測到中斷事件的發生。

對象終結規則

一個對象初始化完成,happens-before于它的finalize()方法的調用

CPU緩存一緻性協定與可見性

CPU緩存一緻性協定

目的是為了保證不同CPU之間的緩存資料一緻,比較經典的一緻性協定就是MESI協定。

緩存行有4種不同的狀态:

  • 已修改Modified (M)
  • 緩存行是髒的(dirty),與主存的值不同。如果别的CPU核心要讀主存這塊資料,該緩存行必須回寫到主存,狀态變為共享(S).
  • 獨占Exclusive (E)
  • 緩存行隻在目前緩存中,但是幹淨的(clean)--緩存資料同于主存資料。當别的緩存讀取它時,狀态變為共享;目前寫資料時,變為已修改狀态。
  • 共享Shared (S)
  • 緩存行也存在于其它緩存中且是幹淨的。緩存行可以在任意時刻抛棄。
  • 無效Invalid (I)
  • 緩存行是無效的

我們通過一個例子來深入了解一下MESI緩存行4種不同狀态的轉移方式,例如我們有3個CPU,分别為CPU0,CPU1,CPU2,初始變量V=1。

java線程-Java記憶體模型
  1. 第一步,CPU0讀取V,CPU0 Cache裡的V=1,狀态為E。其餘CPU Cache沒有資料
  2. 第二步,CPU0更新V=2,CPU0 Cache裡的V=2,狀态為M,Memory裡V = 1。其餘CPU Cache沒有資料
  3. 第三步,CPU1讀取V,先通過總線廣播一條讀請求到其他CPU,CPU0接收到通知後,狀态為M,需要将更新後的資料更新到住記憶體,再将狀态變為S,才能響應CPU1的讀取請求,并将CPU1 Cache裡的緩存行的變為S。
  4. 第四步,CPU2讀取V,同第三步
  5. 第五步,CPU2更新V,因為CPU2緩存行裡的狀态為S,如果需要修改V,則需要先廣播其他緩存行狀态為S的CPU Cache,将其他CPU上對應的緩存行的狀态改為I,并回複invalidate ack消息給CPU2,CPU2收到invalidate ack消息後,更新資料V=3,同步更新到主記憶體上,CPU2上的緩存行狀态則變為E。
  6. 第六步,CPU0讀取,發現其CPU緩存行的狀态為I,是以CPU0先廣播讀請求,CPU1不做處理,CPU2上将緩存行的狀态改為S,CPU0從記憶體中讀取,并更新緩存行狀态為S。

Store Buffer

從上述第五步中我們可以了解到,當多個CPU共享同一個資料,其中一個CPU更新資料,需要先廣播invalidate消息,其他CPU收到invalidate消息後将緩存行狀态改為I,然後傳回invalidate ack消息給這個CPU,然後這個CPU收到invalidate ack消息後,才能更新資料并同步更新到記憶體上。這個是非常耗時的一個操作,需要等CPU寫操作完成後才能執行其他指令,會影響CPU的執行效率。是以計算機科學家,在CPU和CPU緩存之間增加了Store Buffer,用于完成異步寫操作。

CPU會将寫操作的資訊存儲到Store Buffer後,CPU就可以執行其他操作指令,Store Buffer負責完成廣播invalidate消息,接收invalidate ack消息,寫入緩存和記憶體等。

讀取消息的時候也是先從Store Buffer裡擷取,如果Store Buffer裡沒有再從緩存和主存裡擷取。

Invalidate Queue

Store Buffer發送給其他CPU invalidate消息之後,需要等待其他CPU設定緩存失效并傳回invalidate ack消息,才能執行寫入緩存和記憶體的操作。但是其他CPU可能忙于執行其他指令,是以導緻store buffer寫入緩存和記憶體操作不及時,有大量的寫操作資訊存儲在Store Buffer裡。

你也許會想到可以擴大Store Buffer的存儲空間,來讓Store Buffer存儲更多的寫操作資訊。

計算機科學家則是在Cpu Cache和總線之間設計了一個Invalidate Queue,用于存儲invalidate消息和傳回invalidate ack消息,并異步執行緩存行狀态設定為I的操作。

CPU緩存一緻性協定與可見性

如果沒有Store Buffer和Invalidate Queue,那麼,緩存一緻性協定是可以保證各個CPU緩存之間的資料一緻性,也就不會存在可見性問題。但是,當引入Store Buffer和Invalidate Queue來異步執行寫操作之後,即便使用緩存一緻性協定,但各個CPU緩存之間仍然會存在短暫的資料不一緻的情況,也就是會存在短暫的可見性問題。

java線程-Java記憶體模型

可見性案例:

  1. CPU0和CPU1均讀取了記憶體中的資料a=1到各自的緩存中,對應的緩存行狀态均标記為S(共享)。CPU0執行寫入操作a=2,為了提高寫入的速度,CPU0将寫入操作a=2存儲到Store Buffer中後就立刻傳回。假設此時Store Buffer還沒有完成寫入緩存和記憶體操作。
  2. CPU0讀取資料,是直接從Store Buffer裡擷取到a=2。CPU1讀取資料,發現Store Buffer裡沒有資料,就從緩存裡讀取到a = 1。此時出現緩存資料不一緻的情況。
  3. 假設Cpu0的Store Buffer會發送消息給Cpu1的Invalidate Queue。在Invalidate Queue還沒有将失效資訊更新到Cpu1的緩存前,Cpu1還是讀取不到最新值a=2。

将寫操作寫入Store Buffer到Invalidate Queue根據失效資訊将Cpu緩存行的狀态設定為I的這段時間内,多個Cpu之間的緩存資料會存在短暫不一緻的情況