記憶體模型之順序一致性
前言
順序一致性是程式執行過程中可見性和順序的強有力保證。在順序一致的執行過程中,所有動作(如讀和寫)間存在一個全序關係,與程式的順序一致。每個動作都是原子的且立即對所有執行緒可見。如果一個程式沒有資料爭用,那麼該程式的執行看起來將是順序一致的。如前面所提到的,在一組操作要保持原子性而未得到保證時,即使有順序一致性和/或未遭遇資料爭用,仍然可能會出現錯 誤。
1、資料競爭和資料一致性保證
當程式未正確同步時,就可能會存在資料競爭。java 記憶體模型規範對資料競爭的定 義如下:
- 在一個執行緒中寫一個變數
- 在另一個執行緒讀同一個變數
- 而且寫和讀沒有通過同步來排序
當代碼中包含資料競爭時,程式的執行往往產生違反直覺的結果。如果一個多執行緒程式能正確同步,這個程式將是一個沒有資料競爭的程 序。
JMM 對正確同步的多執行緒程式的記憶體一致性做了如下保證:
- 如果程式是正確同步的,程式的執行將具有順序一致性(sequentially consistent)--即
程式的執行結果與該程式在順序一致性記憶體模型中的執行結 果相同。馬上我們將會看到,這對於程式設計師來說是一個極強的保證。這裡的同 步是指廣義上的同步,包括對常用同步原語(synchronized,volatile 和 final) 的正確使用。
2、順序一致性記憶體模型
順序一致性記憶體模型是一個被電腦科學家理想化了的理論參考模型,它為程式設計師 提供了極強的記憶體可見性保證。順序一致性記憶體模型有兩大特性:
- 一個執行緒中的所有操作必須按照程式的順序來執行。
- (不管程式是否同步)所有執行緒都只能看到一個單一的操作執行順序。
在順序 一致性記憶體模型中,每個操作都必須原子執行且立刻對所有執行緒可見。 順序一致性記憶體模型為程式設計師提供的檢視如下:
在概念上,順序一致性模型有一個單一的全域性記憶體,這個記憶體通過一個左右擺動的 開關可以連線到任意一個執行緒,同時每一個執行緒必須按照程式的順序來執行記憶體讀 /寫操作。從上面的示意圖我們可以看出,在任意時間點最多隻能有一個執行緒可以 連線到記憶體。當多個執行緒併發執行時,圖中的開關裝置能把所有執行緒的所有記憶體讀 /寫操作序列化(即在順序一致性模型中,所有操作之間具有全序關係)。
為了更好的理解,下面我們通過兩個示意圖來對順序一致性模型的特性做進一步的 說明。
假設有兩個執行緒 A 和 B 併發執行。其中 A 執行緒有三個操作,它們在程式中的順序 是:A1->A2->A3。B 執行緒也有三個操作,它們在程式中的順序是:B1->B2- >B3。
假設這兩個執行緒使用監視器鎖來正確同步:A 執行緒的三個操作執行後釋放監視器 鎖,隨後 B 執行緒獲取同一個監視器鎖。那麼程式在順序一致性模型中的執行效果將 如下圖所示:
現在我們再假設這兩個執行緒沒有做同步,下面是這個未同步程式在順序一致性模型 中的執行示意圖:
未同步程式在順序一致性模型中雖然整體執行順序是無序的,但所有執行緒都只能看 到一個一致的整體執行順序。以上圖為例,執行緒 A 和 B 看到的執行順序都是:B1- >A1->A2->B2->A3->B3。之所以能得到這個保證是因為順序一致性記憶體模型中 的每個操作必須立即對任意執行緒可見。 但是,在 JMM 中就沒有這個保證。
未同步程式在 JMM 中不但整體的執行順序是 無序的,而且所有執行緒看到的操作執行順序也可能不一致。比如,在當前執行緒把寫 過的資料快取在本地記憶體中,在還沒有重新整理到主記憶體之前,這個寫操作僅對當前線 程可見;從其他執行緒的角度來觀察,會認為這個寫操作根本還沒有被當前執行緒執 行。只有當前執行緒把本地記憶體中寫過的資料重新整理到主記憶體之後,這個寫操作才能對 其他執行緒可見。在這種情況下,當前執行緒和其它執行緒看到的操作執行順序將不一致。
3、同步程式的順序一致性結果
下面我們對前面的示例程式 ReorderExample 用鎖來同步,看看正確同步的程式如 何具有順序一致性。
class SynchronizedExample {
int a = 0;
boolean flag = false;
public synchronized void writer() { //獲取鎖
a = 1;
flag = true;
} //釋放鎖
public synchronized void reader() { //獲取鎖
if (flag) {
int i = a;
} //釋放鎖
}
}複製程式碼
上面示例程式碼中,假設 A 執行緒執行 writer()方法後,B 執行緒執行 reader()方法。這 是一個正確同步的多執行緒程式。根據 JMM 規範,該程式的執行結果將與該程式在 順序一致性模型中的執行結果相同。下面是該程式在兩個記憶體模型中的執行時序對 比圖:
在順序一致性模型中,所有操作完全按程式的順序序列執行。而在 JMM 中,臨界 區內的程式碼可以重排序(但 JMM 不允許臨界區內的程式碼“逸出”到臨界區之外, 那樣會破壞監視器的語義)。JMM 會在退出臨界區和進入臨界區這兩個關鍵時間 點做一些特別處理,使得執行緒在這兩個時間點具有與順序一致性模型相同的記憶體視 按程 序順 序執 行 按程 序順 序執 行 臨界 區內 可以 重排 序 臨界區 內可以 重排序 在 JMM 中的執 行 在順序一 致性模型 中的執行 時間 flag = true; if (flag) int i = a; a = 1; A 獲取鎖 A 釋放鎖 B 獲取鎖 B 釋放鎖 A 獲取鎖 flag = true; a = 1; A 釋放鎖 B 獲取鎖 if (flag) int i = a; B 釋放鎖 圖(具體細節後文會說明)。雖然執行緒 A 在臨界區內做了重排序,但由於監視器的 互斥執行的特性,這裡的執行緒 B 根本無法“觀察”到執行緒 A 在臨界區內的重排序。 這種重排序既提高了執行效率,又沒有改變程式的執行結果。
從這裡我們可以看到 JMM 在具體實現上的基本方針:在不改變(正確同步的)程 序執行結果的前提下,儘可能的為編譯器和處理器的優化開啟方便之門。
4、未同步程式的執行
對於未同步或未正確同步的多執行緒程式,JMM 只提供最小安全性:執行緒執行時讀 取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值(0,null,false), JMM 保證執行緒讀操作讀取到的值不會無中生有(out of thin air)的冒出來。為了 實現最小安全性,JVM 在堆上分配物件時,首先會清零記憶體空間,然後才會在上面 分配物件(JVM 內部會同步這兩個操作)。因此,在已清零的記憶體空間(prezeroed memory)分配物件時,域的預設初始化已經完成了。
JMM 不保證未同步程式的執行結果與該程式在順序一致性模型中的執行結果一 致。因為如果想要保證執行結果一致,JMM 需要禁止大量的處理器和編譯器的優 化,這對程式的執行效能會產生很大的影響。而且未同步程式在順序一致性模型中 執行時,整體是無序的,其執行結果往往無法預知。保證未同步程式在這兩個模型 中的執行結果一致沒什麼意義。
未同步程式在 JMM 中的執行時,整體上是無序的,其執行結果無法預知。未同步 程式在兩個模型中的執行特性有下面幾個差異:
- 順序一致性模型保證單執行緒內的操作會按程式的順序執行,而 JMM 不保證單 執行緒內的操作會按程式的順序執行(比如上面正確同步的多執行緒程式在臨界區 內的重排序)。這一點前面已經講過了,這裡就不再贅述。
- 順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而 JMM 不保證 所有執行緒能看到一致的操作執行順序。這一點前面也已經講過,這裡就不再贅 述。
- JMM 不保證對 64 位的 long 型和 double 型變數的讀/寫操作具有原子性,而 順序一致性模型保證對所有的記憶體讀/寫操作都具有原子性。
第 3 個差異與處理器匯流排的工作機制密切相關。在計算機中,資料通過匯流排在處理 器和記憶體之間傳遞。每次處理器和記憶體之間的資料傳遞都是通過一系列步驟來完成 的,這一系列步驟稱之為匯流排事務(bus transaction)。匯流排事務包括讀事務 (read transaction)和寫事務(write transaction)。讀事務從記憶體傳送資料到 處理器,寫事務從處理器傳送資料到記憶體,每個事務會讀/寫記憶體中一個或多個物 理上連續的字。這裡的關鍵是,匯流排會同步試圖併發使用匯流排的事務。在一個處理 器執行匯流排事務期間,匯流排會禁止其它所有的處理器和 I/O 裝置執行記憶體的讀/ 寫。下面讓我們通過一個示意圖來說明匯流排的工作機制:
如上圖所示,假設處理器 A,B 和 C 同時向匯流排發起匯流排事務,這時匯流排仲裁(bus arbitration)會對競爭作出裁決,這裡我們假設匯流排在仲裁後判定處理器 A 在競爭中 獲勝(匯流排仲裁會確保所有處理器都能公平的訪問記憶體)。此時處理器 A 繼續它的 匯流排事務,而其它兩個處理器則要等待處理器 A 的匯流排事務完成後才能開始再次執 行記憶體訪問。假設在處理器 A 執行匯流排事務期間(不管這個匯流排事務是讀事務還是 寫事務),處理器D 匯流排發起了匯流排事務,此時處理器D的這個請求會被匯流排處理器A處理器C匯流排記憶體處理器B處理器D記憶體訪問A記憶體訪問B記憶體訪問C記憶體訪問D記憶體訪問A 禁止。
匯流排的這些工作機制可以把所有處理器對記憶體的訪問以序列化的方式來執行;在任 意時間點,最多隻能有一個處理器能訪問記憶體。這個特性確保了單個匯流排事務之中 的記憶體讀/寫操作具有原子性。 在一些 32 位的處理器上,如果要求對 64 位資料的寫操作具有原子性,會有比較大 的開銷。為了照顧這種處理器,java 語言規範鼓勵但不強求 JVM 對 64 位的 long 型變數和 double 型變數的寫具有原子性。當 JVM 在這種處理器上執行時,會把 一個 64 位 long/ double 型變數的寫操作拆分為兩個 32 位的寫操作來執行。這兩 個 32 位的寫操作可能會被分配到不同的匯流排事務中執行,此時對這個 64 位變數的 寫將不具有原子性。
當單個記憶體操作不具有原子性,將可能會產生意想不到後果。請看下面示意圖:
如上圖所示,假設處理器 A 寫一個 long 型變數,同時處理器 B 要讀這個 long 型 變數。處理器 A 中 64 位的寫操作被拆分為兩個 32 位的寫操作,且這兩個 32 位的 寫操作被分配到不同的寫事務中執行。同時處理器 B 中 64 位的讀操作被分配到單 個的讀事務中執行。當處理器 A 和 B 按上圖的時序來執行時,處理器 B 將看到僅 僅被處理器 A“寫了一半“的無效值。
注意,在 JSR -133 之前的舊記憶體模型中,一個 64 位 long/ double 型變數的讀/ 寫操作可以被拆分為兩個 32 位的讀/寫操作來執行。從 JSR -133 記憶體模型開始 (即從 JDK5 開始),僅僅只允許把一個 64 位 long/ double 型變數的寫操作拆分 為兩個 32 位的寫操作來執行,任意的讀操作在 JSR -133 中都必須具有原子性(即 任意讀操作必須要在單個讀事務中執行)。