一. 指令重排
令重排序:java語言規范規定JVM線程內部維持順序化語義。即只要程序的最終結果 與它順序化情況的結果相等,那么指令的執行順序可以與代碼順序不一致,此過程叫指令的 重排序。
指令重排序的意義是什么?
JVM能根據處理器特性(CPU多級緩存系統、多核處 理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的 發揮機器性能。
下圖為從源碼到最終執行的指令序列示意圖:
1.1. as-if-serial
語義 as-if-serial語義的意思是:不管怎么重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語 義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序, 因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作就可 能被編譯器和處理器重排序。
1.2. happens-before 原則
只靠sychronized和volatile關鍵字來保證原子性、可見性以及有序性,那么編寫並發 程序可能會顯得十分麻煩,幸運的是,從JDK 5開始,Java使用新的JSR-133內存模型,提 供了happens-before 原則來輔助保證程序執行的原子性、可見性以及有序性的問題,它是 判斷數據是否存在競爭、線程是否安全的依據,happens-before 原則內容如下
1. 程序順序原則
即在一個線程內必須保證語義串行性,也就是說按照代碼順序執行。
2. 鎖規則
解鎖(unlock)操作必然發生在后續的同一個鎖的加鎖(lock)之前,也就是 說,如果對於一個鎖解鎖后,再加鎖,那么加鎖的動作必須在解鎖動作之后(同一個 鎖)。
3. volatile規則
volatile變量的寫,先發生於讀,這保證了volatile變量的可見性,簡 單的理解就是,volatile變量在每次被線程訪問時,都強迫從主內存中讀該變量的 值,而當該變量發生變化時,又會強迫將最新的值刷新到主內存,任何時刻,不同的 線程總是能夠看到該變量的最新值。
4. 線程啟動規則
線程的start()方法先於它的每一個動作,即如果線程A在執行線程B 的start方法之前修改了共享變量的值,那么當線程B執行start方法時,線程A對共享 變量的修改對線程B可見
5. 傳遞性
A先於B ,B先於C 那么A必然先於C
6. 線程終止規則
線程的所有操作先於線程的終結,Thread.join()方法的作用是等待 當前執行的線程終止。假設在線程B終止之前,修改了共享變量,線程A從線程B的 join方法成功返回后,線程B對共享變量的修改將對線程A可見。
7. 線程中斷規則
對線程 interrupt()方法的調用先行發生於被中斷線程的代碼檢測到 中斷事件的發生,可以通過Thread.interrupted()方法檢測線程是否中斷。
8. 對象終結規則
對象的構造函數執行,結束先於finalize()方法
二. volatile內存語義
volatile是Java虛擬機提供的輕量級的同步機制。
2.1 volatile關鍵字有兩個作用
-
保證被volatile修飾的共享變量對所有線程總數可見的,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值總是可以被其他線程立即得知。
-
禁止指令重排序優化。
2.2 volatile的可見性
關於volatile的可見性作用,我們必須意識到被volatile修飾的變量對所有線程總是立即可見的,對volatile變量的所有寫操作總是能立刻反應到其他線程中
2.3 volatile無法保證原子性
代碼
在並發場景下,i變量的任何改變都會立馬反應到其他線程中,但是如此存在多條線程 同時調用increase()方法的話,就會出現線程安全問題,畢竟i++;操作並不具備原子性,該 操作是先讀取值,然后寫回一個新值,相當於原來的值加上1,分兩步完成,如果第二個線 程在第一個線程讀取舊值和寫回新值期間讀取i的域值,那么第二個線程就會與第一個線程 一起看到同一個值,並執行相同值的加1操作,這也就造成了線程安全失敗,因此對於 increase方法必須使用synchronized修飾,以便保證線程安全,需要注意的是一旦使用 synchronized修飾方法后,由於synchronized本身也具備與volatile相同的特性,即可見 性,因此在這樣種情況下就完全可以省去volatile修飾變量。
2.4 volatile禁止指令重排
volatile關鍵字另一個作用就是禁止指令重排優化,從而避免多線程環境下程序出現亂序執行的現象,關於指令重排優化前面已詳細分析過,這里主要簡單說明一下volatile是如 何實現禁止指令重排優化的。先了解一個概念,內存屏障(Memory Barrier)。
三. 硬件層的內存屏障
Intel硬件提供了一系列的內存屏障,主要有:
1. lfence,是一種Load Barrier 讀屏障
2. sfence, 是一種Store Barrier 寫屏障
3. mfence, 是一種全能型的屏障,具備ifence和sfence的能力
4. Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對 CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
不同硬件實現內存屏障的方式不同,Java內存模型屏蔽了這種底層硬件平台的差異,由 JVM來為不同的平台生成相應的機器碼。 JVM中提供了四類內存屏障指令:
屏障類型
|
指令示例
|
說明
|
LoadLoad
|
Load1; LoadLoad; Load2
|
保證load1的讀取操作在load2及后續讀取操作之前執行
|
StoreStore
|
Store1; StoreStore; Store2
|
在store2及其后的寫操作執行前,保證store1的寫操作已刷新到主內存
|
LoadStore
|
Load1; LoadStore; Store2
|
在stroe2及其后的寫操作執行前,保證load1的讀操作已讀取結束
|
StoreLoad
|
Store1; StoreLoad; Load2
|
保證store1的寫操作已刷新到主內存之后,load2及其后的讀操作才能執行
|
內存屏障,又稱內存柵欄,是一個CPU指令,它的作用有兩個,
一是保證特定操作的執行順序,
二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。
由於編譯器和處理器都能執行指令重排優化。
如果在指令間插入一條Memory Barrier則會告訴 編譯器和CPU,不管什么指令都不能和這條Memory Barrier指令重排序,也就是說通過插 入內存屏障禁止在內存屏障前后的指令執行重排序優化。
Memory Barrier的另外一個作用 是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。
總之,volatile變量正是通過內存屏障實現其在內存中的語義,即可見性和禁止重排優化。 下面看一個非常典型的禁止重排優化的例子DCL,如下:
來看一個單例模式
public class DoubleCheckLock {
private static DoubleCheckLock instance; private DoubleCheckLock(){} private static DoubleCheckLock getInstance() {
// 第一次檢查 if (instance == null) { synchronized (DoubleCheckLock.class) { if (instance == null) {
//多線程環境下可能出問題 instance = new DoubleCheckLock(); } } } return instance; } }
上述代碼一個經典的單例的雙重檢測的代碼,問題: 為什么要在synchronized里面再次判斷instance == null?
因為, 當t1線程搶到鎖的時候, 很多其他的線程t2,t3,t4都在外面等着呢, 一旦t1釋放鎖, 那么t2, t3, t4就立刻搶鎖, 那么進來以后, 他們需要知道當前這個對象是否已經被實例化了, 如果還沒有被實例化, 那么采取new. 如果已經new過了, 就不在new了
這段代碼在單線程環境下並沒有什么問題,但如果在多線程環境下就可以出現線程安全問題。
原因在於instance = new DoubleCheckLok(); 這個操作不是原子性的, 它由多個操作構成,如下圖:
我們隨便new一個對象. 然后看他的字節碼文件, 發現一個new操作, 在內存中經過了5步.
首先, 開辟一塊內存空間
第二: 入棧
第三: 調用init初始化方法, 也就是構造方法
第四: 彈出,
第五: 返回地址.
主要是第一三五步.
下面來看具體分析:
某一個線程執行到第一次檢測,讀取到的instance不為null時,instance的引用對象可能沒有完成初始化。
因為instance = new DoubleCheckLock();可以分為以下3步完成(偽代碼)
memory = allocate();//1.分配對象內存空間 instance(memory);//2.初始化對象 instance = memory;//3.設置instance指向剛分配的內存地址,此時instance!=null
由於步驟1和步驟2間可能會重排序,如下:
memory=allocate();//1.分配對象內存空間 instance=memory;//3.設置instance指向剛分配的內存地址,此時instance!=null,但是對象還沒有初始化完成! instance(memory);//2.初始化對象
由於步驟2和步驟3不存在數據依賴關系,而且無論重排前還是重排后程序的執行結果 在單線程中並沒有改變,因此這種重排優化是允許的。但是指令重排只會保證串行語義的執 行的一致性(單線程),但並不會關心多線程間的語義一致性。所以當一條線程訪問instance 不為null時,由於instance實例未必已初始化完成,也就造成了線程安全問題。那么該如何 解決呢,很簡單,我們使用volatile禁止instance變量被執行指令重排優化即可。
//禁止指令重排優化 private volatile static DoubleCheckLock instance;
四 volatile內存語義的實現
前面提到過重排序分為編譯器重排序和處理器重排序。為了實現volatile內存語義 ,JMM會分別限制這兩種類型的重排序類型
下圖是JMM針對編譯器制定的volatile重排序規則表。
第一個操作
|
第二個操作:普通讀寫
|
第二個操作:volatile讀
|
第二個操作:volatile寫
|
普通讀寫
|
可以重排
|
可以重排
|
不可以重排
|
volatile讀
|
不可以重排
|
不可以重排
|
不可以重排
|
volatile寫
|
可以重排
|
不可以重排
|
不可以重排
|
舉例來說,第二行最后一個單元格的意思是:
在程序中,當第一個操作為普通變量的讀或寫 時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。
public voiatile static int a = 1; public static int b = 2; public static void main(string[] args) { // 第一個操作是普通讀 b = 2; // 第二個操作是volatile寫 a = 3; // 結論: 這樣的兩個操作, 不會發生指令重排序 }
從上圖可以看出: cpu定義了, 在使用了volatile以后, 哪種方式會禁止指令重排.
- 當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序 到volatile寫之后。
- 當第一個操作是volatile讀時,不管第二個操作是什么,都不 能重排序。這個規則確保volatile讀之后的操作不會被編譯器重排序到volatile讀之前
-
當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序
為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。
對於編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能。
為此,JMM采取保守策略。下面是基於保守策略的JMM內存屏障插入策略。
- 在每個volatile寫操作的前面插入一個StoreStore屏障。
- 在每個volatile寫操作的后面插入一個StoreLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadLoad屏障。
- 在每個volatile讀操作的后面插入一個LoadStore屏障。
上述內存屏障插入策略非常保守,但它可以保證在任意處理器平台,任意的程序中都能得到 正確的volatile內存語義。 下面是保守策略下,volatile寫插入內存屏障后生成的指令序列示意圖
上圖中StoreStore屏障可以保證在volatile寫之前,其前面的所有普通寫操作已經對任 意處理器可見了。這是因為StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新 到主內存。
這里比較有意思的是,volatile寫后面的StoreLoad屏障。此屏障的作用是避免volatile 寫與 后面可能有的volatile讀/寫操作重排序。因為編譯器常常無法准確判斷在一個volatile 寫的后面 是否需要插入一個StoreLoad屏障(比如,一個volatile寫之后方法立即 return)。為了保證能正確 實現volatile的內存語義,JMM在采取了保守策略:在每個
volatile寫的后面,或者在每個volatile 讀的前面插入一個StoreLoad屏障。從整體執行效 率的角度考慮,JMM最終選擇了在每個 volatile寫的后面插入一個StoreLoad屏障。因為 volatile寫-讀內存語義的常見使用模式是:一個 寫線程寫volatile變量,多個讀線程讀同一 個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫之后插入StoreLoad 屏障將帶來可觀的執行效率的提升。從這里可以看到JMM 在實現上的一個特點:首先確保 正確性,然后再去追求執行效率。
下圖是在保守策略下,volatile讀插入內存屏障后生成的指令序列示意圖
上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。 LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內存屏障插入策略非常保守。在實際執行時,只要不改變 volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。下面通過具體的示 例
代碼進行說明。

針對readAndWrite()方法,編譯器在生成字節碼時可以做如下的優化。
注意,最后的StoreLoad屏障不能省略。因為第二個volatile寫之后,方法立即 return。此時編 譯器可能無法准確斷定后面是否會有volatile讀或寫,為了安全起見,編譯 器通常會在這里插 入一個StoreLoad屏障。
上面的優化針對任意處理器平台,由於不同的處理器有不同“松緊度”的處理器內存模 型,內存屏障的插入還可以根據具體的處理器內存模型繼續優化。以X86處理器為例,圖3- 21 中除最后的StoreLoad屏障外,其他的屏障都會被省略。
前面保守策略下的volatile讀和寫,在X86處理器平台可以優化成如下圖所示。前文提 到過,X86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀、讀-寫和寫-寫操作 做重排 序,因此在X86處理器中會省略掉這3種操作類型對應的內存屏障。在X86中,JMM僅需 在 volatile寫后面插入一個StoreLoad屏障即可正確實現volatile寫-讀的內存語義。