Java 記憶體模型,許多人會錯誤地理解成 JVM 的記憶體模型。但實際上,這兩者是完全不同的東西。Java 記憶體模型定義了 Java 語言如何與記憶體進行互動,具體地說是 Java 語言執行時的變數,如何與我們的硬體記憶體進行互動的。而 JVM 記憶體模型,指的是 JVM 記憶體是如何劃分的。
Java 記憶體模型是並行程式設計的基礎,只有對 Java 記憶體模型理解較為透徹,我們才能避免一些錯誤地理解。Java 中一些高階的特性,也建立在 Java 記憶體模型的基礎上,例如:volatile 關鍵字。為了讓大家能明白 Java 記憶體模型存在的意義,本篇文章將從計算機硬體出發,一路寫到作業系統、程式語言,一環扣一環的引出 Java 記憶體模型存在的意義,讓大家對 Java 記憶體模型有較為深刻的理解。看完之後,希望大家能夠明白如下幾個問題:
我們知道計算機有 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。下面是可能發生的一種情況:
可以看到發生錯誤結果的主要原因是:兩個 CPU 快取記憶體中的資料是相互獨立,它們無法感知到對方的變化。
到這裡,就產生了第一個問題:硬體層面上,由於多 CPU 的存在,以及加入 CPU 快取記憶體,導致的資料一致性問題。
要注意的是,這個問題是硬體層面上的問題。只要使用了多 CPU 並且 CPU 有快取記憶體,那就會遇到這個問題。對於生產該 CPU 的廠商,就需要去解決這個問題,這與具體作業系統無關,也與程式語言無關。
那麼如何解決這個問題呢?答案是:快取一致性協定。
所謂的快取一致性協定,指的是在 CPU 快取記憶體與主記憶體互動的時候,遵守特定的規則,這樣就可以避免資料一致性問題了。
在不同的 CPU 中,會使用不同的快取一致性協定。例如 MESI 協定用於奔騰系列的 CPU 中,而 MOSEI 協定則用於 AMD 系列 CPU 中,Intel 的 core i7 處理器使用 MESIF 協定。在這裡我們介紹最為常見的一種:MESI資料一致性協定。
在 MESI 協定中,每個快取可能有有4個狀態,它們分別是:
那麼在 MESI 協定的作用下,我們上面的執行緒執行過程就變為:
從上面的例子,我們可以知道 MESI 快取一致性協定,本質上是定義了一些記憶體狀態,然後通過訊息的方式通知其他 CPU 快取記憶體,從而解決了資料一致性的問題。
作業系統,它遮蔽了底層硬體的操作細節,將各種硬體資源虛擬化,方便我們進行上層軟體的開發。在我們開發應用軟體的時候,我們不需要直接與硬體進行互動,只需要和作業系統互動即可。既然如此,那麼作業系統就需要將硬體進行封裝,然後抽象出一些概念,方便上層應用使用。於是 CPU 時間片、核心態、使用者態等概念也誕生了。
前面我們說到 CPU 與記憶體之間會存在快取一致性問題,那作業系統抽象出來的 CPU 與記憶體也會面臨這樣的問題。因此,作業系統層面也需要去解決同樣的問題。所以,對於任何一個系統來說,它們都需要去解決這樣一個問題。我們把在特定的操作協定下,對特定記憶體或快取記憶體進行讀寫存取的過程進行抽象,得到的就是記憶體模型了。 無論是 Windows 系統,還是 Linux 系統,它們都有特定的記憶體模型。
Java 語言是建立在作業系統上層的高階語言,它只能與作業系統進行互動,而不與硬體進行互動。與作業系統相對於硬體類似,作業系統需要抽象出記憶體模型,那麼 Java 語言也需要抽象出相對於作業系統的記憶體模型。一般來說,程式語言也可以直接複用作業系統層面的記憶體模型,例如:C++ 語言就是這麼做的。但由於不同作業系統的記憶體模型不同,有可能導致程式在一套平臺上並行完全正常,而在另外一套平臺上並行存取卻經常出錯。因此在某些場景下,就必須針對不同的平臺來編寫程式。
而我們都知道 Java 的最大特點是「Write Once, Run Anywhere」,即一次編譯哪裡都可以執行。而為了達到這樣一個目標,Java 語言就必須在各個作業系統的基礎上進一步抽象,建立起一套對記憶體或快取記憶體的讀寫存取抽象標準。這樣就可以保證無論在哪個作業系統,只要遵循了這個規範,都能保證並行存取是正常的。
經過了前面的鋪墊,相信你已經明白了為什麼要有 Java 記憶體模型,以及 Java 記憶體模型是什麼,有了一個感性的理解。這裡我們再給 Java 記憶體模型下一個較為準確的定義。
Java 記憶體模型(Java Memory Model,JMM)用於遮蔽各種硬體和作業系統的記憶體存取差異,以實現讓 Java 程式在各種平臺都能達到一致的記憶體存取效果。
Java 記憶體模型定義程式中各個變數的存取規則,即在虛擬機器器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。這裡說的變數包括了範例欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數。因為後者是執行緒私有的,不會被共用,自然就不會存在競爭問題。
Java 記憶體模型規定所有的變數都儲存在主記憶體中,每條執行緒都有自己的工作記憶體。執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接存取對方工作記憶體中的變數,執行緒間變數值的傳遞都需要通過主記憶體來完成。主記憶體、工作記憶體、執行緒三者之間的關係如下圖所示。
Java 記憶體模型的主記憶體、工作記憶體與 JVM 的堆、棧、方法區,並不是同一層次的記憶體劃分,兩者是沒有關聯的。如果一定要對應一下,那麼主記憶體主要對應於 Java 堆中物件範例的資料部分,而工作記憶體則對應於虛擬機器器棧中的部分割區域。
關於主記憶體與工作記憶體之間具體的互動協定,即一個變數如何從主記憶體拷貝到工作記憶體,以及如何從工作記憶體同步回主記憶體的細節,Java 記憶體模型定義了 8 種操作來完成。虛擬機器器實現的時候必須保證下面提及的每一種操作都是原子的、不可再分的。
如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行 read 和 load 操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行 store 和 write 操作。注意,Java 記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read 與 load 之間、store 與 write 之間是可插入其他指令的,如對主記憶體中的變數 a、b 進行存取時,一種可能出現順序是 read a、read b、load b、load a
。
此外,Java 記憶體模型還規定上述 8 種基本操作時必須滿足如下規則:
這 8 種記憶體存取操作以及上述規則限定,再加上稍後介紹的對 volatile 的一些特殊規定,就已經完全確定了 Java 程式中哪些記憶體存取操作在並行下是安全的。 看完了 Java 記憶體模型的 8 個基本操作和 8 個規則,感覺太過於繁瑣了,非常不利於我們日常程式碼的編寫。為了能幫助程式設計人員理解,於是就有了與其相等價的判斷原則 —— 先行發生原則,它可以用於判斷一個存取在並行環境下是否安全。
這篇文章我們從底層 CPU 開始講起,一直講到作業系統,最後講到了程式語言層面,讓大家能夠一環扣一環地理解,最後明白 Java 記憶體模型誕生的原因(上層有資料一致性問題),以及最終要解決的問題(快取一致性問題)。看到這裡,我們大概把為什麼要有 Java 記憶體模型講清楚了,也知道了 Java 記憶體模型是什麼。最後我們來做個總結:
如果 Java 程式能夠遵守 Java 記憶體模型的規則,那麼其寫出的程式就是並行安全的,這就是 Java 記憶體模型最大的價值。