Java記憶體模型(JMM)詳解

2022-10-18 12:01:56

作者:小牛呼嚕嚕 | https://xiaoniuhululu.com
計算機內功、JAVA底層、面試相關資料等更多精彩文章在公眾號「小牛呼嚕嚕 」

什麼是 JMM?

在上一篇文章https://mp.weixin.qq.com/s/KecubYROQztHvwPicJb9wQ中,我們瞭解了計算機由於各個硬體的讀取速度之間的巨大差距,和充分利用CPU的效能的手段方法,及其所帶來的一系列問題:

  1. 為了充分壓榨CPU的效能,CPU 會對指令亂序執行或者語言的編譯器會指令重排,讓CPU一直工作不停歇,但同時會導致有序性問題
  2. 為了平衡CPU的暫存器和記憶體的速度差異,計算機的CPU 增加了快取記憶體,但同時導致了 可見性問題
  3. 為了平衡CPU 與 I/O 裝置的速度差異,作業系統增加了程序、執行緒概念,以分時複用 CPU,但同時導致了原子性問題

Java 是最早嘗試提供記憶體模型的程式語言。由於Java 語言是跨平臺的,另外各個作業系統總存在一些差異,Java在物理機器的基礎上抽象出一個"記憶體模型(JMM)"

JMM 可以看作是 Java 定義的並行程式設計相關的一組規範,除了抽象了執行緒和主記憶體之間的關係之外,其還規定了從 Java 原始碼到 CPU 可執行指令的這個轉化過程要遵守哪些和並行相關的原則和規範,這樣就可以遮蔽各個作業系統的差異,簡化多執行緒程式設計。

Java 執行時記憶體區域與硬體記憶體的關係

Java 記憶體區域和Java記憶體模型有何區別?

這是一個非常容易讓人混淆的問題,Java 記憶體區域和記憶體模型完全是不一樣的東西,

  1. Java 記憶體區域, 也叫記憶體區域JVM記憶體模型,和 Java 虛擬機器器(JVM)的執行時區域相關,是指 JVM執行時將資料分割區域儲存,強調對記憶體空間的劃分。
  2. Java記憶體模型,也叫記憶體模型(JMM),是Java 定義的並行程式設計相關的一組規範,除了抽象了執行緒和主記憶體之間的關係之外,其還規定了從 Java 原始碼到 CPU 可執行指令的這個轉化過程要遵守哪些和並行相關的原則和規範,遮蔽各個作業系統的差異。通俗點說:JMM規範了程式中變數的存取規則,保證了操作的原子性、可見性、有序性,我們下文慢慢道來。

我們知道JVM 執行時記憶體區域是分割區域的,分為棧、堆等,其實這些都是 JVM 定義的邏輯概念。但在傳統的硬體記憶體架構中是沒有棧和堆這種概念。

其中:

  1. 圖中棧可以細分為:虛擬機器器棧(JVM Stacks) 本地方法棧(Native Method Stack)
  • 虛擬機器器棧(JVM Stacks):執行緒私有,它的生命週期和執行緒相同,描述的是Java方法執行的記憶體模型,每個方法在執行的同時都會建立一個線幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊,每個方法從呼叫直至執行完成的過程,都對應著一個線幀在虛擬機器器棧中入棧到出棧的過程
  • 本地方法棧(Native Method Stack):執行緒私有,本地方法棧與虛擬機器器棧的作用是一樣的,只不過虛擬機器器棧是服務Java方法的,而本地方法棧是為虛擬機器器呼叫Native方法服務的。在Java虛擬機器器規範中對於本地方法棧沒有特殊的要求,虛擬機器器可以自由的實現它,因此在Sun HotSpot虛擬機器器直接把本地方法棧和虛擬機器器棧合二為一了。執行緒開始呼叫本地方法時,會進入 不再受 JVM 約束的世界。本地方法可以通過 JNI(Java Native Interface)來存取虛擬機器器執行時的資料區,甚至可以呼叫暫存器,具有和 JVM 相同的能力和許可權。 JNI 類本地方法最著名的應該是 System.currentTimeMillis()
  1. 堆(Heap)

虛擬機器器堆是Java虛擬機器器中記憶體最大的一塊,是被所有執行緒共用的,在虛擬機器器啟動時候建立,Java堆唯一的目的就是存放物件範例,幾乎所有的物件範例都在這裡分配記憶體,隨著JIT編譯器的發展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優化的技術將會導致一些微妙的變化,所有的物件都分配在堆上漸漸變得不那麼「絕對」了。

Java中棧和堆既存在於計算機的快取記憶體中,又存在於主記憶體中,所以兩者並沒有很直接的關係。

Java 執行緒與主記憶體的關係

Java 記憶體模型(JMM) 抽象了執行緒和主記憶體之間的關係,就比如說執行緒之間的共用變數必須儲存在主記憶體中。
在 JDK1.2 之前,Java 的記憶體模型實現總是從 主記憶體 (即共用記憶體)讀取變數,是不需要進行特別的注意的。而在當前的 Java 記憶體模型下,執行緒可以把變數儲存 本地記憶體 (比如機器的暫存器)中,而不是直接在主記憶體中進行讀寫。這就可能造成一個執行緒在主記憶體中修改了一個變數的值,而另外一個執行緒還繼續使用它在暫存器中的變數值的拷貝,造成資料的不一致。

什麼是主記憶體?什麼是本地記憶體?

  • 主記憶體 :所有執行緒建立的範例物件都存放在主記憶體中,不管該範例物件是成員變數還是方法中的本地變數(也稱區域性變數)
  • 本地記憶體 :每個執行緒都有一個私有的本地記憶體來儲存共用變數的副本,並且,每個執行緒只能存取自己的本地記憶體,無法存取其他執行緒的本地記憶體。本地記憶體是 JMM 抽象出來的一個概念,儲存了主記憶體中的共用變數副本。

Java 記憶體模型其實是一種規範,定義了很多東西:

  • 所有的變數都儲存在主記憶體(Main Memory)中。
  • 每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中儲存了該執行緒以讀/寫共用變數的拷貝副本。
  • 執行緒對變數的所有操作都必須在本地記憶體中進行,而不能直接讀寫主記憶體。
  • 不同的執行緒之間無法直接存取對方本地記憶體中的變數。

這裡所講的主記憶體、工作記憶體與 Java 記憶體區域中的 Java 堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的,如果兩者一定要勉強對應起來,那從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應於Java堆中的物件範例資料部分,而工作記憶體則對應於虛擬機器器棧中的部分割區域。

執行緒間通訊

執行緒間的通訊一般有兩種方式進行,一是通過訊息傳遞,二是共用記憶體。Java 執行緒間的通訊採用的是共用記憶體方式,JMM 為共用變數提供了執行緒間的保障。如果兩個執行緒都對一個共用變數進行操作,共用變數初始值為 1,每個執行緒都變數進行加 1,預期共用變數的值為 3。在 JMM 規範下會有一系列的操作。我們直接來看下圖:

在多執行緒的情況下,對主記憶體中的共用變數進行操作可能發生執行緒安全問題,比如:執行緒 1 和執行緒 2 同時對同一個共用變數進行操作,執行+1操作,執行緒 1 、執行緒2 讀取的共用變數是否是彼此修改前還是修改後的值呢,這個是無法確定的,這種情況和CPU的快取記憶體與記憶體之間的問題非常相似

如何實現主記憶體與工作記憶體的變數同步,為了更好的控制主記憶體和本地記憶體的互動,Java 記憶體模型定義了八種操作來實現:

  • lock:鎖定。作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。
  • unlock:解鎖。作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read:讀取。作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
  • load:載入。作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。工作記憶體即本地記憶體
  • use:使用。作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  • assign:賦值。作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store:儲存。作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
  • write:寫入。作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

重溫Java 並行三大特性

原子性

原子性:即一個或者多個操作作為一個整體,要麼全部執行,要麼都不執行,並且操作在執行過程中不會被執行緒排程機制打斷;而且這種操作一旦開始,就一直執行到結束,中間不會有任何上下文切換(context switch)
比如:

int i = 0;   //語句1,原子性

i++;         //語句2,非原子性

語句1大家一幕瞭然,語句2卻許多人容易犯迷糊,i++ 其實可以分為3步:

  1. i 被從區域性變數表(記憶體)取出,
  2. 壓入操作棧(暫存器),操作棧中自增
  3. 使用棧頂值更新區域性變數表(暫存器更新寫入記憶體)

執行上述3個步驟的時候是可以進行執行緒切換的,或者說是可以被另其他執行緒的 這3 步打斷的,因此語句2不是一個原子性操作

在 Java 中,可以藉助synchronized 、各種 Lock 以及各種原子類實現原子性。
synchronized 和各種Lock是通過保證任一時刻只有一個執行緒存取該程式碼塊,因此可以保證其原子性。各種原子類是利用CAS (compare and swap)操作(可能也會用到 volatile或者final關鍵字)來保證原子操作。

可見性

可見性是指當多個執行緒存取同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看到修改的值。
我們來看一個例子:

public class VisibilityTest {
    private boolean flag = true;

    public void change() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + ",已修改flag=false");
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + ",開始執行.....");
        int i = 0;
        while (flag) {
            i++;
        }
        System.out.println(Thread.currentThread().getName() + ",結束迴圈");
    }

    public static void main(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();

        // 執行緒threadA模擬資料載入場景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 讓threadA執行一會兒
        Thread.sleep(1000);
        // 執行緒threadB 修改 共用變數flag
        Thread threadB = new Thread(() -> test.change(), "threadB");
        threadB.start();

    }
}

threadA 負責迴圈,threadB負責修改 共用變數flag,如果flag=false時,threadA 會結束迴圈,但是上面的例子會死迴圈。原因是threadA無法立即讀取到共用變數flag修改後的值。我們只需 private volatile boolean flag = true;加上volatile關鍵字threadA就可以立即退出迴圈了。

Java中的volatile關鍵字提供了一個功能,那就是被其修飾的變數在被修改後可以立即同步到主記憶體,被其修飾的變數在每次是用之前都從主記憶體重新整理。

因此,可以使用volatile來保證多執行緒操作時變數的可見性。除了volatile,Java中的synchronizedfinal兩個關鍵字 以及各種 Lock也可以實現可見性。

有序性

有序性:即程式執行的順序按照程式碼的先後順序執行。

int i = 0;
int j = 0;
i = 10;   //語句1
j = 1;    //語句2

但由於指令重排序問題,程式碼的執行順序未必就是編寫程式碼時候的順序。語句可能的執行順序如下:

  1. 語句1 語句2
  2. 語句2 語句1

指令重排對於非原子性的操作,在不影響最終結果的情況下,其拆分成的原子操作可能會被重新排列執行順序。指令重排不會影響單執行緒的執行結果,但是會影響多執行緒並行執行的結果正確性
在Java 中,可以通過volatile關鍵字來禁止指令進行重排序優化,詳情可見:https://mp.weixin.qq.com/s/TyiCfVMeeDwa-2hd9N9XJQ。也可以使用synchronized關鍵字保證同一時刻只允許一條執行緒存取程式塊。


參考資料:
《java並行程式設計實戰》
https://www.cnblogs.com/czwbig/p/11127124.html
https://www.cnblogs.com/jelly12345/p/14609657.html
https://www.cnblogs.com/bailiyi/p/11967396.html


本篇文章到這裡就結束啦,很感謝你能看到最後,如果覺得文章對你有幫助,別忘記關注我!更多精彩的文章