從 CPU 講起,深入理解 Java 記憶體模型!

2022-06-22 12:00:35

Java 記憶體模型,許多人會錯誤地理解成 JVM 的記憶體模型。但實際上,這兩者是完全不同的東西。Java 記憶體模型定義了 Java 語言如何與記憶體進行互動,具體地說是 Java 語言執行時的變數,如何與我們的硬體記憶體進行互動的。而 JVM 記憶體模型,指的是 JVM 記憶體是如何劃分的。

Java 記憶體模型是並行程式設計的基礎,只有對 Java 記憶體模型理解較為透徹,我們才能避免一些錯誤地理解。Java 中一些高階的特性,也建立在 Java 記憶體模型的基礎上,例如:volatile 關鍵字。為了讓大家能明白 Java 記憶體模型存在的意義,本篇文章將從計算機硬體出發,一路寫到作業系統、程式語言,一環扣一環的引出 Java 記憶體模型存在的意義,讓大家對 Java 記憶體模型有較為深刻的理解。看完之後,希望大家能夠明白如下幾個問題:

  1. 為什麼要有 Java 記憶體模型?
  2. Java 記憶體模型解決了什麼問題?
  3. Java 記憶體模型是怎樣的一個東西?

從 CPU 說起

我們知道計算機有 CPU 和記憶體兩個東西,CPU 負責計算,記憶體負責儲存資料,每次 CPU 計算前都需要從記憶體獲取資料。我們知道 CPU 的執行速度遠遠快於記憶體的速度,因此會出現 CPU 等待記憶體讀取資料的情況。

由於兩者的速度差距實在太大,我們為了加快執行速度,於是計算機的設計者在 CPU 中加了一個CPU 快取記憶體。這個 CPU 快取記憶體的速度介於 CPU 與記憶體之間,每次需要讀取資料的時候,先從記憶體讀取到CPU快取中,CPU再從CPU快取中讀取。這樣雖然還是存在速度差異,但至少不像之前差距那麼大了。

隨著技術的發展,多核 CPU 出現了,CPU 的計算能力進一步提高。原本同一時間只能執行一個任務,但現在可以同時執行多個任務。由於多核 CPU 的出現,雖然提高了 CPU 的處理速度,但也帶來了新的問題:快取一致性。

在多 CPU 系統中,每個處理器都有自己的快取記憶體,而它們又共用同一主記憶體,如下圖所示。當多個 CPU 的運算任務都涉及同一塊主記憶體區域時,可能導致各自的快取資料不一致。如果發生了這種情況,那同步回主記憶體時以哪個 CPU 快取記憶體的資料為準呢?

我們舉個例子,執行緒 A 執行這樣一段程式碼:

i = i + 10;

執行緒 B 執行這樣一段程式碼:

i = i + 10;

他們的 i 都是儲存在記憶體中共用的,初始值是 0。按照我們的設想,最終輸出的值應該是 20 才對。但實際上有可能輸出的值是 10。下面是可能發生的一種情況:

  • 執行緒 A 分配到 CPU0 執行,這時候讀取 i 的值為 0,存到 CPU0 的快取記憶體中。
  • 執行緒 B 分配到 CPU1 執行,這時候讀取 i 的值為 0,存到 CPU1 的快取記憶體中。
  • CPU0 進行運算,得出結果 10,運算結束,寫回記憶體,此時記憶體 i 的值為 10。
  • CPU1 進行運算,得出結果 10,運算結束,寫回記憶體,此時記憶體 i 的值為 10。

可以看到發生錯誤結果的主要原因是:兩個 CPU 快取記憶體中的資料是相互獨立,它們無法感知到對方的變化。

到這裡,就產生了第一個問題:硬體層面上,由於多 CPU 的存在,以及加入 CPU 快取記憶體,導致的資料一致性問題。

要注意的是,這個問題是硬體層面上的問題。只要使用了多 CPU 並且 CPU 有快取記憶體,那就會遇到這個問題。對於生產該 CPU 的廠商,就需要去解決這個問題,這與具體作業系統無關,也與程式語言無關。

那麼如何解決這個問題呢?答案是:快取一致性協定。

所謂的快取一致性協定,指的是在 CPU 快取記憶體與主記憶體互動的時候,遵守特定的規則,這樣就可以避免資料一致性問題了。

在不同的 CPU 中,會使用不同的快取一致性協定。例如 MESI 協定用於奔騰系列的 CPU 中,而 MOSEI 協定則用於 AMD 系列 CPU 中,Intel 的 core i7 處理器使用 MESIF 協定。在這裡我們介紹最為常見的一種:MESI資料一致性協定。

在 MESI 協定中,每個快取可能有有4個狀態,它們分別是:

  • M(Modified):這行資料有效,資料被修改了,和記憶體中的資料不一致,資料只存在於本 Cache 中。
  • E(Exclusive):這行資料有效,資料和記憶體中的資料一致,資料只存在於本 Cache 中。
  • S(Shared):這行資料有效,資料和記憶體中的資料一致,資料存在於很多 Cache 中。
  • I(Invalid):這行資料無效。

那麼在 MESI 協定的作用下,我們上面的執行緒執行過程就變為:

  • 執行緒 A 分配到 CPU0 執行,這時候讀取 i 的值為 0,存到 CPU0 的快取記憶體中。
  • 執行緒 B 分配到 CPU1 執行,這時候讀取 i 的值為0,存到 CPU1 的快取記憶體中。
  • CPU0 進行運算,得出結果 10,運算結束,寫回記憶體,此時記憶體 i 的值為 10。同時通過訊息的方式告訴其他持有 i 變數的 CPU 快取,將這個快取的狀態值為 Invalid。
  • CPU1 進行運算,從 CPU 快取取出值,但是發現這個快取值被置為 Invalid了。於是重新去記憶體中讀取,讀取到 10 這個值放入 CPU 快取。
  • CPU1 進行運算,得出結果 20,運算結束,寫回記憶體,此時記憶體 i 的值為 20。

從上面的例子,我們可以知道 MESI 快取一致性協定,本質上是定義了一些記憶體狀態,然後通過訊息的方式通知其他 CPU 快取記憶體,從而解決了資料一致性的問題。

從作業系統說起

作業系統,它遮蔽了底層硬體的操作細節,將各種硬體資源虛擬化,方便我們進行上層軟體的開發。在我們開發應用軟體的時候,我們不需要直接與硬體進行互動,只需要和作業系統互動即可。既然如此,那麼作業系統就需要將硬體進行封裝,然後抽象出一些概念,方便上層應用使用。於是 CPU 時間片、核心態、使用者態等概念也誕生了。

前面我們說到 CPU 與記憶體之間會存在快取一致性問題,那作業系統抽象出來的 CPU 與記憶體也會面臨這樣的問題。因此,作業系統層面也需要去解決同樣的問題。所以,對於任何一個系統來說,它們都需要去解決這樣一個問題。我們把在特定的操作協定下,對特定記憶體或快取記憶體進行讀寫存取的過程進行抽象,得到的就是記憶體模型了。 無論是 Windows 系統,還是 Linux 系統,它們都有特定的記憶體模型。

Java 語言是建立在作業系統上層的高階語言,它只能與作業系統進行互動,而不與硬體進行互動。與作業系統相對於硬體類似,作業系統需要抽象出記憶體模型,那麼 Java 語言也需要抽象出相對於作業系統的記憶體模型。一般來說,程式語言也可以直接複用作業系統層面的記憶體模型,例如:C++ 語言就是這麼做的。但由於不同作業系統的記憶體模型不同,有可能導致程式在一套平臺上並行完全正常,而在另外一套平臺上並行存取卻經常出錯。因此在某些場景下,就必須針對不同的平臺來編寫程式。

而我們都知道 Java 的最大特點是「Write Once, Run Anywhere」,即一次編譯哪裡都可以執行。而為了達到這樣一個目標,Java 語言就必須在各個作業系統的基礎上進一步抽象,建立起一套對記憶體或快取記憶體的讀寫存取抽象標準。這樣就可以保證無論在哪個作業系統,只要遵循了這個規範,都能保證並行存取是正常的。

Java 記憶體模型

經過了前面的鋪墊,相信你已經明白了為什麼要有 Java 記憶體模型,以及 Java 記憶體模型是什麼,有了一個感性的理解。這裡我們再給 Java 記憶體模型下一個較為準確的定義。

Java 記憶體模型(Java Memory Model,JMM)用於遮蔽各種硬體和作業系統的記憶體存取差異,以實現讓 Java 程式在各種平臺都能達到一致的記憶體存取效果。

Java 記憶體模型定義程式中各個變數的存取規則,即在虛擬機器器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。這裡說的變數包括了範例欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數。因為後者是執行緒私有的,不會被共用,自然就不會存在競爭問題。

記憶體模型的定義

Java 記憶體模型規定所有的變數都儲存在主記憶體中,每條執行緒都有自己的工作記憶體。執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接存取對方工作記憶體中的變數,執行緒間變數值的傳遞都需要通過主記憶體來完成。主記憶體、工作記憶體、執行緒三者之間的關係如下圖所示。

Java 記憶體模型的主記憶體、工作記憶體與 JVM 的堆、棧、方法區,並不是同一層次的記憶體劃分,兩者是沒有關聯的。如果一定要對應一下,那麼主記憶體主要對應於 Java 堆中物件範例的資料部分,而工作記憶體則對應於虛擬機器器棧中的部分割區域。

記憶體間的互動

關於主記憶體與工作記憶體之間具體的互動協定,即一個變數如何從主記憶體拷貝到工作記憶體,以及如何從工作記憶體同步回主記憶體的細節,Java 記憶體模型定義了 8 種操作來完成。虛擬機器器實現的時候必須保證下面提及的每一種操作都是原子的、不可再分的。

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

如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行 read 和 load 操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行 store 和 write 操作。注意,Java 記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read 與 load 之間、store 與 write 之間是可插入其他指令的,如對主記憶體中的變數 a、b 進行存取時,一種可能出現順序是 read a、read b、load b、load a

此外,Java 記憶體模型還規定上述 8 種基本操作時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  • 不允許一個執行緒丟棄它的最近的 assign 操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中「誕生」,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一個時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
  • 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。
  • 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、write操作)。

這 8 種記憶體存取操作以及上述規則限定,再加上稍後介紹的對 volatile 的一些特殊規定,就已經完全確定了 Java 程式中哪些記憶體存取操作在並行下是安全的。 看完了 Java 記憶體模型的 8 個基本操作和 8 個規則,感覺太過於繁瑣了,非常不利於我們日常程式碼的編寫。為了能幫助程式設計人員理解,於是就有了與其相等價的判斷原則 —— 先行發生原則,它可以用於判斷一個存取在並行環境下是否安全。

總結

這篇文章我們從底層 CPU 開始講起,一直講到作業系統,最後講到了程式語言層面,讓大家能夠一環扣一環地理解,最後明白 Java 記憶體模型誕生的原因(上層有資料一致性問題),以及最終要解決的問題(快取一致性問題)。看到這裡,我們大概把為什麼要有 Java 記憶體模型講清楚了,也知道了 Java 記憶體模型是什麼。最後我們來做個總結:

  1. 由於多核 CPU 和快取記憶體在存在,導致了快取一致性問題。這個問題屬於硬體層面上的問題,而解決辦法是各種快取一致性協定。不同 CPU 採用的協定不同,MESI 是最經典的一個快取一致性協定。
  2. 作業系統作為對底層硬體的抽象,自然也需要解決 CPU 快取記憶體與記憶體之間的快取一致性問題。各個作業系統都對 CPU 快取記憶體與快取的讀寫存取過程進行抽象,最終得到的一個東西就是「記憶體模型」。
  3. Java 語言作為執行在作業系統層面的高階語言,為了解決多平臺執行的問題,在作業系統基礎上進一步抽象,得到了 Java 語言層面上的記憶體模型。
  4. Java 記憶體模型分為工作記憶體與主記憶體,每個執行緒都有自己的工作記憶體。每個執行緒都不能直接與主記憶體互動,只能與工作記憶體互動。此外,為了保證並行程式設計下的資料準確性,Java 記憶體模型還定義了 8 個基本的原子操作,以及 8 條基本的規則。

如果 Java 程式能夠遵守 Java 記憶體模型的規則,那麼其寫出的程式就是並行安全的,這就是 Java 記憶體模型最大的價值。

參考資料