close

來源丨經授權轉自公眾號 陳樹義

作者丨陳樹義


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

Java 內存模型是並發編程的基礎,只有對 Java 內存模型理解較為透徹,我們才能避免一些錯誤地理解。Java 中一些高級的特性,也建立在 Java 內存模型的基礎上,例如:volatile 關鍵字。

為了讓大家能明白 Java 內存模型存在的意義,本篇文章將從計算機硬件出發,一路寫到操作系統、編程語言,一環扣一環的引出 Java 內存模型存在的意義,讓大家對 Java 內存模型有較為深刻的理解。看完之後,希望大家能夠明白如下幾個問題:

為什麼要有 Java 內存模型?
Java 內存模型解決了什麼問題?
Java 內存模型是怎樣的一個東西?

從 CPU 說起

我們知道計算機有 CPU 和內存兩個東西,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 內存模型(Java Memory Model,JMM)用於屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平台都能達到一致的內存訪問效果。

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 程序中哪些內存訪問操作在並發下是安全的。

總結

這篇文章我們從底層 CPU 開始講起,一直講到操作系統,最後講到了編程語言層面,讓大家能夠一環扣一環地理解,最後明白 Java 內存模型誕生的原因(上層有數據一致性問題),以及最終要解決的問題(緩存一致性問題)。

看到這裡,我們大概把為什麼要有 Java 內存模型講清楚了,也知道了 Java 內存模型是什麼。最後我們來做個總結:

由於多核 CPU 和高速緩存在存在,導致了緩存一致性問題。這個問題屬於硬件層面上的問題,而解決辦法是各種緩存一致性協議。不同 CPU 採用的協議不同,MESI 是最經典的一個緩存一致性協議。
操作系統作為對底層硬件的抽象,自然也需要解決 CPU 高速緩存與內存之間的緩存一致性問題。各個操作系統都對 CPU 高速緩存與緩存的讀寫訪問過程進行抽象,最終得到的一個東西就是「內存模型」。
Java 語言作為運行在操作系統層面的高級語言,為了解決多平台運行的問題,在操作系統基礎上進一步抽象,得到了 Java 語言層面上的內存模型。
Java 內存模型分為工作內存與主內存,每個線程都有自己的工作內存。每個線程都不能直接與主內存交互,只能與工作內存交互。此外,為了保證並發編程下的數據準確性,Java 內存模型還定義了 8 個基本的原子操作,以及 8 條基本的規則。

如果 Java 程序能夠遵守 Java 內存模型的規則,那麼其寫出的程序就是並發安全的,這就是 Java 內存模型最大的價值。

深入理解 Java 內存模型參考資料

Java 內存模型原理,你真的理解嗎?

《Java 並發編程的藝術》

Java 並發編程實戰 - 蓋茨等 - 微信讀書

Java 高並發編程詳解:深入理解並發核心庫 - 汪文君 - 微信讀書

操作系統對 CPU 的控制權 | 王輝的博客

Operating Systems: Three Easy Pieces

既然 CPU 有緩存一致性協議(MESI),為什麼 JMM 還需要 volatile 關鍵字?- 羅一鑫的回答 - 知乎

1、招銀面試官,聽說你精通 MySQL,我們來大戰 66 回合

2、3000幀動畫圖解MySQL為什麼需要binlog、redo log和undo log

3、求你了,別在高並發場景中使用悲觀鎖了!

4、我為 Netty 貢獻源碼

5、蝦皮二面後續:JWT 身份認證優缺點

點分享

點點讚

點在

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()