數據競爭與順序一致性保證
當程序未正確同步時,就會存在數據競爭。java內存模型規范對數 據競爭的定義如下:
在一個線程中寫一個變量,
在另一個線程讀同一個變量,
而且寫和讀沒有通過同步來排序。
當代碼中包含數據競爭時,程序的執行往往產生違反直覺的結果(前一章的示例正是如此)。如果一 個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。
JMM對正確同步的多線程程序 的內存一致性做了如下保證:
如果程序是正確同步的,程序的執行將具有順序一致性(sequentially consistent)--即程序的執行 結果與該程序在順序一致性內存模型中的執行結果相同(馬上我們將會看到,這對於程序員來說是一個極 強的保證)。這裡的同步是指廣義上的同步,包括對常用同步原語(lock,volatile和final)的正確使 用。
順序一致性內存模型
順序一致性內存模型是一個被計算機科學家理想化了的理論參考模型,它 為程序員提供了極強的內存可見性保證。順序一致性內存模型有兩大特性:
一個線程中的所有操作必須按照程序的順序來執行。
(不管程序是否同步)所有線程都只能看到一個單一的操作執行順序。在順序一致性內存模型中,每 個操作都必須原子執行且立刻對所有線程可見。
順序一致性內存模型為程序員提供的視圖如下:
在概念上,順序一致性模型有一個單一的全局內存,這個內存通過一個左右擺動的開關可以連 接到任意一個線程。同時,每一個線程必須按程序的順序來執行內存讀/寫操作。從上圖我們可以看出, 在任意時間點最多只能有一個線程可以連接到內存。當多個線程並發執行時,圖中的開關裝置能把所有線 程的所有內存讀/寫操作串行化。
為了更好的理解,下面我們通過兩個示意圖來對順序一致性模型 的特性做進一步的說明。
假設有兩個線程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中的執行時,整體上也是無序的,其執行結果也無法預知。同時,未同步 程序在這兩個模型中的執行特性有下面幾個差異:
順序一致性模型保證單線程內的操作會按程序的順序執行,而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的這個請求會被總線禁止。
總線的這些工作機制可以把所有處理器對內存的訪問以串行化的方式來執行;在任意時間點,最 多只能有一個處理器能訪問內存。這個特性確保了單個總線事務之中的內存讀/寫操作具有原子性。
在一些32位的處理器上,如果要求對64位數據的讀/寫操作具有原子性,會有比較大的開銷。為了 照顧這種處理器,java語言規范鼓勵但不強求JVM對64位的long型變量和double型變量的讀/寫具有原子性 。當JVM在這種處理器上運行時,會把一個64位long/ double型變量的讀/寫操作拆分為兩個32位的讀/寫 操作來執行。這兩個32位的讀/寫操作可能會被分配到不同的總線事務中執行,此時對這個64位變量的讀/ 寫將不具有原子性。
當單個內存操作不具有原子性,將可能會產生意想不到後果。請看下面示意 圖:
如上圖所示,假設處理器A寫一個long型變量,同時處理器B要讀這個long型變量 。處理器A中64位的寫操作被拆分為兩個32位的寫操作,且這兩個32位的寫操作被分配到不同的寫事務中 執行。同時處理器B中64位的讀操作被拆分為兩個32位的讀操作,且這兩個32位的讀操作被分配到同一個 的讀事務中執行。當處理器A和B按上圖的時序來執行時,處理器B將看到僅僅被處理器A“寫了一半“的無 效值。