Java記憶體模型(JMM)
JMM 之 happens-before
在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關係。
happens-before 原則非常重要,它是判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們解決在併發環境下兩操作之間是否可能存在衝突的所有問題。下面我們就一個簡單的例子稍微瞭解下happens-before ;
i = 1; // 執行緒 A 執行
j = i; //執行緒 B 執行
複製程式碼
j 是否等於 1 呢?
假定執行緒 A 的操作(i = 1) happens-before 執行緒 B 的操作(j = i),那麼可以確定,執行緒 B 執行後 j = 1 一定成立。
如果他們不存在 happens-before 原則,那麼 j = 1 不一定成立。這就是happens-before原則的威力。
定義
happens-before 原則【定義】如下:
如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果,將對第二個操作可見,而且第一個操作的執行順序,排在第二個操作之前。
兩個操作之間存在 happens-before 關係,並不意味著一定要按照 happens-before 原則制定的順序來執行。如果重排序之後的執行結果與按照 happens-before 關係來執行的結果一致,那麼這種重排序並不非法。
規則
- 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作,happens-before 於書寫在後面的操作。
- 鎖定規則:一個 unLock 操作,happens-before 於後面對同一個鎖的 lock 操作。
- volatile 變數規則:對一個volatile變數的寫操作,happens-before 於後面對這個變數的讀操作。注意是後面的.
- 傳遞規則:如果操作 A happens-before 操作 B,而操作 B happens-before 操作C,則可以得出,操作 A happens-before 操作C
- 執行緒啟動規則:Thread 物件的 start 方法,happens-before 此執行緒的每個一個動作。
- 執行緒中斷規則:對執行緒 interrupt 方法的呼叫,happens-before 被中斷執行緒的程式碼檢測到中斷事件的發生。
- 執行緒終結規則:執行緒中所有的操作,都 happens-before 執行緒的終止檢測,我們可以通過Thread.join() 方法結束、Thread.isAlive() 的返回值手段,檢測到執行緒已經終止執行。
- 物件終結規則:一個物件的初始化完成,happens-before 它的 finalize() 方法的開始
上面八條是原生 Java 滿足 happens-before 關係的規則,但是我們可以對他們進行推匯出其他滿足 happens-before 的規則:
- 將一個元素放入一個執行緒安全的佇列的操作,happens-before 從佇列中取出這個元素的操作。
- 將一個元素放入一個執行緒安全容器的操作,happens-before 從容器中取出這個元素的操作。
- 在 CountDownLatch 上的 countDown 操作,happens-before CountDownLatch 上的 await 操作。
- 釋放 Semaphore 上的 release 的操作,happens-before Semaphore 上的 acquire 操作。
- Future 表示的任務的所有操作,happens-before Future 上的 get 操作。
- 向 Executor 提交一個 Runnable 或 Callable 的操作,happens-before 任務開始執行操作。
這裡再說一遍 happens-before 的概念:
-
如果兩個操作不存在上述(前面8條 + 後面6條)任一一個 happens-before 規則,那麼這兩個操作就沒有順序的保障,JVM 可以對這兩個操作進行重排序。
-
如果操作 A happens-before 操作 B,那麼操作A在記憶體上所做的操作對操作B都是可見的。
下面就用一個簡單的例子,來描述下 happens-before 的原則:
private int i = 0;
public void write(int j ) {
i = j;
}
public int read() {
return i;
}
複製程式碼
我們約定執行緒 A 執行 #write(int j),執行緒 B 執行 #read(),且執行緒 A 優先於執行緒 B 執行,那麼執行緒 B 獲得結果是什麼?
就這段簡單的程式碼,我們來基於 happens-before 的規則做一次分析:
由於兩個方法是由不同的執行緒呼叫,所以肯定不滿足程式次序規則。
兩個方法都沒有使用鎖,所以不滿足鎖定規則。
變數 i 不是用volatile修飾的,所以 volatile 變數規則不滿足。
傳遞規則肯定不滿足。
規則 5、6、7、8 + 推導的 6 條可以忽略,因為他們和這段程式碼毫無關係。
所以,我們無法通過 happens-before 原則,推匯出執行緒 A happens-before 執行緒 B 。
雖然,可以確認在時間上,執行緒 A 優先於執行緒 B 執行,但是就是無法確認執行緒B獲得的結果是什麼,所以這段程式碼不是執行緒安全的。
複製程式碼
那麼怎麼修復這段程式碼呢?滿足規則 2、3 任一即可。
happen-before原則是JMM中非常重要的原則,它是判斷資料是否存在競爭、執行緒是否安全的主要依據,保證了多執行緒環境下的可見性。
happen-before 總結
JMM 之分析 volatile
我們知道volatile 的特性:
- volatile 可見性:對一個 volatile 的讀,總可以看到對這個變數最終的寫。
- volatile 原子性:volatile 對單個讀 / 寫具有原子性(32 位 Long、Double),但是複合操作除外,例如 i++ 。
- JVM 底層採用“記憶體屏障”來實現 volatile 語義。
下面 通過 happens-before 原則和 volatile 的記憶體語義,兩個方向分析 volatile 。
volatile 與 happens-before
我們知道happens-before 是用來判斷是否存在資料競爭、執行緒是否安全的主要依據,它保證了多執行緒環境下的可見性。下面我們就那個經典的例子,來分析 volatile 變數的讀寫,如何建立的 happens-before 關係。
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
// Thread A
public void write(){
i = 2; // 1
flag = true; // 2
}
// Thread B
public void read(){
if(flag) { // 3
System.out.println("---i = " + i); // 4
}
}
}
複製程式碼
依據 happens-before 原則,就上面程式得到如下關係:
-
程式順序原則:操作 1 happens-before 操作 2 ,操作 3 happens-before 操作 4 。
-
volatile 原則:操作 2 happens-before 操作 3 。
- 2 happens-before 操作 3的前提是2操作比 3操作要早
- 如果1操作後面插入
for (int j = 0; j < 100; j++) { System.out.println(13213123); } 複製程式碼
那麼3會先執行完
-
傳遞性原則:操作 1 happens-before 操作 4 。
-
操作 1、操作 4 存在 happens-before 關係,那麼操作 1 一定是對 操作 4 是可見的。
可能有人就會問,操作 1、操作 2 可能會發生重排序啊,會嗎?volatile 除了保證可見性外,還有就是禁止重排序。所以 A 執行緒在寫 volatile 變數之前所有可見的共享變數,線上程 B 讀同一個 volatile 變數後,將立即變得對執行緒 B 可見。
volataile 的記憶體語義及其實現
- 當寫一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數值,立即重新整理到主記憶體中。
- 當讀一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體設定為無效,直接從主記憶體中讀取共享變數
所以 volatile 的寫記憶體語義是直接重新整理到主記憶體中,讀的記憶體語義是直接從主記憶體中讀取。
那麼 volatile 的記憶體語義是如何實現的呢?對於一般的變數則會被重排序,而對於 volatile 的變數則不能。這樣會影響其記憶體語義,所以為了實現 volatile 的記憶體語義,JMM 會限制重排序。其重排序規則如下:
- 如果第一個操作為 volatile 讀,則不管第二個操作是啥,都不能重排序。這個操作確保volatile 讀之後的操作,不會被編譯器重排序到 volatile 讀之前;
- 如果第二個操作為 volatile 寫,則不管第一個操作是啥,都不能重排序。這個操作確保volatile 寫之前的操作,不會被編譯器重排序到 volatile 寫之後;
- 當第一個操作 volatile 寫,第二個操作為 volatile 讀時,不能重排序。
volatile 的底層實現,是通過插入記憶體屏障。但是對於編譯器來說,發現一個最優佈置來最小化插入記憶體屏障的總數幾乎是不可能的,所以,JMM 採用了保守策略。
策略如下:
- 在每一個 volatile 寫操作前面,插入一個 StoreStore 屏障
- 在每一個 volatile 寫操作後面,插入一個 StoreLoad 屏障
- 在每一個 volatile 讀操作後面,插入一個 LoadLoad 屏障
- 在每一個 volatile 讀操作後面,插入一個 LoadStore 屏障
原因如下:
StoreStore 屏障:保證在 volatile 寫之前,其前面的所有普通寫操作,都已經重新整理到主記憶體中。
StoreLoad 屏障:避免 volatile 寫,與後面可能有的 volatile 讀 / 寫操作重排序。
LoadLoad 屏障:禁止處理器把上面的 volatile讀,與下面的普通讀重排序。
LoadStore 屏障:禁止處理器把上面的 volatile讀,與下面的普通寫重排序。
複製程式碼
案例 1:VolatileTest
下面我們就上面 VolatileTest 例子重新分析下:
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write() {
i = 2;
flag = true;
}
public void read() {
if (flag){
System.out.println("---i = " + i);
}
}
}
複製程式碼
記憶體屏障圖例
案例 2:VolatileBarrierExample
volatile 的記憶體屏障插入策略非常保守,其實在實際中,只要不改變 volatile 寫-讀的記憶體語義,編譯器可以根據具體情況優化,省略不必要的屏障。
public class VolatileBarrierExample {
int a = 0;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite(){
int i = v1; //volatile讀
int j = v2; //volatile讀
a = i + j; //普通讀
v1 = i + 1; //volatile寫
v2 = j * 2; //volatile寫
}
}
複製程式碼
沒有優化的示例圖如下:
我們來分析,上圖有哪些記憶體屏障指令是多餘的:
1:這個肯定要保留了
2:禁止下面所有的普通寫與上面的 volatile 讀重排序,但是由於存在第二個 volatile讀,那個普通的讀根本無法越過第二個 volatile 讀。所以可以省略。
3:下面已經不存在普通讀了,可以省略。
4:保留
5:保留
6:下面跟著一個 volatile 寫,所以可以省略
7:保留
8:保留
所以 2、3、6 可以省略,其示意圖如下:
總結
JMM 之重排序
在執行程式時,為了提高效能,處理器和編譯器常常會對指令進行重排序,但是不能隨意重排序,不是你想怎麼排序就怎麼排序,它需要滿足以下兩個條件:
- 在單執行緒環境下,不能改變程式執行的結果。
- 存在資料依賴關係的情況下,不允許重排序。
其實這兩點可以歸結於一點:無法通過 happens-before 原則推匯出來的,JMM 允許任意的排序。
as-if-serial 語義
as-if-serial 語義的意思是:所有的操作均可以為了優化而被重排序,但是你必須要保證重排序後執行的結果不能被改變,編譯器、runtime、處理器都必須遵守 as-if-serial 語義。注意,as-if-serial 只保證單執行緒環境,多執行緒環境下無效。
下面我們用一個簡單的示例來說明:
int a = 1 ; // A
int b = 2 ; // B
int c = a + b; // C
複製程式碼
A、B、C 三個操作存在如下關係:
A、B 不存在資料依賴關係,
A和C、B和C存在資料依賴關係,
複製程式碼
因此在進行重排序的時候,A、B 可以隨意排序,但是必須位於 C 的前面,執行順序可以是 A –> B –> C 或者 B –> A –> C 。但是無論是何種執行順序最終的結果 C 總是等於 3 。
as-if-serail 語義把單執行緒程式保護起來了,它可以保證在重排序的前提下程式的最終結果始終都是一致的。
其實,對於上段程式碼,他們存在這樣的 happen-before 關係:
A happens-before B
B happens-before C
A happens-before C
複製程式碼
1、2 是程式順序次序規則,3 是傳遞性。但是,不是說通過重排序,B 可能會排在 A 之前執行麼,為何還會存在存在 A happens-before B 呢?這裡再次申明 A happens-before B 不是 A 一定會在 B 之前執行,而是 A 的執行結果對 B 可見,但是相對於這個程式 A 的執行結果不需要對 B 可見,且他們重排序後不會影響結果,所以 JMM 不會認為這種重排序非法。
我們需要明白這點:在不改變程式執行結果的前提下,儘可能提高程式的執行效率。
下面我們在看一段有意思的程式碼:
public class RecordExample1 {
public static void main(String[] args){
int a = 1;
int b = 2;
try {
a = 3; // A
b = 1 / 0; // B
} catch (Exception e) {
} finally {
System.out.println("a = " + a);
}
}
}
複製程式碼
按照重排序的規則,操作 A 與操作 B 有可能會進行重排序,如果重排序了,B 會丟擲異常( / by zero),此時A語句一定會執行不到,那麼 a 還會等於 3 麼?
如果按照 as-if-serial 原則它就改變了程式的結果。
其實,JVM 對異常做了一種特殊的處理,為了保證 as-if-serial 語義,Java 異常處理機制對重排序做了一種特殊的處理:JIT 在重排序時,會在catch 語句中插入錯誤代償程式碼(a = 3),這樣做雖然會導致 catch 裡面的邏輯變得複雜,但是 JIT 優化原則是:儘可能地優化程式正常執行下的邏輯,哪怕以 catch 塊邏輯變得複雜為代價。
重排序對多執行緒的影響
在單執行緒環境下,由於 as-if-serial 語義,重排序無法影響最終的結果,但是對於多執行緒環境呢?
如下程式碼(volatile的經典用法):
public class RecordExample2 {
int a = 0;
boolean flag = false;
/**
* A執行緒執行
*/
public void writer() {
a = 1; // 1
flag = true; // 2
}
/**
* B執行緒執行
*/
public void read(){
if (flag) { // 3
int i = a + a; // 4
}
}
}
複製程式碼
A 執行緒先執行 #writer(),執行緒 B 後執行 #read(),執行緒 B 在執行時能否讀到 a = 1 呢?
答案是不一定(注:x86 CPU 不支援寫寫重排序,如果是在 x86 上面操作,這個一定會是 a = 1 )。
由於操作 1 和操作 2 之間沒有資料依賴性,所以可以進行重排序處理。 操作 3 和操作 4 之間也沒有資料依賴性,他們亦可以進行重排序,但是操作 3 和操作 4 之間存在控制依賴性。
假如操作1 和操作2 之間重排序:
按照這種執行順序執行緒 B 肯定讀不到執行緒 A 設定的 a 值,在這裡多執行緒的語義就已經被重排序破壞了。
實際上,操作 3 和操作 4 之間也可以重排序,雖然他們之間存在一個控制依賴的關係,只有操作 3 成立操作 4 才會執行。
當程式碼中存在控制依賴性時,會影響指令序列的執行的並行度,所以編譯器和處理器會採用猜測執行來克服控制依賴對並行度的影響。
假如操作 3 和操作 4 重排序了,操作 4 先執行,則先會把計算結果臨時儲存到重排序緩衝中,當操作 3 為真時,才會將計算結果寫入變數 i 中。
通過上面的分析,重排序不會影響單執行緒環境的執行結果,但是會破壞多執行緒的執行語義。
重排序總結
JMM 角度分析 DCL
DCL ,即 Double Check Lock ,中文稱為“雙重檢查鎖定”。
其實 DCL 很多人在單例模式中用過,但是有很多人都會寫錯。他們為什麼會寫錯呢?其錯誤根源在哪裡?有什麼解決方案?下面就一起來分析。
問題分析
我們先看單例模式裡面的懶漢式:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
複製程式碼
我們都知道這種寫法是錯誤的,因為它無法保證執行緒的安全性。優化如下:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
複製程式碼
優化非常簡單,就是在 #getInstance() 方法上面做了同步,但是 synchronized 就會導致這個方法比較低效,導致程式效能下降,那麼怎麼解決呢?聰明的人們想到了雙重檢查 DCL:
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance(){
if(singleton == null){ // 1
synchronized (Singleton.class){ // 2
if(singleton == null){ // 3
singleton = new Singleton(); // 4
}
}
}
return singleton;
}
}
複製程式碼
就如上面所示,這個程式碼看起來很完美,理由如下:
- 如果檢查第一個 singleton 不為 null ,則不需要執行下面的加鎖動作,極大提高了程式的效能。
- 如果第一個 singleton 為 null ,即使有多個執行緒同一時間判斷,但是由於 synchronized 的存在,只會有一個執行緒能夠建立物件。
- 當第一個獲取鎖的執行緒建立完成後 singleton 物件後,其他的在第二次判斷 singleton 一定不會為 null ,則直接返回已經建立好的 singleton 物件。
通過上面的分析,DCL 看起確實是非常完美,但是可以明確地告訴你,這個錯誤的。上面的邏輯確實是沒有問題,分析也對,但是就是有問題,那麼問題出在哪裡呢?在回答這個問題之前,我們先來複習一下建立物件過程,例項化一個物件要分為三個步驟:
memory = allocate(); //1:分配記憶體空間
ctorInstance(memory); //2:初始化物件
instance = memory; //3:將記憶體空間的地址賦值給對應的引用
複製程式碼
但是由於重排序的原因,步驟 2、3 可能會發生重排序,其過程如下:
memory = allocate(); // 1:分配記憶體空間
instance = memory; // 3:將記憶體空間的地址賦值給對應的引用
// 注意,此時物件還沒有被初始化!
ctorInstance(memory); // 2:初始化物件
複製程式碼
如果 2、3 發生了重排序,就會導致第二個判斷會出錯,singleton != null,但是它其實僅僅只是一個地址而已,此時物件還沒有被初始化,所以 return 的 singleton 物件是一個沒有被初始化的物件,如下:
按照上面圖例所示,執行緒 B 訪問的是一個沒有被初始化的 singleton 物件。
知道問題根源所在,那麼怎麼解決呢?有兩個解決辦法:
不允許初始化階段步驟 2、3 發生重排序。
允許初始化階段步驟 2、3 發生重排序,但是不允許其他執行緒“看到”這個重排序。
複製程式碼
解決方案
解決方案依據上面兩個解決辦法即可。
基於 volatile 解決方案
對於上面的DCL其實只需要做一點點修改即可:將變數singleton生命為volatile即可:
public class Singleton {
// 通過volatile關鍵字來確保安全
private volatile static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized (Singleton.class){
if(singleton == null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
複製程式碼
當 singleton 宣告為 volatile後,步驟 2、3 就不會被重排序了,也就可以解決上面那問題了。
基於類初始化的解決方案
該解決方案的根本就在於:利用 ClassLoder 的機制,保證初始化 instance 時只有一個執行緒。JVM 在類初始化階段會獲取一個鎖,這個鎖可以同步多個執行緒對同一個類的初始化。
public class Singleton {
private static class SingletonHolder{
public static Singleton singleton = new Singleton();
}
public static Singleton getInstance(){
return SingletonHolder.singleton;
}
}
複製程式碼
Java 語言規定,對於每一個類或者介面 C ,都有一個唯一的初始化鎖 LC 與之相對應。從C 到 LC 的對映,由 JVM 的具體實現去自由實現。JVM 在類初始化階段期間會獲取這個初始化鎖,並且每一個執行緒至少獲取一次鎖來確保這個類已經被初始化過了。
DCL 總結
延遲初始化降低了初始化類或建立例項的開銷,但增加了訪問被延遲初始化的欄位的開銷。在大多數時候,正常的初始化要優於延遲初始化。
如果確實需要對例項欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於 volatile 的延遲初始化的方案。 如果確實需要對靜態欄位使用執行緒安全的延遲初始化,請使用上面介紹的基於類初始化的方案。
JMM 之總結
經過上面的討論,現在對 JMM 做一個比較簡單的總結。
-
JMM 規定了執行緒的工作記憶體和主記憶體的互動關係,以及執行緒之間的可見性和程式的執行順序。
-
一方面,要為程式設計師提供足夠強的記憶體可見性保證。
-
另一方面,對編譯器和處理器的限制要儘可能地放鬆。JMM 對程式設計師遮蔽了 CPU 以及 OS 記憶體的使用問題,能夠使程式在不同的 CPU 和 OS 記憶體上都能夠達到預期的效果。
-
Java 採用記憶體共享的模式來實現執行緒之間的通訊。編譯器和處理器可以對程式進行重排序優化處理,但是需要遵守一些規則,不能隨意重排序。
-
在併發程式設計模式中,勢必會遇到上面三個概念:
原子性:一個操作或者多個操作要麼全部執行要麼全部不執行。 可見性:當多個執行緒同時訪問一個共享變數時,如果其中某個執行緒更改了該共享變數,其他執行緒應該可以立刻看到這個改變。 有序性:程式的執行要按照程式碼的先後順序執行。 複製程式碼
-
JMM 對原子性並沒有提供確切的解決方案,但是 JMM 解決了可見性和有序性,至於原子性則需要通過鎖或者 synchronized 來解決了。
-
如果一個操作 A 的操作結果需要對操作 B 可見,那麼我們就認為操作 A 和操作 B 之間存在happens-before 關係,即 A happens-before B 。
-
happens-before 原則,是 JMM 中非常重要的一個原則,它是判斷資料是否存在競爭、執行緒是否安全的主要依據,依靠這個原則,我們可以解決在併發環境下兩個操作之間是否存在衝突的所有問題。JMM 規定,兩個操作存在 happens-before 關係並不一定要 A 操作先於B 操作執行,只要 A 操作的結果對 B 操作可見即可。
-
在程式執行過程中,為了執行的效率,編譯器和處理器是可以對程式進行一定的重排序,但是他們必須要滿足兩個條件:
執行的結果保持不變 存在資料依賴的不能重排序。重排序是引起多執行緒不安全的一個重要因素。 複製程式碼
-
同時,順序一致性是一個比較理想化的參考模型,它為我們提供了強大而又有力的記憶體可見性保證,他主要有兩個特徵:
一個執行緒中的所有操作必須按照程式的順序來執行。 所有執行緒都只能看到一個單一的操作執行順序,在順序一致性模型中,每個操作都必須原則執行且立刻對所有執行緒可見。複製程式碼