1. 程式人生 > >深入理解Java虛擬機器-Java記憶體模型閱讀筆記

深入理解Java虛擬機器-Java記憶體模型閱讀筆記

記憶體模型簡介

這裡說的記憶體模型與堆疊記憶體模型不是同一回事,是定義一種變數線上程工作記憶體和主記憶體之間的工作規範。在書中描述一種如下圖所示的記憶體模型。
java記憶體模型

記憶體操作定義

變數在工作記憶體和主記憶體之間的互動操作,由圖中的8種操作完成,書中定義如下。
這裡寫圖片描述
這裡寫圖片描述

記憶體操作執行約束

同時還定義了以下規則來約束上面8種操作的執行,如下所示。
1、不允許read和load、store和write操作之一單獨出現,以上兩個操作必須按順序執行,但沒有保證必須連續執行,也就是說,read與load之間、store與write之間是可插入其他指令的。

2、不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。

3、不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。

4、一個新的變數只能從主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。

5、一個變數在同一個時刻只允許一條執行緒對其執行lock操作,但lock操作可以被同一個條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。

6、如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。

7、如果一個變數實現沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定的變數。

8、對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體(執行store和write操作)。

另外,除了以上的通用執行約束,對於volatile變數有自己的一套約束,是虛擬機器自動完成的,如下所示。
這裡寫圖片描述

理解

先針對volatile的約束規則的瞭解一下約束是怎麼工作的。以下的約束都是java虛擬機器幫助我們自動完成。
volatile約束第一條:通用約束裡面,規定了read、load必須連續出現。而在volatile約束中,規定想要use volatile變數的話必須先執行load動作,那麼也就是說read、load、use必須一起連續進行,這規則保證執行緒執行引擎每次使用的volatile變數的值是從主記憶體中讀取的。

volatile約束第二條:通用約束裡面store、write是必須成對出現。同時volatile第二約束裡面,規定第後一個動作必須是store才能進行assign,所以如果執行引擎要assign volatile變數,必須assign、store、write一起連續進行,也就是,這條規則保證volatile變數的值修改時必須馬上同步回主記憶體,保證主記憶體變數一定是最新值

volatile約束第三條:通俗來說就是規定了,對變數A read、load、use,對變數B read、load、use,假如程式(執行引擎)對A的use先於對B的use,那麼A 的 load先於B的load,A的read、load先於B的read、load。保證指令不會被執行重排(例如常量賦值等),按照程式碼順序執行。

通用約束:主要還是看對lock、unlock規則約束。通用約束6如果執行lock的話,清空工作記憶體此變數,保證賦值或者取值都是最新的;通用約束7保證lock執行約束;通用約束8如果執行unlock的話,需要先把此變數同步回主記憶體,保證了變數可見性。synchronized關鍵字和concurrent包下面的鎖基於此約束執行。

java記憶體的原子性、可見性和有序性

先看一段《深入理解Java虛擬機器》的描述
原子性(Atomicity):由Java記憶體模型來直接保證的原子性變數操作包括read、load、use、assign、store、write,我們大致可以認為基本資料型別的訪問讀寫是具備原子性的。
如果需要一個更大範圍的原子性操作,Java記憶體模型提供了lock和unlock操作來滿足這種需求,儘管虛擬機器未把lock和unlock操作直接開放給使用者使用,但是卻提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個位元組碼指令反應到Java程式碼中就是同步塊—synchronized關鍵字,所以在synchronized塊之間的操作也具備原子性。

可見性(Visibility):可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。
volatile、synchronized、final都可以實現可見性,synchronized可見性是由對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)這條規則獲得的,而final關鍵字的可見性是指:被final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把this的引用傳遞出去,那在其他執行緒中就能見final欄位的值。

有序性(Ordering):Java程式中天然的有序性可以總結為一句話:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指執行緒內表現為序列的語義(within-Thread AS-if-Serial Semantics),後半句是指指令重排序現象和工作記憶體和主記憶體同步延遲現象。
Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由一個變數在同一時刻只允許一條執行緒對其進行lock做這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能序列的進入。

理解

原子性

lock、unlock之間的記憶體操作具備原子性

可見性

一個執行緒做的值修改,其他執行緒能夠馬上感知到這個修改,或者說在其他執行緒使用這個值的時候,肯定能從主記憶體獲取到最新的值。
volatile:保證感知

synchronized:線上程A unlock的時候,肯定會執行把值同步回主記憶體操作,當下一個執行緒B synchronized這個變數的時候,會從主記憶體讀取這個值到執行緒B,保證了schronized的變數的可見性。

final:我們都知道final變數一旦初始化值之後其對應的值是不能改變的,因此對於所有的執行緒來說都是可見的。但是需要注意一點就是在final變數所在物件初始化未未完成時,物件this引用暴露給外部的話,那麼final變數初始化會有執行緒安全問題。

public class FinalInit {
    public final int a;
    public static FinalInit thisPoint;
    public FinalInit() throws InterruptedException {
    //引用逃逸
        thisPoint = this;

        Thread.sleep(5000);
        a = 10;
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            try {
                new FinalInit();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        Thread.sleep(1000);
        //輸出未初始化的final值0
        System.out.println(FinalInit.thisPoint.a);
    }
}

有序性

這個有序可以理解為java基本記憶體操作的順序,能否有序影響著執行結果的正確性。

序列的語義保證了執行緒內部所有操作都是記憶體操作按照預想有序進行,執行緒安全的;

跨執行緒的話所有的操作都無法保證有序,例如指令重排保證變數對於執行緒內部使用到時計算上正確,但是如果多執行緒下,重排可能會導致別的執行緒訪問本執行緒的變數結果不對,無法保證記憶體操作按照預想有序進行。

又或者是由於虛擬機器對於變數在工作記憶體和主記憶體之間的同步存在延遲,無法保證記憶體操作按照預想有序進行,導致執行緒無法訪問到正確的變數值。例如下圖,我們程式設計時預想
這裡寫圖片描述

多執行緒環境下,我們需要保持執行緒之間的有序性,java提供synchronized和volatile關鍵之來給我們使用保證可見性與有序性。volatile變數具有禁止指令重排的語義,同時其本身具有可見性,因此我們可以利用這些特性來邏輯控制保證執行緒之間的有序性。synchronized本身就有“一個變數在同一時刻只允許一條執行緒對其進行lock”的規則,因此天然就具有保證執行緒之間有序性。

先行發生原則

為了保證有序性,我們寫程式碼時可以利用的手段就是先行發生原則。套用這些原則,如果程式能夠保證是有序的,並且能確定執行順序和結果,那麼就是執行緒安全的。先行發生原則是對執行緒安全判斷的重要終結原則。

程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確地說,應該是控制流順序而不是程式程式碼順序,因為考慮分支、迴圈等結構。

管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作,這裡必須強調的是同一個鎖,而“後面”是指時間上的先後順序。

volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,“後面”是指時間上的先後順序。

執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。

執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。

執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷時間的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。

物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。

傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

理解

《深入理解java虛擬機器》書中已經演示如何套用這些規則來判斷程式的順序,如果根據這些規則能夠為程式定出一個順序,那麼執行緒就是安全的,否則就是不安全。
先來一個執行緒不安全的例子。和書中一樣,wrapObject.a = 30先執行賦值,另起一個執行緒取值輸出,執行下面程式,輸出。

public class NotSafeThreadDemo {
    public static void main(String[] args) {
        WrapObject wrapObject = new WrapObject();
        wrapObject.a = 30;
        final int tag = 30;
        System.out.println(tag + "--main-->" + wrapObject.a);
        new Thread(() ->{
            System.out.println(tag +"--join-->"+wrapObject.a);
        }).start();
    }

    static class WrapObject{
        public int a = 10;
    }
}

不是預想的輸出結果:

30--main-->30
30--join-->10

賦值a = 30,但是在另一個執行緒中取值輸出輸出卻是10,不按預想輸出。
按照先行發生規則分析,有沒有適用規則來保證順序。首先是多執行緒所以程式次序規則不適用;沒有使用synchronized或者其他concurrent包的鎖,所以也沒有管程鎖定規則;沒有使用volatile關鍵字,所以也不適用volatile變數規則。除此之外的規則就更加不用說。因此無規則使用,所以執行緒是無法確定順序,無法保證有序。

再來一個執行緒安全的例子。試著使用volatile規則或者管程鎖定規則改造上面的例子,達到執行緒安全。

public class SafeThreadDemo {
    public static void main(String[] args) {
        WrapObject wrapObject = new WrapObject();
        wrapObject.a = 30;
        final int tag = 30;
        System.out.println(tag + "--main-->" + wrapObject.a);
        new Thread(() ->{
            System.out.println(tag +"--join-->"+wrapObject.a);
        }).start();
    }

    static class WrapObject{
        public volatile int a = 10;
    }
}

或者這樣

public class SafeThreadDemo2 {
    public static void main(String[] args) {
        WrapObject wrapObject = new WrapObject();
        synchronized (wrapObject){
            wrapObject.a = 30;
        }
        final int tag = 30;
        System.out.println(tag + "--main-->" + wrapObject.a);
        new Thread(() ->{
            synchronized (wrapObject){
                System.out.println(tag +"--join-->"+wrapObject.a);
            }
        }).start();
    }

    static class WrapObject{
        public int a = 10;
    }
}

就能保證執行緒安全。書中有曰,如果不加先行發生規則進行控制的話,程式碼的先行執行,並不代表對應的記憶體操作是先行執行的;同時記憶體操作的先行執行,也不代表在程式碼級別是先行執行的。記憶體操作的順序,需要我們根據先行發生規則去進行控制。

重排序與記憶體屏障

重排序指編譯器編譯出的指令執行順序跟我們程式碼中編寫順序有誤差,指令重排必須保證它們重排序後的結果和程式程式碼本身的應有結果是一致的。Java編譯器、執行時和處理器都會保證單執行緒下的as-if-serial語義。

記憶體屏障是一種CPU級別指令,用於控制特定條件下的重排序和記憶體可見性問題。Java編譯器會根據記憶體屏障的規則禁止重排序。

記憶體屏障可以被分為以下幾種型別

  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad屏障; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1; StoreStore屏障; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1; LoadStore屏障; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad屏障; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。

在JMM的編譯器層面,通過volatile關鍵字來保證不會重排指令。對於volatile關鍵字,相當於加入一個StoreLoad屏障,屏障前所有store操作先行執行於屏障後的load操作。
這裡寫圖片描述

但是要是同時又多個store操作,那麼就無法保證讀取資料的正確性,例如原本(store1、load1)和(store2、load2)為兩組操作,併發執行,可能出現以下情形,最直觀的就是另個寫操作可能覆蓋另一個寫操作,就好像經典的例子,volatile變數在多個執行緒中做++操作,結果是不正確的。所以也可以理解volatile的實現是基於記憶體屏障的實現,既保證可見性也保證不會指令重排。
這裡寫圖片描述