當程序未正確同步時,就會存在數據競爭。java 內存模型規范對數據競爭的定義如下:
當代碼中包含數據競爭時,程序的執行往往產生違反直覺的結果(前一章的示例正是如此)。如果一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。
JMM 對正確同步的多線程程序的內存一致性做了如下保證:
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它為程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:
順序一致性內存模型為程序員提供的視圖如下:
在概念上,順序一致性模型有一個單一的全局內存,這個內存通過一個左右擺動的開關可以連接到任意一個線程。同時,每一個線程必須按程序的順序來執行內存讀/寫操作。從上圖我們可以看出,在任意時間點最多只能有一個線程可以連接到內存。當多個線程並發執行時,圖中的開關裝置能把所有線程的所有內存讀/寫操作串行化。
為了更好的理解,下面我們通過兩個示意圖來對順序一致性模型的特性做進一步的說明。
假設有兩個線程A和B並發執行。其中 A 線程有三個操作,它們在程序中的順序是:A1->A2->A3。B線程也有三個操作,它們在程序中的順序是:B1->B2->B3。
假設這兩個線程使用監視器來正確同步:A 線程的三個操作執行後釋放監視器,隨後 B 線程獲取同一個監視器。那麼程序在順序一致性模型中的執行效果將如下圖所示:
現在我們再假設這兩個線程沒有做同步,下面是這個未同步程序在順序一致性模型中的執行示意圖:
未同步程序在順序一致性模型中雖然整體執行順序是無序的,但所有線程都只能看到一個一致的整體執行順序。以上圖為例,線程 A 和 B 看到的執行順序都是:B1->A1->A2->B2->A3->B3。之所以能得到這個保證是因為順序一致性內存模型中的每個操作必須立即對任意線程可見。
但是,在 JMM 中就沒有這個保證。未同步程序在 JMM 中不但整體的執行順序是無序的,而且所有線程看到的操作執行順序也可能不一致。比如,在當前線程把寫過的數據緩存在本地內存中,且還沒有刷新到主內存之前,這個寫操作僅對當前線程可見;從其他線程的角度來觀察,會認為這個寫操作根本還沒有被當前線程執行。只有當前線程把本地內存中寫過的數據刷新到主內存之後,這個寫操作才能對其他線程可見。在這種情況下,當前線程和其它線程看到的操作執行順序將不一致。
下面我們對前面的示例程序 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會在退出監視器和進入監視器這兩個關鍵時間點做一些特別處理,使得線程在這兩個時間點具有與順序一致性模型相同的內存視圖(具體細節後文會說明)。雖然線程 A 在臨界區內做了重排序,但由於監視器的互斥執行的特性,這裡的線程 B 根本無法“觀察”到線程 A 在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程序的執行結果。
從這裡我們可以看到 JMM 在具體實現上的基本方針:在不改變(正確同步的)程序執行結果的前提下,盡可能的為編譯器和處理器的優化打開方便之門。
對於未同步或未正確同步的多線程程序,JMM 只提供最小安全性:線程執行時讀取到的值,要麼是之前某個線程寫入的值,要麼是默認值(0,null,false),JMM 保證線程讀操作讀取到的值不會無中生有(out of thin air)的冒出來。為了實現最小安全性,JVM 在堆上分配對象時,首先會清零內存空間,然後才會在上面分配對象(JVM內部會同步這兩個操作)。因此,在以清零的內存空間(pre-zeroed memory)分配對象時,域的默認初始化已經完成了。
JMM 不保證未同步程序的執行結果與該程序在順序一致性模型中的執行結果一致。因為未同步程序在順序一致性模型中執行時,整體上是無序的,其執行結果無法預知。保證未同步程序在兩個模型中的執行結果一致毫無意義。
和順序一致性模型一樣,未同步程序在 JMM 中的執行時,整體上也是無序的,其執行結果也無法預知。同時,未同步程序在這兩個模型中的執行特性有下面幾個差異:
第3個差異與處理器總線的工作機制密切相關。在計算機中,數據通過總線在處理器和內存之間傳遞。每次處理器和內存之間的數據傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為總線事務(bus transaction)。總線事務包括讀事務(read transaction)和寫事務(write transaction)。讀事務從內存傳送數據到處理器,寫事務從處理器傳送數據到內存,每個事務會讀/寫內存中一個或多個物理上連續的字。這裡的關鍵是,總線會同步試圖並發使用總線的事務。在一個處理器執行總線事務期間,總線會禁止其它所有的處理器和 I/O 設備執行內存的讀/寫。下面讓我們通過一個示意圖來說明總線的工作機制:
如上圖所示,假設處理器 A,B 和 C 同時向總線發起總線事務,這時總線仲裁(bus arbitration)會對競爭作出裁決,這裡我們假設總線在仲裁後判定處理器A在競爭中獲勝(總線仲裁會確保所有處理器都能公平的訪問內存)。此時處理器 A 繼續它的總線事務,而其它兩個處理器則要等待處理器A的總線事務完成後才能開始再次執行內存訪問。假設在處理器 A 執行總線事務期間(不管這個總線事務是讀事務還是寫事務),處理器D向總線發起了總線事務,此時處理器 D 的這個請求會被總線禁止。
總線的這些工作機制可以把所有處理器對內存的訪問以串行化的方式來執行;在任意時間點,最多只能有一個處理器能訪問內存。這個特性確保了單個總線事務之中的內存讀/寫操作具有原子性。
在一些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 中都必須具有原子性(即任意讀操作必須要在單個讀事務中執行)。