修改後的 OpenJDK 記憶體模型

oschina發表於2015-06-25

傳統的 Java 記憶體模型涵蓋許多 Java 語言語義上的保證。在本文中,我們將會強調其中的一些語義,並且深入的理解。我們也將嘗試理解更新 Java 記憶體模型(JMM)的動機,這些更新都是與本文介紹的語義相關的。關於對 JMM 這次更新的討論在本文中將被稱為 JMM9。

Java 記憶體模型

現存的 Java 記憶體模型,就是 JSR133 定義的(後面稱之為 JMM-JSR133),規範了共享記憶體的一致性模型,為開發者提供了一致的定義。JMM-JSR133 規範的目標是確保執行緒與記憶體互動的語義定義是精確的以便優化效能,也提供了一個清楚的程式設計模型。JMM-JSR133 致力於提供定義和語義,使得多執行緒程式不僅執行正確,而且效能良好,並且最小限度地影響現有程式碼。

知道了這些之後,我將要談談某些具體的語義保證,這些保證在 JMM-JSR133 或者定義的過多或者規範的不夠,同時也將強調一下社群內關於如何在 JMM9 中改善的討論。

JMM9 – 順序一致 – 無資料競爭的問題

JMM-JSR133使用指令的概念討論程式的執行過程。這樣的執行過程結合了指令以及描述其關係的順序。在本文中,我將會詳細闡述順序和關係,然後討論什麼構成了一個順序一致的執行過程。讓我們從“程式順序”開始——每個執行緒的程式順序是一個全序關係,指的是該執行緒所有指令的執行順序。某些時候並不是所有指令都需要有順序。因此有些關係只是半序關係。例如“happens-before”和“synchronized-with”是兩個半序關係。當一條指令在另一條指令之前執行;第一個指令不僅對第二條指令是可見的,而且第一條指令也排在第二條指令前面。這兩條指令之間的關係就稱為happens-before關係。某些情況下,有些特殊的排序指令,這些指令被稱為“同步指令”。易失性的讀寫、監控器加鎖解鎖等等都是同步指令的例子。同步指令引起“synchronized-with”關係。synchronized-with關係是半序關係,這意味著並不是所有同步指令對都包含在內。所有同步指令之間的全序關係稱為“同步順序”,每個執行過程有一個同步順序.

現在讓我們討論一下順序一致的執行過程——所有的讀寫指令都呈現全序關係的執行過程稱為順序一致(SC)。在SC執行過程中,所有的讀操作總是能看到上次寫操作的值。當某個SC執行過程沒有“資料競爭”,那麼該程式被稱為無資料競爭的(DRF)。當某個程式兩次訪問同一資料,這兩次訪問至少有一次是寫操作,並且這兩次訪問沒有用happens-before關係排序的時候,那麼就會產生資料競爭。DRF情況下的SC意味著DRF程式的行為類似SC。但是嚴格地支援SC需要犧牲一點兒效能——大部分系統將會重排記憶體操作指令來“隱藏”耗時操作的延遲以提高執行速度。然而即使編譯器可以通過重排指令來優化執行過程,但是為了保證嚴格地順序一致,所有這些記憶體指令重排或程式碼優化不能進行,因此效能受到影響。JMM-JSR133已經試圖放鬆排序的限制,任何底層編譯器、快取-記憶體互動以及JIT自己的重排對程式來說都是不可見的。

注: 耗時操作指的是那些需要很多CPU週期來完成而且/或者阻塞執行流水線的操作。

對於JMM9來說,效能是一個重要的考慮事項,而且任何程式語言記憶體模型理想情況下都應該允許所有開發者使用弱序的架構記憶體模型。在弱序架構中有一些放鬆嚴格排序的成功的實現和案例。

注: 弱序架構指的是可以重排讀寫指令的架構,需要顯式的記憶體屏障指令來控制這些重排。

JMM9 – 無中生有(OoTA)問題

JMM-JSR133另外一個主要的語義是禁止“無中生有”(OoTA)的值。“happens-before”模型有時候會讓變數被建立,然後讀到無中生有的值,這是因為該模型沒有包含因果要求。 值得注意的是其原因並不是由於資料和控制依賴,就像從下述同步正確的例子程式碼中看到的那樣,非法的寫是由寫操作本身引起的。

(注: x 和 y 初始化為‘0’) -

Thread a Thread b
r1 = x; r2 = y;
if (r1 != 0) if (r2 != 0)
y = 42; x = 42;

這段程式碼是 happens-before 一致的,但是不是順序一致的。例如,如果 r1 看到 x=42 寫操作,r2看到 y=42 寫操作,那麼 x 和 y 的值都是42,這是資料競爭情況下的結果。

r1 = x;
y = 42;
r2 = y;
x = 42;

兩個寫操作都在讀操作之前執行,讀操作將會看到各自的寫操作,這將會導致 OoTA。

注: 資料競爭可能是由於推測,最終將會成為自我實現的預言。OoTA 保證關於遵守因果關係準則。當前考慮的是因果關係可能被推測的寫操作破壞。JMM9 致力於發現 OoTA 的起因,細化避免 OoTA 的方式。

為了禁止 OoTA 值,某些寫操作為了避免資料競爭需要等待讀操作。因此 JMM-JSR133 將 OoTA 禁止定義形式化為不允許 OoTA 讀操作。該形式化定義包含記憶體模型的“執行操作和因果要求”。從根本上來說,如果所有的程式指令可以被執行,那麼形式合法的執行操作滿足因果要求。

注: 形式合法的執行操作指的是遵守執行緒內語義,“happens-before”和“synchronization-order”一致執行操作指的是每個讀操作可以看到相應的寫操作。

你可能已經發現了,JMM-JSR133 定義不允許 OoTA 值出現。JMM9 致力於辨認和調整這個形式化定義使得允許某些常見的優化。

JMM9 – 非易失變數的易失操作

首先,‘Volatile’關鍵字是什麼? Java ‘volatile’ 關鍵字保證執行緒間的互動,使得當某個執行緒對易失變數進行寫操作的時候,不僅該寫操作對其他執行緒可見,而且其他執行緒可以看到對易失變數的所有寫操作。

那麼對於非易失變數是怎樣的情況呢?非易失變數沒有‘volatile’關鍵字的執行緒間互動保證。因此編譯器可以使用非易失變數的快取值,然而易失變數總是讀記憶體。happens-before 模型可以用來為非易失變數提供同步訪問。

注: 將任意變數定義為‘volatile’ 並不意味著涉及到鎖。因此 volatile 的成本比使用鎖的成本低。但是值得注意的是在方法中使用多個易失變數將會使其成本比使用鎖高。

JMM9 – 原子性讀寫和字分裂問題 .

JMM-JSR133 同樣對共享記憶體的併發演算法保證(也有例外)了原子性讀寫操作。這個例外是對非易失性長雙精度數的一次寫入也可以被認為是兩個獨立的的寫操作。因此一個64位數的寫入可以被認為是兩個單獨的32位寫操作,當所有寫操作還沒完成的時候,如果一個執行緒執行一個讀操作可能只會得到正確數值的一半,從而失去原子性。下面是一個例子,說明原子性是怎樣依賴於底層的硬體和記憶體子系統從而得到確保的。例如,為了保證原子性,底層的彙編指令應該能夠處理運算物件大小的位數,否則讀寫操作就必須被分割成多次操作,最終破壞原則性(就像非易失性長雙精度數的例子)。類似的,如果記憶體模型的實現呼叫不止一個記憶體子系統的事務,這也會破壞原子性。

注: 易失性長雙精度欄位和引用一定能保證原則性讀寫操作。

如果這些在 64 位計算機上異常問題都解決了,那麼採取有利於一種架構的方案並不是一種理想的解決方案,因為 32 位架構的計算機會受到影響。而如果採取不利於64位架構的方案,那麼即使硬體可以保證操作的原子性,只要用到,你就不得不為 long 和 double 型別引入 ‘volatiles’ (易變數)。例如:因為硬體平臺,ISA 或浮點浮點單元會處理 64 位長的域,因此 volatile 型別是不需要的。JMM9 旨在通過硬體來識別是否提供原子性保證。

JMM-JSR133 是在 12 年前寫的;處理器的位數在這之後已經取得了很大的進展,64 位已經成為了處理器的主流。JMM-JSR133 對於 64 位的讀和寫採取了明顯的折衷辦法-雖然在任何的架構上,64 位的值可以是原子性的,但是在一些架構上需要獲取鎖。現在,這使得在這些架構上對 64 位值的讀和寫昂貴。 如果在 32 位 x86 架構上不能找到對 64 位原子性操作的合理實施,那麼其原子性將不能改變。

注意:這裡有個語言設計上的潛在問題,“volatile”關鍵字在意思上有歧義。 對於執行環境來說,它很難理解在使用者輸入“volatile”的時候是為了重獲原子性(因此可以擺脫64位平臺),或者是為了記憶體排序的目的。

當談及訪問的原子性,獨立的讀和寫操作是一個重要的考慮。寫操作對一個特定的區域應該不與讀操作或寫操作到任何其他區域相互影響。JMM-JSR133 保證禁止“字撕裂(word-tearing)”的問題。基本上,當對一個運算元做一個更新操作,它比由基礎架構所提供的所有運算元在粒度上還要低,那麼我們就偶遇到了“字撕裂(word-tearing)”。重點是要記住字撕裂(word-tearing)問題產生的原因是64位字長和雙精度沒有給出原子性操作的保證。字撕裂(word-tearing)在 JMM-JSR133 中是被禁止的,並且在 JMM9 中也持續保持了這種方式。

JMM9 – 關鍵的欄位問題

與其他的欄位相比較,關鍵欄位是與眾不同的。舉例來說,一個執行緒讀一個“完全初始化的”物件,它有一個關鍵性的欄位‘x’。物件被“完全初始化”後,是讀取這個關鍵性的欄位值‘y’的保證。但一個“正常”的非關鍵性欄位——‘nonX’是沒有保證的。

注意: “完全初始化” 意味著物件的構造完成。

綜上所述,有一些簡單的東西在 JMM9 中被確定。舉例來說:不穩定的欄位——構造器對一個不穩定的欄位初始化是沒有保證的,即使例項自身是可見的。因此出現一個問題——應該從關鍵欄位的保證擴充套件到所有的欄位,包括不穩定的欄位?因此,如果一個“正常的”完全初始化物件的非關鍵欄位的值沒有改變,我們應該擴充套件關鍵欄位以保證到這個“正常”的欄位。

參考文獻

我從這些網站獲益良多,他們還提供了大量的示例程式碼和執行結果。我應該考慮在文章中新增介紹文章的部分,而下面這些文章很適合深入研究 Java 記憶體模式。

  1. JSR 133: JavaTM Memory Model and Thread Specification Revision
  2. The Java Memory Model
  3. JAVA CONCURRENCY (&C)
  4. The jmm-dev Archives
  5. Threads and Locks
  6. Synchronization and the Java Memory Model
  7. All Accesses Are Atomic
  8. Java Memory Model Pragmatics (transcript)
  9. Memory Barriers: a Hardware View for Software Hackers

特別感謝

我要感謝 Jeremy Manson,是他糾正了我的一些錯誤理解,並且對我剛接觸的東西給了清晰的定義。 還要感謝 Aleksey Shipilev ,在本文起草階段得益於他的幫助減少了一些複雜的概念,Aleksey 還指導我/我們深入理解他關於 Java 記憶體模式程式設計的文章,讓我們很清晰,還帶有示例。

相關文章