天天看點

Java記憶體模型小析之重排序(三)

重排序是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段。也就是說重排序的目的是提高程式的執行性能。

編譯器在不改變單線程程式執行結果的前提下,可以重新安排語句的執行順序。這裡需要注意的是:不改變單線程程式的語義(as-if-serial)。  

現代處理器采用了指令級并行技術(ILP)來将多條指令重疊執行。在單線程和單處理器中,如果兩個操作之間不存在資料依賴,處理器可以改變語句對應機器指令的執行順序。

由于處理器使用緩存和讀/寫緩沖區,處理器會重排對記憶體的讀/寫操作的執行順序。

是以從java源代碼到最終實際執行的指令序列,會分别經曆下面三種重排序 :

    源代碼---->編譯器優化重排序---->指令級并行重排序---->記憶體系統重排序---->最終執行的指令序列

我們在指令級并行的重排序中說如果兩個操作之間沒有資料依賴,處理器會進行指令的重排序。那麼什麼是資料依賴呢?如果兩個操作通路同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。這裡的資料依賴僅針對單個處理器中執行的指令序列和單個線程中執行的操作。資料依賴分為下列三種類型:

類型

代碼示例

說明

寫後讀

a=1;

b=a;

寫一個變量之後,再讀這個變量

寫後寫

a=1; 

a=2;

寫一個變量之後,再寫這個變量

a=b;

b=1;

度一個變量之後,再寫這個變量

在按序執行中,一旦遇到指令依賴的情況,流水線就會停滞(因為CPU從主存加載讀取資料是一個很慢(相對于CPU的處理速度來說)很複雜的IO操作,但是CPU層面實作了異步IO,通過異步IO的方式讀取記憶體資料。),為了讓CPU一直處于工作狀态,把時間浪費減到最小,CPU就會進行重排序,跳到下一個非依賴指令。如a=b;c=1;d=1;如果b不在緩存行裡,需要從主存加載,這就是一個指令依賴。CPU可以對此進行重排序,先讀c=1;或者d=1;。

重排序會引起多線程的可見性問題。

下面我們詳細的說明一下重排序會引起的多線程的可見性問題。

大家先看這樣的一段代碼:

如果以server模式運作上面的代碼的話,程式的執行結果可能和大家以為的不太一樣。有人可能會認為程式在調用stopFlag()方法之後,就會停止循環,然後輸出finish loop i:。但是程式的執行結果确實可能會陷入到死循環中。這裡會出現死循環的原因是:程式在進入Visibility的run方法之前,先讀取到了flag的值,然後在while循環中不再讀取flag的值了,即使後續對flag的值進行了修改,run方法中也不會再讀取flag的值。相當于

 run{ int i ; while(!false){  循環 } }。編譯器層面進行了重排序。這裡想讓程式停下來也很簡單,隻需要用volatile修改變量即可。

這裡需要說明一下的是:我們在安裝64位JDK時,一般都是server模式運作程式的(預設)。32位不支援server模式。server模式與client模式的差別是:server模式啟動時,速度較慢,但是一旦運作起來後,性能将會有很大的提升.原因是。當虛拟機運作在-client模式的時候,使用的是一個代号為C1的輕量級編譯器, 而-server模式啟動的虛拟機采用相對重量級,代号為C2的編譯器,這個編譯器對代碼做了很多的優化。可以通過java -version這個指令來檢視對應的模式。上面是client模式,下面是server模式。

Java記憶體模型小析之重排序(三)
Java記憶體模型小析之重排序(三)

我們再看一個CPU重排序的例子

上面的程式可能會出現如下所示的結果(說明:x:1 y:1這個結果我沒有跑出來,但是有可能會存在這一的結果。以下程式的結果,都是本人親自執行程式測試出來的結果):

Java記憶體模型小析之重排序(三)
Java記憶體模型小析之重排序(三)
Java記憶體模型小析之重排序(三)

對于x:0 y:0這個結果大家可能會非常奇怪。程式執行時CPU和記憶體的互動如下圖所示:

Java記憶體模型小析之重排序(三)

當處理器A和處理器B把各自的結果寫入緩沖區(A1 B1),然後從主存中讀取另外的共享變量的值,注意這時處理器A和處理器B寫入的值還在寫入緩沖區,還沒重新整理到主存中去,是以這時從主存中讀取到的共享變量a和b的值還是0,即text.x和test.y的值是0。最後處理器A和處理器B把自己寫入緩沖區中的資料重新整理到主存中去。注意這裡發生了記憶體系統的重排序。按照程式發生的順序應該是A3把A1寫緩存區中的值重新整理到主存中a的變量寫入才算數成功了。即應該是A1-A3-A2。但是實際發生的順序可能是A1-A2-A3或者是A2-A1-A3(因為這裡沒有資料依賴關系,可能會發生重排序)。

下面我們再看最後一個例子關于指令級重排序的:

一條指令的執行是可以分為很多步驟的,下面列了一下主要的步驟:

取值 IF

譯碼和取寄存器操作數 ID

執行或者有效位址計算 EX

存儲器通路 MEM

寫回 WB  

即,指令的執行順序為:IF ID EX MEM WB。下面我們看一下 A=B+C的操作的指令序列:

Java記憶體模型小析之重排序(三)

上面我們說過一條指令的順序為 IF ID EX MEM WB那當我們有兩條指令的時候是不是先等第一條指令順序執行完的時候,才開始執行第二條?很明顯不是的。指令的執行就如同流水線,當我們第一個指令執行完IF的時候,我們就可以跟着執行第二條指令的IF了如上圖所示。但是當執行到ADD這一行的時候我們發現ID和EX中間多了一個X,這個X代表的時候一個停頓。為什麼這裡會有一個停頓呢?因為這時要進行一個計算的操作,而第二條指令還沒有從記憶體中讀出來值。為什麼第二條指令還沒有寫回到寄存器中,就可以進行EX呢?在硬體電路中處理資料沖突的時候會使用一種旁路的技術,直接把資料從硬體中讀取出來,是以不用等第二條指令完全執行完,就可以進行計算了。我們再看一個複雜的例子:

Java記憶體模型小析之重排序(三)

在上圖中我們看到很多X存在,那麼有沒有辦法盡可能的減少這些X呢?答案是肯定的,指令重排。我們看一下重排之後的結果:

Java記憶體模型小析之重排序(三)
Java記憶體模型小析之重排序(三)

從上圖中我們發現沒有了X的存在,程式的性能得到了一定的提升,并且在單線程和單處理器中程式的執行結果不會發生變化。

既然重排序會導緻的不可見的問題,那麼能不能禁止特定類型的重排序呢?答案是:能。通過什麼方式呢?就是我們下面要說的栅欄或者記憶體屏障。

簡單了解:記憶體屏障(Memory Barrier / Memory Fence)是一種CPU指令,用于控制特定條件下的重排序和記憶體可見性問題

java編譯器在生成指令序列時插入特定類型的記憶體屏障(Memory Barriers/Memory Fence),可以禁止特定類型的重排序。

記憶體屏障可以分為下面四類:

Java記憶體模型小析之重排序(三)

StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果,現代的多處理器大多支援該屏障 執行該屏障開銷會很昂貴。因為目前處理器通常要把寫緩沖區中的資料全部重新整理到記憶體中。在volatile的時候我們在詳細說這個。

as-if-serial語義的意思是:不管怎麼重排序,在單線程中程式的執行結果不能被改變。  

   編譯器、runtime和處理器都必須遵守as-if-serial語義。

什麼是控制依賴性? 像:if(條件){ 執行代碼。。。 }這樣的代碼之間存在控制依賴性。  

    當代碼中存在控制依賴性時,會影響指令序列執行的并行度。為此,編譯器和處理器會采用猜測(Speculation)執行來克服 控制依賴對并行度的影響。   

    在單線程程式中,對存在控制依賴的操作重排序不會改變執行結果;但在多線程程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。  

參考:

java并發程式設計的藝術。

葛一鳴的相關資料。