標籤:同步 啟動 ash 指令重排序 pen 這一 特性 技術分享 java記憶體
一、概念理解
首先我們先來瞭解一下什麼是重排序:重排序是指編譯器和處理器為了最佳化程式效能而對指令序列進行重新排序的一種手段。
從Java原始碼到最終實際執行的指令序列,會分別經曆下面3種重排序,如所示
上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會導致多線程程式出現記憶體可見度問題。在單線程程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語義允許對存在控制依賴的操作做重排序的原因);但在多線程程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。
1)資料依賴性(針對單個處理器而已)
關於重排序,這裡要先講一個概念就是資料依賴性問題。如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分為下列3種類型,如下表所示。
上面3種情況,只要重排序兩個操作的執行順序,程式的執行結果就會被改變。前面提到過,編譯器和處理器可能會對操作做重排序。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序。這裡所說的資料依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的資料依賴性不被編譯器和處理器考慮。
2)as-if-serial語義
as-if-serial語義的意思是:不管怎麼重排序,(單線程)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序。as-if-serial語義把單線程程式保護了起來,as-if-serial語義使單線程程式員無需擔心重排序會干擾他們,也無需擔心記憶體可見度問題。
3)happens-before
如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。這裡提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。
對happens-before關係的具體定義如下。
① 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
②兩個操作之間存在happens-before關係,並不意味著Java平台的具體實現必須要按照 happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM允許這種重排序)。
上面的①是JMM對程式員的承諾。從程式員的角度來說,可以這樣理解happens-before關係:如果A happens-before B,那麼Java記憶體模型將向程式員保證——A操作的結果將對B可見,且A的執行順序排在B之前。注意,這隻是Java記憶體模型向程式員做出的保證!上面的②是JMM對編譯器和處理器重排序的約束原則。正如前面所言,其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單線程程式和正確同步的多線程程式),編譯器和處理器怎麼最佳化都行。因此,happens-before關係本質上和as-if-serial語義是一回事。
·as-if-serial語義保證單線程內程式的執行結果不被改變,happens-before關係保證正確同步的多線程程式的執行結果不被改變。
·as-if-serial語義給編寫單線程程式的程式員創造了一個幻境:單線程程式是按程式的順序來執行的。happens-before關係給編寫正確同步的多線程程式的程式員創造了一個幻境:正確同步的多線程程式是按happens-before指定的順序來執行的。
as-if-serial語義和happens-before這麼做的目的,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。
happens-before規則如下:
程式順序規則:一個線程中的每個操作,happens-before於該線程中的任意後續操作。
監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
start()規則:如果線程A執行操作ThreadB.start()(啟動線程B),那麼A線程的ThreadB.start()操作happens-before於線程B中的任意操作。
join()規則:如果線程A執行操作ThreadB.join()並成功返回,那麼線程B中的任意操作happens-before於線程A從ThreadB.join()操作成功返回。
二、例子分析
假設有兩個線程分別調用同一個test對象的writer()和reader()。請問,b的值是什嗎?
(a) 1
(b) 2
(c) 1 or 2
public class test{ private boolean flag = false; private int a = 0; public void writer(){ a = 1; flag = True; } public void reader(){ if (flag){ b = a + 1 } }}
這裡主要涉及的是處理器重排序問題。當前處理器為了加速指令執行,會將部分指令重排序之後執行。
資料依賴
資料依賴是一個簡單的概念,就是判斷前後兩行代碼在資料上有否有依賴關係。例如:
num1 = 1 // (a)num2 = 2 // (b)result = num1 + num2 // (c)
顯然,c 語句用到的 num1 和 num2 依賴 a 和 b。
資料依賴分三種:
- 1 store - load
- 2 load - store
- 3 store - store
如何判斷是否有依賴,很簡單,只用判斷兩個語句之間是否用到同一個變數,是否是寫操作。
Happen before
JVM定義了一個概念叫做 happen before,意思是前一條執行的結果要對後一條執行可見。簡單來說前一條執行完,才能執行後一條。但實際上為了提高處理速度,JVM弱化了這個概念,在有資料依賴的情況下,前一條執行完,才能執行後一條。
看下面的例子:
num1 = 1 // (a)num2 = 2 // (b)result = num1 + num2 // (c)
對於上述三條語句 a, b, c執行,單線程順序執行的情況。
a happen before b b happen before c。
根據傳遞性可以得出:
a happen before c
c指令要用到的 num1 和 num2 顯然是依賴 a 和 b 的,典型的store-load。所以c指令必須等到 a 和 b 執行完才能執行。然而 a 和 b 並沒有資料依賴,於是 JVM 允許處理器對 a 和 b 進行重排序。
a -> b -> c = 3b -> a -> c = 3
那麼happen before到底是什嗎?我的理解是happen before是JVM對底層記憶體控制抽象出一層概念。我們可以根據代碼順序來判斷happen before的關係,而JVM底層會根據實際情況執行不同的 action (例如添加記憶體屏障,處理器屏障,阻止重排序又或者是不做任何額外操作,允許處理器沖排序)。通過這一層使得記憶體控制對程式員透明,程式員也不需要考慮代碼實際執行情況,JVM會保證單線程執行成功,as-if-serial。
既然JVM已經透明了記憶體控制,那為什麼要搞清楚這點,那就是JVM只保證單線程執行成功,而多線程環境下,就會出各種各樣的問題。
答案
下面就用上述講的分析一下最初的題目。
A線程執行:
public void writer(){ a = 1; // (1) flag = True; // (2) }
B線程執行:
public void reader(){ if (flag){ // (3) b = a + 1 // (4) } }
1.先考慮大多數人考慮的情況:
指令順序:(1)-> (2) -> (3) -> (4),b = 1 +1 = 2
2.意想不到的情況
對於A線程來說,語句 (1)和(2)並不存在任何資料依賴問題。因此處理器可以對其進行重排序,也就是指令 (2)可能會先於指令(1)執行。
那麼當指令按照(2)-> (3) -> (4) -> (1) 順序,b = 0 +1 = 1
3.還有一種情況
對於B線程,處理器可能會提前處理 (4),將結果放到 ROB中,如果控制語句(3)為真,就將結果從ROB取出來直接使用,這是一種最佳化技術,預測。
所以指令執行順序可能是 (4) -> x -> x ->x
看來4條語句都有可能最先被執行。
總結一下,在多處理器環境中,由於每個處理器都有自己的讀寫緩衝區,所以會使部分資料不一致。JMM會有一系列 action 保證資料一致性,但是在多線程環境下,還是會有很多詭異的問題發生,這個時候就要考慮處理器,編譯器重排序。
三、知識點總結1,指令重排序
大多數現代微處理器都會採用將指令亂序執行(out-of-order execution,簡稱OoOE或OOE)的方法,
在條件允許的情況下,直接運行當前有能力立即執行的後續指令,避開擷取下一條指令所需資料時造成的等待。
通過亂序執行的技術,處理器可以大大提高執行效率。除了處理器,常見的Java運行時環境的JIT編譯器也會做指令重排序操作,即產生的機器指令與位元組碼指令順序不一致。
2,as-if-serial語義
As-if-serial語義的意思是,所有的動作(Action)都可以為了最佳化而被重排序,但是必須保證它們重排序後的結果和程式碼本身的應有結果是一致的。
Java編譯器、運行時和處理器都會保證單線程下的as-if-serial語義。
ps:即指令好像是連續的,是對這種執行效果特性的一個說法。
為了保證這一語義,重排序不會發生在有資料依賴的操作之中。
3,記憶體訪問重排序與記憶體可見度
電腦系統中,為了儘可能地避免處理器訪問主記憶體的時間開銷,處理器大多會利用緩衝(cache)以提高效能。即緩衝中的資料與主記憶體的資料並不是即時同步的,各CPU(或CPU核心)間緩衝的資料也不是即時同步的。這導致在同一個時間點,各CPU所看到同一記憶體位址的資料的值可能是不一致的。從程式的視角來看,就是在同一個時間點,各個線程所看到的共用變數的值可能是不一致的。有的觀點會將這種現象也視為重排序的一種,命名為“記憶體系統重排序”。因為這種記憶體可見度問題造成的結果就好像是記憶體訪問指令發生了重排序一樣。
(執行了卻不知道執行了和以為執行了卻重排序沒有執行造成相同效果)
4,記憶體訪問重排序與Java記憶體模型
Java的目標是成為一門平台無關性的語言,即Write once, run anywhere. 但是不同硬體環境下指令重排序的規則不盡相同。例如,x86下運行正常的Java程式在IA64下就可能得到非預期的運行結果。為此,JSR-1337制定了Java記憶體模型(Java Memory Model, JMM),旨在提供一個統一的可參考的規範,屏蔽平台差異性。從Java 5開始,Java記憶體模型成為Java語言規範的一部分。
根據Java記憶體模型中的規定,可以總結出以下幾條happens-before規則。
(ps:記憶體模型即通過運行環境把一些可見度和重排序問題統一成一個標準描述)
Happens-before的前後兩個操作不會被重排序且後者對前者的記憶體可見。
程式次序法則: 線程中的每個動作A都happens-before於該線程中的每一個動作B,其中,在程式中,所有的動作B都能出現在A之後。監視器鎖法則: 對一個監視器鎖的解鎖 happens-before於每一個後續對同一監視器鎖的加鎖。volatile變數法則:對volatile域的寫入操作happens-before於每一個後續對同一個域的讀寫操作。線程啟動法則: 在一個線程裡,對Thread.start的調用會happens-before於每個啟動線程的動作。線程終結法則:線程中的任何動作都happens-before於其他線程檢測到這個線程已經終結、或者從Thread.join調用中成功返回,或Thread.isAlive返回false。中斷法則: 一個線程調用另一個線程的interrupt happens-before於被中斷的線程發現中斷。終結法則: 一個對象的建構函式的結束happens-before於這個對象finalizer的開始。傳遞性: 如果A happens-before於B,且B happens-before於C,則A happens-before於C
Happens-before關係只是對Java記憶體模型的一種近似性的描述,它並不夠嚴謹,但便於日常程式開發參考使用,
關於更嚴謹的Java記憶體模型的定義和描述,請閱讀JSR-133原文或Java語言規範章節17.4。
除此之外,Java記憶體模型對volatile和final的語義做了擴充。
對volatile語義的擴充保證了volatile變數在一些情況下不會重排序,volatile的64位變數double和long的讀取和賦值操作都是原子的。對final語義的擴充保證一個對象的構建方法結束前,所有final成員變數都必須完成初始化(前提是沒有this引用溢出)。
(ps:沒有理解final的意思)
Java記憶體模型關於重排序的規定,總結後如下表所示。(ps:下表沒看懂)
5,記憶體屏障
記憶體屏障(Memory Barrier,或有時叫做記憶體柵欄,Memory Fence)是一種CPU指令,用於控制特定條件下的重排序和記憶體可見度問題。
Java編譯器也會根據記憶體屏障的規則禁止重排序。
記憶體屏障可以被分為以下幾種類型:
LoadLoad 屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。LoadStore 屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
StoreLoad 屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。
它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。
有的處理器的重定序較嚴,無需記憶體屏障也能很好的工作,Java編譯器會在這種情況下不放置記憶體屏障。
為了實現上一章中討論的JSR-133的規定,Java編譯器會這樣使用記憶體屏障。(ps:下表沒看懂)
四、案例參考
78221064
Java並發編程原理與實戰四十一:重排序 和 happens-before