天天看點

《Java并發程式設計的藝術》一一3.3 順序一緻性

本節書摘來華章計算機出版社《java并發程式設計的藝術》一書中的第3章,第3.3節,作者:方騰飛 魏鵬 程曉明 更多章節内容可以通路雲栖社群“華章計算機”公衆号檢視。

順序一緻性記憶體模型是一個理論參考模型,在設計的時候,處理器的記憶體模型和程式設計語言的記憶體模型都會以順序一緻性記憶體模型作為參照。

3.3.1 資料競争與順序一緻性

當程式未正确同步時,就可能會存在資料競争。java記憶體模型規範對資料競争的定義

如下。

在一個線程中寫一個變量,

在另一個線程讀同一個變量,

而且寫和讀沒有通過同步來排序。

當代碼中包含資料競争時,程式的執行往往産生違反直覺的結果(前一章的示例正是如此)。如果一個多線程程式能正确同步,這個程式将是一個沒有資料競争的程式。

jmm對正确同步的多線程程式的記憶體一緻性做了如下保證。

如果程式是正确同步的,程式的執行将具有順序一緻性(sequentially consistent)——即程式的執行結果與該程式在順序一緻性記憶體模型中的執行結果相同。馬上我們就會看到,這對于程式員來說是一個極強的保證。這裡的同步是指廣義上的同步,包括對常用同步原語(synchronized、volatile和f?inal)的正确使用。

3.3.2 順序一緻性記憶體模型

順序一緻性記憶體模型是一個被計算機科學家理想化了的理論參考模型,它為程式員提供了極強的記憶體可見性保證。順序一緻性記憶體模型有兩大特性。

1)一個線程中的所有操作必須按照程式的順序來執行。

2)(不管程式是否同步)所有線程都隻能看到一個單一的操作執行順序。在順序一緻性記憶體模型中,每個操作都必須原子執行且立刻對所有線程可見。

順序一緻性記憶體模型為程式員提供的視圖如圖3-10所示。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-10 順序一緻性記憶體模型的視圖

在概念上,順序一緻性模型有一個單一的全局記憶體,這個記憶體通過一個左右擺動的開關可以連接配接到任意一個線程,同時每一個線程必須按照程式的順序來執行記憶體讀/寫操作。從上面的示意圖可以看出,在任意時間點最多隻能有一個線程可以連接配接到記憶體。當多個線程并發執行時,圖中的開關裝置能把所有線程的所有記憶體讀/寫操作串行化(即在順序一緻性模型中,所有操作之間具有全序關系)。

為了更好進行了解,下面通過兩個示意圖來對順序一緻性模型的特性做進一步的說明。

假設有兩個線程a和b并發執行。其中a線程有3個操作,它們在程式中的順序是:a1→a2→a3。b線程也有3個操作,它們在程式中的順序是:b1→b2→b3。

假設這兩個線程使用螢幕鎖來正确同步:a線程的3個操作執行後釋放螢幕鎖,随後b線程擷取同一個螢幕鎖。那麼程式在順序一緻性模型中的執行效果将如圖3-11所示。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-11 順序一緻性模型的一種執行效果

現在我們再假設這兩個線程沒有做同步,下面是這個未同步程式在順序一緻性模型中的執行示意圖,如圖3-12所示。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-12 順序一緻性模型中的另一種執行效果

未同步程式在順序一緻性模型中雖然整體執行順序是無序的,但所有線程都隻能看到一個一緻的整體執行順序。以上圖為例,線程a和b看到的執行順序都是:b1→a1→a2→b2→a3→b3。之是以能得到這個保證是因為順序一緻性記憶體模型中的每個操作必須立即對任意線程可見。

但是,在jmm中就沒有這個保證。未同步程式在jmm中不但整體的執行順序是無序的,而且所有線程看到的操作執行順序也可能不一緻。比如,在目前線程把寫過的資料緩存在本地記憶體中,在沒有重新整理到主記憶體之前,這個寫操作僅對目前線程可見;從其他線程的角度來觀察,會認為這個寫操作根本沒有被目前線程執行。隻有目前線程把本地記憶體中寫過的資料重新整理到主記憶體之後,這個寫操作才能對其他線程可見。在這種情況下,目前線程和其他線程看到的操作執行順序将不一緻。

3.3.3 同步程式的順序一緻性效果

下面,對前面的示例程式reorderexample用鎖來同步,看看正确同步的程式如何具有順序一緻性。

請看下面的示例代碼。

在上面示例代碼中,假設a線程執行writer()方法後,b線程執行reader()方法。這是一個正确同步的多線程程式。根據jmm規範,該程式的執行結果将與該程式在順序一緻性模型中的執行結果相同。下面是該程式在兩個記憶體模型中的執行時序對比圖,如圖3-13

所示。

順序一緻性模型中,所有操作完全按程式的順序串行執行。而在jmm中,臨界區内的代碼可以重排序(但jmm不允許臨界區内的代碼“逸出”到臨界區之外,那樣會破壞螢幕的語義)。jmm會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特别處理,使得線程在這兩個時間點具有與順序一緻性模型相同的記憶體視圖(具體細節後文會說明)。雖然線程a在臨界區内做了重排序,但由于螢幕互斥執行的特性,這裡的線程b根本無法“觀察”到線程a在臨界區内的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行

結果。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-13 兩個記憶體模型中的執行時序對比圖

從這裡我們可以看到,jmm在具體實作上的基本方針為:在不改變(正确同步的)程式執行結果的前提下,盡可能地為編譯器和處理器的優化打開友善之門。

3.3.4 未同步程式的執行特性

對于未同步或未正确同步的多線程程式,jmm隻提供最小安全性:線程執行時讀取到的值,要麼是之前某個線程寫入的值,要麼是預設值(0,null,false),jmm保證線程讀操作讀取到的值不會無中生有(out of thin air)的冒出來。為了實作最小安全性,jvm在堆上配置設定對象時,首先會對記憶體空間進行清零,然後才會在上面配置設定對象(jvm内部會同步這兩個操作)。是以,在已清零的記憶體空間(pre-zeroed memory)配置設定對象時,域的預設初始化已經完成了。

jmm不保證未同步程式的執行結果與該程式在順序一緻性模型中的執行結果一緻。因為如果想要保證執行結果一緻,jmm需要禁止大量的處理器和編譯器的優化,這對程式的執行性能會産生很大的影響。而且未同步程式在順序一緻性模型中執行時,整體是無序的,其執行結果往往無法預知。而且,保證未同步程式在這兩個模型中的執行結果一緻沒什麼

意義。

未同步程式在jmm中的執行時,整體上是無序的,其執行結果無法預知。未同步程式在兩個模型中的執行特性有如下幾個差異。

1)順序一緻性模型保證單線程内的操作會按程式的順序執行,而jmm不保證單線程内的操作會按程式的順序執行(比如上面正确同步的多線程程式在臨界區内的重排序)。這一點前面已經講過了,這裡就不再贅述。

2)順序一緻性模型保證所有線程隻能看到一緻的操作執行順序,而jmm不保證所有線程能看到一緻的操作執行順序。這一點前面也已經講過,這裡就不再贅述。

3)jmm不保證對64位的long型和double型變量的寫操作具有原子性,而順序一緻性模型保證對所有的記憶體讀/寫操作都具有原子性。

第3個差異與處理器總線的工作機制密切相關。在計算機中,資料通過總線在處理器和記憶體之間傳遞。每次處理器和記憶體之間的資料傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為總線事務(bus transaction)。總線事務包括讀事務(read transaction)和寫事務(write transaction)。讀事務從記憶體傳送資料到處理器,寫事務從處理器傳送資料到記憶體,每個事務會讀/寫記憶體中一個或多個實體上連續的字。這裡的關鍵是,總線會同步試圖并發使用總線的事務。在一個處理器執行總線事務期間,總線會禁止其他的處理器和i/o裝置執行記憶體的讀/寫。下面,讓我們通過一個示意圖來說明總線的工作機制,如圖3-14所示。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-14 總線的工作機制

由圖可知,假設處理器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位變量的寫操作将不具有原子性。

當單個記憶體操作不具有原子性時,可能會産生意想不到後果。請看示意圖,如圖3-15所示。

《Java并發程式設計的藝術》一一3.3 順序一緻性

圖3-15 總線事務執行的時序圖

如上圖所示,假設處理器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中都必須具有原子性(即任意讀操作必須要在單個讀事務中執行)。