java 記憶體模型-06-happens before,as-if-serial,synchronization
as-if-serial
不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不會改變。
編譯器、runtime和處理器都必須遵守as-if-serial
語義。
為了遵守as-if-serial
語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。
但是,如果操作之間不存在資料依賴關係,這些操作就可能被編譯器和處理器重排序。
happens before
作用
JMM 可以通過happens-before
關係向程式設計師提供跨執行緒的記憶體可見性保證
。
(如果A執行緒的寫操作a與B執行緒的讀操作b之間存在 happens-before 關係,儘管 a 操作和 b 操作在不同的執行緒中執行,但 JMM 向程式設計師保證 a 操作將對 b 操作可見)
-
如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
-
兩個操作之間存在 happens-before 關係,並不意味著 Java 平臺的具體實現必須要按照 happens-before 關係指定的順序來執行。
如果重排序之後的執行結果,與按 happens-before 關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM 允許這種重排序)。
- 其中 1 是 JMM對程式設計師的承諾
從程式設計師的角度來說,可以這樣理解 happens-before 關係:如果 A happens-before B,那麼 Java 記憶體模型將向程式設計師保證——A 操作的結果將對B可見,且 A 的執行順序排在B之前。注意,這只是 Java 記憶體模型向程式設計師做出的保證!
- 其中 2 是 JMM對編譯器和處理器重排序的約束原則
正如前面所言,JMM 其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。
JMM這麼做的原因是:程式設計師對於這兩個操作是否真的被重排序並不關心,程式設計師關心的是程式執行時的語義不能被改變(即執行結果不能被改變)。
因此,happens-before 關係本質上和 as-if-serial 語義是一回事。
vs as-if-serial
-
as-if-serial 語義保證單執行緒內程式的執行結果不被改變,happens-before 關係保證正確同步的多執行緒程式的執行結果不被改變。
-
as-if-serial 語義給編寫單執行緒程式的程式設計師創造了一個幻境:單執行緒程式是按程式的順序來執行的。
happens-before 關係給編寫正確同步的多執行緒程式的程式設計師創造了一個幻境:正確同步的多執行緒程式是按 happens-before 指定的順序來執行的。
-
as-if-serial 語義和 happens-before 這麼做的目的,都是為了在不改變程式執行結果的前提下,儘可能地提高程式執行的並行度。
概念
兩個動作可以由happens-before
的關係排序。
如果一個動作發生在另一個動作之前,那麼第一個動作是可見的,並且在排序於第一個動作之前。
第二, 有許多方法可以誘導事件發生——在Java程式中排序之前,
包括:
-
執行緒中的每個操作都發生在該執行緒中的每個後續操作之前。
-
在監視器上的每次鎖定之前,都會對其進行解鎖。
-
在每次讀取該揮發物之前,都要對該揮發物進行一次寫入操作。
-
在啟動執行緒中的任何操作之前,對執行緒執行
start()
呼叫。 -
執行緒中的所有操作都發生在其他執行緒成功從a返回之前
join()
執行緒。 -
如果動作 a 發生在動作 b 之前,而b發生在動作 c 之前,那麼 a 之前 c。
當一個程式包含兩個衝突訪問,而這兩個訪問不是由happens-before
排序的關係,據說包含一個數據競爭。
一個正確同步的程式是其中沒有資料競爭(第3.4節包含一個微妙但重要的澄清)。
ps: 以上內容節選自 JSR-133
例項
假設存在如下三個執行緒,分別執行對應的操作:
執行緒A中執行如下操作:i=1 執行緒B中執行如下操作:j=i 執行緒C中執行如下操作:i=2
假設執行緒A中的操作”i=1“ happen—before執行緒B中的操作“j=i”,那麼就可以保證線上程B的操作執行後,變數j的值一定為1,即執行緒B觀察到了執行緒A中操作“i=1”所產生的影響;
現在,我們依然保持執行緒A和執行緒B之間的 happen—before 關係,同時執行緒C出現在了執行緒A和執行緒B的操作之間,但是C與B並沒有happen—before關係,
那麼j的值就不確定了,執行緒C對變數i的影響可能會被執行緒B觀察到,也可能不會,這時執行緒B就存在讀取到不是最新資料的風險,不具備執行緒安全性。
synchronization
同步到底做了什麼?
同步有幾個方面。最容易理解的是互斥,只有一個執行緒可以舉行一次監控,所以同步監測意味著一旦一個執行緒進入監視器保護的同步塊,沒有其他執行緒可以輸入一個街區保護監測到第一個執行緒退出synchronized 塊。
但是同步不僅僅是相互排斥。同步確保在同步塊之前或期間由執行緒寫入的記憶體以可預測的方式顯示給在同一監視器上同步的其他執行緒。
在退出一個同步塊之後,我們釋放(release
)監視器,它具有將快取重新整理到主記憶體的效果,因此這個執行緒所做的寫入可以被其他執行緒看到。
在輸入同步塊之前,我們獲取(acquire
)監視器,它的作用是使本地處理器快取失效,以便從主記憶體重新載入變數。
然後,我們將能夠看到前一版本中可見的所有寫操作。
從快取的角度討論這個問題,可能聽起來這些問題只會影響多處理器機器。
但是,在單個處理器上可以很容易地看到重新排序的效果。
例如,編譯器不可能在獲取之前或釋出之後移動程式碼。
當我們說獲取和釋放作用於快取時,我們使用了一些可能的效果的簡寫。
新的記憶體模型語義在記憶體操作(讀欄位、寫欄位、鎖、解鎖)和其他執行緒操作(開始和連線)上建立了部分排序,在這些操作中,某些操作據說在其他操作之前發生(happen before)。
當一個動作先於另一個動作發生時,第一個動作被保證在第二個動作之前被排序並可見。
排序規則,見。
規則
8 大規則
下面是Java記憶體模型中的八條可保證happen—before的規則,它們無需任何同步器協助就已經存在,可以在編碼中直接使用。
如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以對它們進行隨機地重排序
- 單執行緒規則
在一個單獨的執行緒中,按照程式程式碼的執行流順序,(時間上)先執行的操作 happen—before 後執行的操作。
- 鎖定規則
一個 unlock 操作 happen—before 後面對同一個鎖的 lock 操作。
- volatile 變數規則
對一個 volatile 變數的寫操作 happen—before 後面對該變數的讀操作。
- 執行緒啟動規則
Thread 物件的start()
happen—before此執行緒的每一個動作。
- 執行緒結束規則
執行緒的所有操作都 happen—before 對此執行緒的終止檢測,
可以通過Thread.join()
方法結束、Thread.isAlive()
的返回值等手段檢測到執行緒已經終止執行。
- 中斷規則
對執行緒interrupt()
方法的呼叫 happen—before 發生於被中斷執行緒的程式碼檢測到中斷時事件的發生。
- 終結器規則
一個物件的初始化完成(建構函式執行結束)happen—before它的finalize()
方法的開始。
- 傳遞性規則
如果操作 A happen—before 操作 B,操作 B happen—before 操作 C,那麼可以得出 A happen—before 操作 C。
單執行緒規則
在同一個執行緒中,書寫在前面的操作happens-before後面的操作。
- 不可變
int a = 3;//1 int b = a + 1;//2
b 的值存在對 a 的依賴關係,所以 JVM 禁止重排序。
從而保證 //1 的變化對於 //2 是可見的。
- 可變
int a = 3; int b = 4;
兩個語句直接沒有依賴關係,所以指令重排序可能發生,即對b的賦值可能先於對a的賦值。
鎖定規則
同一個鎖的unlock操作happen-beofre此鎖的lock操作。
public class A { public int var; private static A a = new A(); private A(){} public static A getInstance(){ return a; } public synchronized void method1(){ var = 3; } public synchronized void method2(){ int b = var; } public void method3(){ synchronized(new A()){ //注意這裡和method1 method2 用的可不是同一個鎖哦 var = 4; } } }
執行不同執行緒的程式碼
//執行緒1執行的程式碼: A.getInstance().method1(); //執行緒2執行的程式碼: A.getInstance().method2(); //執行緒3執行的程式碼: A.getInstance().method3();
如果某個時刻執行完“執行緒1” 馬上執行“執行緒2”,因為“執行緒1”執行A類的method1方法後肯定要釋放鎖,“執行緒2”在執行A類的method2方法前要先拿到鎖,符合“鎖的happens-before原則”,那麼在“執行緒2”method2方法中的變數var一定是3,所以變數b的值也一定是3。
但是如果是“執行緒1”、“執行緒3”、“執行緒2”這個順序,那麼最後“執行緒2”method2方法中的b值是3,還是4呢?
其結果是可能是3,也可能是4。的確“執行緒3”在執行完method3方法後的確要unlock,然後“執行緒2”有個lock,
但是這兩個執行緒用的不是同一個鎖
,所以JMM這個兩個操作之間不符合八大happens-before
中的任何一條,
所以JMM不能保證“執行緒3”對var變數的修改對“執行緒2”一定可見,雖然“執行緒3”先於“執行緒2”發生。
volatile 變數規則
對一個 volatile 變數的寫操作 happens-before 對此變數的任意操作:
volatile int a; a = 1; //1 b = a;//2
如果執行緒1 執行//1,“執行緒2”執行了//2,並且“執行緒1”執行後,“執行緒2”再執行,那麼符合“volatile的happens-before原則”所以“執行緒2”中的a值一定是1。
happens-before 的真正含義
下面我們在深入思考一下,happens-before
原則到底是如何解決變數間可見性問題的。
我們已經知道,導致多執行緒間可見性問題的兩個“罪魁禍首”是CPU快取和重排序。
那麼如果要保證多個執行緒間共享的變數對每個執行緒都及時可見,一種極端的做法就是禁止使用所有的重排序和CPU快取。
即關閉所有的編譯器、作業系統和處理器的優化,所有指令順序全部按照程式程式碼書寫的順序執行。
去掉CPU快取記憶體,讓CPU的每次讀寫操作都直接與主存互動。
當然,上面的這種極端方案是絕對不可取的,因為這會極大影響處理器的計算效能,並且對於那些非多執行緒共享的變數是不公平的。
重排序和CPU快取記憶體有利於計算機效能的提高,但卻對多CPU處理的一致性帶來了影響。
為了解決這個矛盾,我們可以採取一種折中的辦法。
我們用分割線把整個程式劃分成幾個程式塊,在每個程式塊內部的指令是可以重排序的,但是分割線上的指令與程式塊的其它指令之間是不可以重排序的。
在一個程式塊內部,CPU不用每次都與主記憶體進行互動,只需要在CPU快取中執行讀寫操作即可,但是當程式執行到分割線處,CPU必須將執行結果同步到主記憶體或從主記憶體讀取最新的變數值。
那麼,happens-before
規則就是定義了這些程式塊的分割線。
下圖展示了一個使用鎖定原則作為分割線的例子:
- Thread A
---- (X) ---- Lock M ---- 其他操作(X) ---- Unlock M ----
unlock 之前的所有操作,對於 lock 之後所有操作都是可見的
- Thread B
---- (X) ---- Lock M ---- 其他操作(X) ---- Unlock M ----
如圖所示,這裡的unlock M和lock M就是劃分程式的分割線。
在這裡,(X)
區域的程式碼內部是可以進行重排序的,但是unlock和lock操作是不能與它們進行重排序的。
即第一個圖中的(X)
區域必須要在unlock M指令之前全部執行完,第二個圖中的(X)
區域必須全部在lock M指令之後執行。
並且在第一個圖中的unlock M指令處,(X)
區域的執行結果要全部重新整理到主存中,在第二個圖中的lock M指令處,(X)
區域用到的變數都要從主存中重新讀取。
在程式中加入分割線將其劃分成多個程式塊,雖然在程式塊內部程式碼仍然可能被重排序,但是保證了程式程式碼在巨集觀上是有序的。
並且可以確保在分割線處,CPU一定會和主記憶體進行互動。
happens-before 原則就是定義了程式中什麼樣的程式碼可以作為分隔線。
並且無論是哪條 happens-before 原則,它們所產生分割線的作用都是相同的。
DCL
下面是一個典型的在單例模式中使用 DCL 的例子:
public class LazySingleton { private int someField; private static LazySingleton instance; private LazySingleton() { this.someField = new Random().nextInt(200)+1;// (1) } public static LazySingleton getInstance() { if (instance == null) {// (2) synchronized(LazySingleton.class) {// (3) if (instance == null) {// (4) instance = new LazySingleton();// (5) } } } return instance;// (6) } public int getSomeField() { return this.someField;// (7) } }
問題
這裡得到單一的 instance 例項是沒有問題的,問題的關鍵在於儘管得到了 Singleton 的正確引用,但是卻有可能訪問到其成員變數的不正確值 。
具體來說Singleton.getInstance().getSomeField()
有可能返回 someField 的預設值 0。
為也說明這種情況理論上有可能發生,我們只需要說明語句(1)和語句(7)並不存在 happens-before 關係。
分析
假設執行緒 1 是初次呼叫getInstance()
方法,緊接著執行緒 2 也呼叫了getInstance()
方法和getSomeField()
方法,
我們要說明的是執行緒 1 的語句 (1) 並不 happens-before 執行緒 2 的語句(7)。
執行緒 2 在執行getInstance()
方法的語句(2)時,由於對 instance 的訪問並沒有處於同步塊中,
因此執行緒 2 可能觀察到也可能觀察不到執行緒 1 在語句(5)時對 instance 的寫入,也就是說 instance 的值可能為空也可能為非空。
- instance 值非空
我們先假設instance的值非空,也就觀察到了執行緒 1 對 instance 的寫入。
對於執行緒2:首先執行(6)返回 instance,然後執行(7)。
注意,(7) 沒有任何同步,根據以上happens-before
的 8 條規則,無法保證執行緒 1-(1) 和執行緒 2-(7) 之間的 happpens-before 關係。
這就是 DCL 的問題所在。
- instance 值為空
執行緒 2 在執行語句(2)時也有可能觀察空值。
如果是種情況,那麼它需要進入同步塊,並執行語句(4)。
在語句(4)處執行緒 2 還能夠讀到 instance 的空值嗎?不可能。
這裡為這時對 instance 的寫和讀都是發生在同一個鎖確定的同步塊中,這時讀到的資料是最新的資料。
為也加深印象,我再用 happens-before 規則分析一遍。
執行緒 2 在語句(3)處會執行lock
操作,而執行緒 1 在語句(5)後會執行一個unlock
操作,這兩個操作都是針對同一個鎖(Singleton.class
),
因此根據第 2 條 happens-before 規則,執行緒 1 的 unlock 操作 happens-before 執行緒 2 的 lock 操作;
再利用單執行緒規則,執行緒 1 的語句(5) -> 執行緒 1 的 unlock 操作, 執行緒 2 的 lock 操作 -> 執行緒 2 的語句(4);
再根據傳遞規則,就有執行緒 1 的語句(5)-> 執行緒 2 的語句(4),也就是說執行緒 2 在執行語句(4)時能夠觀測到執行緒Ⅰ在語句(5)時對 Singleton 的寫入值。
接著對返回的 instance 呼叫 getSomeField() 方法時,我們也能得到線Ⅰ的語句(1) -> 執行緒Ⅱ的語句(7)(由於執行緒 2 有進入 synchronized 塊,根據規則2可得),
這表明這時 getSomeField 能夠得到正確的值。
解決方案
- 靜態內部類
最簡單而且安全的解決方法是使用 static 內部類的思想.
它利用的思想是:一個類直到被使用時才被初始化,而類初始化的過程是非並行的,這些都有 JLS 保證。
public class Singleton { private Singleton() {} // Lazy initialization holder class idiom for static fields private static class InstanceHolder { private static final Singleton instance = new Singleton(); } public static Singleton getSingleton() { return InstanceHolder.instance; } }
- instance 宣告為 volatile
private volatile static LazySingleton instance;
執行緒 1 的語句(5) -> 語執行緒 2 的句(2),
根據單執行緒規則,執行緒 1 的語句(1) -> 執行緒 1 的語句(5),語執行緒 2 的語句(2) -> 語執行緒 2 的語句(7);
再根據傳遞規則就有執行緒 1 的語句(1) -> 語執行緒 2 的句(7)。
參考資料
- JSR 133
ofollow,noindex">JSR-133: JavaTM Memory Model and Thread Specification
http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html
- other
https://juejin.im/post/5ae6d309518825673123fd0e
https://segmentfault.com/a/1190000011458941