BAT經典面試題,深入理解Java記憶體模型JMM

JAVA架構發表於2019-05-18

Java 記憶體模型

Java 記憶體模型(JMM)是一種抽象的概念,並不真實存在,它描述了一組規則或規範,透過這組規範定義了程式中各個變數(包括例項欄位、靜態欄位和構成陣列物件的元素)的訪問方式。試圖遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果。

注意JMM與JVM記憶體區域劃分的區別:

  • JMM描述的是一組規則,圍繞原子性、有序性和可見性展開;
  • 相似點:存在共享區域和私有區域

主記憶體與工作記憶體

處理器上的暫存器的讀寫的速度比記憶體快幾個數量級,為了解決這種速度矛盾,在它們之間加入了快取記憶體。

加入快取記憶體帶來了一個新的問題:快取一致性。如果多個快取共享同一塊主記憶體區域,那麼多個快取的資料可能會不一致,需要一些協議來解決這個問題。

BAT經典面試題,深入理解Java記憶體模型JMM

所有的變數都  儲存在主記憶體中,每個執行緒還有自己的工作記憶體  ,工作記憶體儲存在快取記憶體或者暫存器中,儲存了該執行緒使用的變數的主記憶體副本複製。

執行緒只能直接操作工作記憶體中的變數,不同執行緒之間的變數值傳遞需要透過主記憶體來完成。

BAT經典面試題,深入理解Java記憶體模型JMM

資料儲存型別以及操作方式

  • 方法中的基本型別本地變數將直接儲存在工作記憶體的棧幀結構中;
  • 引用型別的本地變數:引用儲存在工作記憶體,實際儲存在主記憶體;
  • 成員變數、靜態變數、類資訊均會被儲存在主記憶體中;
  • 主記憶體共享的方式是執行緒各複製一份資料到工作記憶體中,操作完成後就重新整理到主記憶體中。

記憶體間互動操作

Java 記憶體模型定義了 8 個操作來完成主記憶體和工作記憶體的互動操作。

BAT經典面試題,深入理解Java記憶體模型JMM

  • read:把一個變數的值從主記憶體傳輸到工作記憶體中
  • load:在 read 之後執行,把 read 得到的值放入工作記憶體的變數副本中
  • use:把工作記憶體中一個變數的值傳遞給執行引擎
  • assign:把一個從執行引擎接收到的值賦給工作記憶體的變數
  • store:把工作記憶體的一個變數的值傳送到主記憶體中
  • write:在 store 之後執行,把 store 得到的值放入主記憶體的變數中
  • lock:作用於主記憶體的變數
  • unlock

指令重排序的條件

  • 在單執行緒環境下不能改變程式的執行結果;
  • 存在資料依賴關係的不允許重排序;
  • 無法透過Happens-before原則推到出來的,才能進行指令的重排序。

記憶體模型三大特性

1. 原子性

Java 記憶體模型保證了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如對一個 int 型別的變數執行 assign 賦值操作,這個操作就是原子性的。但是 Java 記憶體模型允許虛擬機器將沒有被 volatile 修飾的 64 位資料(long,double)的讀寫操作劃分為兩次 32 位的操作來進行,即 load、store、read 和 write 操作可以不具備原子性。

有一個錯誤認識就是,int 等原子性的型別在多執行緒環境中不會出現執行緒安全問題。前面的執行緒不安全示例程式碼中,cnt 屬於 int 型別變數,1000 個執行緒對它進行自增操作之後,得到的值為 997 而不是 1000。

為了方便討論,將記憶體間的互動操作簡化為 3 個:load、assign、store。

下圖演示了兩個執行緒同時對 cnt 進行操作,load、assign、store 這一系列操作整體上看不具備原子性,那麼在 T1 修改 cnt 並且還沒有將修改後的值寫入主記憶體,T2 依然可以讀入舊值。可以看出,這兩個執行緒雖然執行了兩次自增運算,但是主記憶體中 cnt 的值最後為 1 而不是 2。因此對 int 型別讀寫操作滿足原子性只是說明 load、assign、store 這些單個操作具備原子性。

BAT經典面試題,深入理解Java記憶體模型JMM

AtomicInteger 能保證多個執行緒修改的原子性。

BAT經典面試題,深入理解Java記憶體模型JMM

使用 AtomicInteger 重寫之前執行緒不安全的程式碼之後得到以下執行緒安全實現:

public class AtomicExample {    private AtomicInteger cnt = new AtomicInteger();    public void add() {
        cnt.incrementAndGet();
    }    public int get() {        return cnt.get();
    }
}複製程式碼
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicExample example = new AtomicExample(); // 只修改這條語句
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}複製程式碼
1000複製程式碼

除了使用原子類之外,也可以使用 synchronized 互斥鎖來保證操作的原子性。它對應的記憶體間互動操作為:lock 和 unlock,在虛擬機器實現上對應的位元組碼指令為 monitorenter 和 monitorexit。

public class AtomicSynchronizedExample {    private int cnt = 0;    public synchronized void add() {
        cnt++;
    }    public synchronized int get() {        return cnt;
    }
}複製程式碼
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    AtomicSynchronizedExample example = new AtomicSynchronizedExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }    countDownLatch.await();    executorService.shutdown();    System.out.println(example.get());
}複製程式碼
1000複製程式碼

2. 可見性

可見性指當一個執行緒修改了共享變數的值,其它執行緒能夠立即得知這個修改。Java 記憶體模型是透過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值來實現可見性的。JMM 內部的實現通常是依賴於所謂的  記憶體屏障  ,透過  禁止某些重排序  的方式,提供記憶體  可見性保證  ,也就是實現了  各種 happen-before 規則  。與此同時,更多複雜度在於,需要儘量確保各種編譯器、各種體系結構的處理器,都能夠提供一致的行為。

主要有有三種實現可見性的方式:

  • volatile,會  強制  將該變數自己和當時其他變數的狀態都  刷出快取  。
  • synchronized,對一個變數執行 unlock 操作之前,必須把變數值同步回主記憶體。
  • final,被 final 關鍵字修飾的欄位在構造器中一旦初始化完成,並且沒有發生 this 逃逸(其它執行緒透過 this 引用訪問到初始化了一半的物件),那麼其它執行緒就能看見 final 欄位的值。

對前面的執行緒不安全示例中的 cnt 變數使用 volatile 修飾,不能解決執行緒不安全問題,因為 volatile 並不能保證操作的原子性。

3. 有序性

有序性是指:在本執行緒內觀察,所有操作都是有序的。在一個執行緒觀察另一個執行緒,所有操作都是無序的,無序是因為發生了指令重排序。在 Java 記憶體模型中,允許編譯器和處理器對指令進行重排序,重排序過程不會影響到單執行緒程式的執行,卻會影響到多執行緒併發執行的正確性。

volatile 關鍵字透過新增記憶體屏障的方式來禁止指令重排,即重排序時不能把後面的指令放到記憶體屏障之前。

也可以透過 synchronized 來保證有序性,它保證每個時刻只有一個執行緒執行同步程式碼,相當於是讓執行緒順序執行同步程式碼。

先行發生原則(Happen-Before)

JSR-133記憶體模型使用先行發生原則在Java記憶體模型中保證多執行緒操作  可見性  的機制,也是對早期語言規範中含糊的可見性概念的一個精確定義。上面提到了可以用 volatile 和 synchronized 來保證有序性。除此之外,JVM 還規定了先行發生原則,讓一個操作  無需控制  就能先於另一個操作完成。

由於  指令重排序  的存在,兩個操作之間有happen-before關係,  並不意味著前一個操作必須要在後一個操作之前執行。  僅僅要求前一個操作的執行結果對於後一個操作是可見的,並且前一個操作  按順序  排在第二個操作之前。

1. 單一執行緒原則(程式設計師順序規則)

Single Thread rule

在一個執行緒內,在程式前面的操作先行發生於後面的操作。

BAT經典面試題,深入理解Java記憶體模型JMM

2. 管程鎖定規則(監視器鎖規則)

Monitor Lock Rule

一個 unlock(解鎖) 操作  先行發生於  後面對同一個鎖的 lock(加鎖) 操作。

BAT經典面試題,深入理解Java記憶體模型JMM

3. volatile 變數規則

Volatile Variable Rule

對一個 volatile 變數的  寫操作  先行發生於後面對這個變數的  讀操作  。

BAT經典面試題,深入理解Java記憶體模型JMM

4. 執行緒啟動規則

Thread Start Rule

Thread 物件的  start()  方法呼叫先行發生於此執行緒的每一個動作。

BAT經典面試題,深入理解Java記憶體模型JMM

5. 執行緒加入規則

Thread Join Rule

Thread 物件的結束先行發生於 join() 方法返回。

BAT經典面試題,深入理解Java記憶體模型JMM

6. 執行緒中斷規則

Thread Interruption Rule

對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以透過 interrupted() 方法檢測到是否有中斷髮生。

7. 物件終結規則

Finalizer Rule

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

8. 傳遞性

Transitivity

如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼操作 A 先行發生於操作 C。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912582/viewspace-2644842/,如需轉載,請註明出處,否則將追究法律責任。

相關文章