詳解Java volatile 內存屏障底層原理語義
一、volatile關鍵字介紹及底層原理
1.volatile的特性(內存語義)
當一個變量被定義成volatile之後,它將具備兩項特性:第一項是保證此變量對所有線程的可見性,這裡的“可見性”是指當一條線程修改瞭這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量並不能做到這一點,普通變量的值在線程間傳遞時均需要通過主內存來完成。比如,線程A修改一個普通變量的值,然後向主內存進行回寫,另外一條線程B在線程A回寫完成瞭之後再對主內存進行讀取操作,新變量值才會對線程B可見。
使用volatile變量的第二個語義是禁止指令重排序優化,普通的變量僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因為在同一個線程的方法執行過程中無法感知到這點,這就是Java內存模型中描述的所謂“線程內表現為串行的語義”(Within-Thread As-If-Serial Semantics)。
2.volatile底層原理
volatile關鍵字修飾的變量可以保證可見性與有序性,無法保證原子性。那麼volatile關鍵字的底層原理是什麼呢?我們可以通過查看Java代碼的匯編指令去看一下volatile的底層原理:查詢Java代碼的匯編指令需要設置JVM允許參數:-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp;如果你的jdk版本小於等於8還要在jdk裡面添加Hsdis插件,將該插件目錄裡面的兩個文件(hsdis-amd64.dll,hsdis-i386.dll)復制到 %JAVA_HOME%\jre\bin\server 下,然後運行你的Java程序,就可以看到控制臺裡面一堆的匯編指令代碼輸出瞭。
public class Singleton { private volatile static Singleton myinstance; public static Singleton getInstance() { if (myinstance == null) { synchronized (Singleton.class) { if (myinstance == null) { myinstance = new Singleton();//對象創建過程,本質可以分文三步 } } } return myinstance; } public static void main(String[] args) { Singleton.getInstance(); } }
上面所示是一段標準的雙鎖檢測(Double Check Lock,DCL)單例代碼,可以觀察加入volatile和未加入volatile關鍵字時所生成的匯編代碼的差別。不加volatile關鍵字時在控制臺輸出指令搜索myinstance可以看到如下兩行
0x00000000038064dd: mov %r10d,0x68(%rsi)
0x00000000038064e1: shr $0x9,%rsi
0x00000000038064e5: movabs $0xf1d8000,%rax
0x00000000038064ef: movb $0x0,(%rsi,%rax,1) ;*putstatic myinstance
; – com.it.edu.jmm.Singleton::getInstance@24 (line 22)
加瞭volatile關鍵字後,變成下面這樣瞭:
0x0000000003cd6edd: mov %r10d,0x68(%rsi)
0x0000000003cd6ee1: shr $0x9,%rsi
0x0000000003cd6ee5: movabs $0xf698000,%rax
0x0000000003cd6eef: movb $0x0,(%rsi,%rax,1)
0x0000000003cd6ef3: lock addl $0x0,(%rsp) ;*putstatic myinstance
; – com.it.edu.jmm.Singleton::getInstance@24 (line 22)
通過對比發現,關鍵變化在於有volatile修飾的變量,賦值後(前面movb $0x0,(%rsi,%rax,1)這句便是賦值操作)多執行瞭一個“lock addl $0x0,(%rsp)”操作,這個操作的作用相當於一個內存屏障(Memory Barrier或Memory Fence,指重排序時不能把後面的指令重排序到內存屏障之前的位置,隻有一個處理器訪問內存時,並不需要內存屏障;但如果有兩個或更多處理器訪問同一塊內存,且其中有一個在觀測另一個,就需要內存屏障來保證一致性瞭。
這裡的關鍵在於lock前綴,它的作用是將本處理器的緩存寫入瞭內存,該寫入動作也會引起別的處理器或者別的內核無效化(Invalidate,MESI協議的I狀態)其緩存,這種操作相當於對緩存中的變量做瞭一次前面介紹Java內存模式中所說的“store和write”操作。所以通過這樣一個操作,可讓前面volatile變量的修改對其他處理器立即可見。lock指令的更底層實現:如果支持緩存行會加緩存鎖(MESI);如果不支持緩存鎖,會加總線鎖。
二、volatile——可見性
volatile修飾變量之後,可以保證可見性,下面通過一個程序示例演示一下:
public class VolatileVisibilitySample { private volatile boolean initFlag = false; static Object object = new Object(); public void refresh(){ this.initFlag = true; System.out.println("線程:"+Thread.currentThread().getName()+":修改共享變量initFlag"); } public void load(){ int i = 0; while (!initFlag){ // synchronized (object){ // i++; // } } System.out.println("線程:"+Thread.currentThread().getName()+"當前線程嗅探到initFlag的狀態的改變"+i); } public static void main(String[] args) throws InterruptedException { VolatileVisibilitySample sample = new VolatileVisibilitySample(); Thread threadA = new Thread(()->{ sample.refresh(); },"threadA"); Thread threadB = new Thread(()->{ sample.load(); },"threadB"); threadB.start(); Thread.sleep(2000); threadA.start(); } }
可以看到共享變量被volatile修飾之前,線程B中調用的方法中 “當前線程嗅探到initFlag的狀態的改變” 這句輸出是打印不出來的,也就意味著線程A中將initFlag改為true,但是線程B並沒有獲取到最新值,程序一直在循環空跑。此時JMM操作如下圖:雖然線程A中將initFlag改為瞭true並且最終會同步回主內存,但是線程B中循環讀取的initFlag一直都是從工作內存讀取的,所以會一直進行死循環無法退出。
添加瞭volatile修飾之後,“當前線程嗅探到initFlag的狀態的改變” 這句話就會被打印出來,因為添加volatile關鍵字後,就會有lock指令,使用緩存一致性協議,線程B中會一直嗅探initFlag是否被改變,線程A修改initFlag後會立即同步回主內存,這時候會通知線程B將緩存行狀態改為I(無效狀態),需要重新從主內存讀取。如下圖所示:
我們將上面的代碼的load()方法進行修改——去掉volatile關鍵字,添加synchronized同步塊,即修改為下面這樣的情況,會達到跟添加volatile關鍵字相同的效果,這是因為添加瞭鎖同步塊,CPU會分配時間片,線程進行鎖競爭導致線程上下文切換,重新讀取主存的變量。
public void load(){ int i = 0; while (!initFlag){ synchronized (object){ i++; } } System.out.println("線程:"+Thread.currentThread().getName()+"當前線程嗅探到initFlag的狀態的改變"+i); }
三、volatile——無法保證原子性
由於volatile變量隻能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized、java.util.concurrent中的鎖或原子類)來保證原子性:
- 運算結果並不依賴變量的當前值,或者能夠確保隻有單一的線程修改變量的值。
- 變量不需要與其他的狀態變量共同參與不變約束
下面通過一個示例演示一下:10個線程,每個線程加1000次(counter++不是一個原子性的操作,可以通過javap命令查看底層指令,可以看到有加載變量數據、將變量放到操作數棧頂、執行加法運算等操作)。運行幾次發現,有時運行結果是小於10000的。下面分析一下:
- 1.首先counter不加volatile修飾時:因為10個線程同時對變量進行自加1運算,每個運算一次後去寫會主內存,會覆蓋其他線程的運算結果,所以運行結果可能會小於10000。
- 2.counter添加volatile修飾時:添加volatile修飾之後,變量被修改後會立即同步回主存,一直嗅探其他線程是否對變量進行過修改,修改後重新從主存讀取變量。但是正因為添加瞭volatile關鍵字時MESI緩存一致性協議生效瞭,當一個變量執行加1操作後,需要同步回主存,這是會鎖緩存行,通知其他線程變量已經被修改過瞭,將本地緩存行改為I無效狀態,這樣被改為無效狀態的線程本地加1操作的結果被丟棄瞭,沒有寫回主內存,也就是白加瞭一次,所以運行結果也可能會小於10000。
想要實現原子性操作,可以通過synchronized,ReentrantLock加鎖,或者使用AtomicInteger進行原子性運算。
public class VolatileAtomicSample { private static volatile int counter = 0; public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10; i++) { Thread thread = new Thread(()->{ for (int j = 0; j < 1000; j++) { counter++; } }); thread.start(); } Thread.sleep(1000); System.out.println(counter); } }
四、volatile——禁止指令重排
1.指令重排
重排序是指編譯器和處理器為瞭優化程序性能而對指令序列進行重新排序的一種手段。java語言規范規定JVM線程內部維持順序化語義。即隻要程序的最終結果與
它順序化情況的結果相等,那麼指令的執行順序可以與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。
下圖為從源碼到最終執行的指令序列示意圖
指令重排主要有兩個階段:
1.編譯器編譯階段:編譯器加載class文件編譯為機器碼時進行指令重排
2.CPU執行階段: CPU執行匯編指令時,可能會對指令進行重排序
2.as-if-serial語義
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為瞭提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。為瞭遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作就可能被編譯器和處理器重排序。
通過一個程序代碼,演示一下指令重排的效果:隻有x=0並且y=0的情況下才會跳出循環
public class VolatileReOrderSample { private static int x = 0, y = 0; private static int a = 0, b =0; static Object object = new Object(); public static void main(String[] args) throws InterruptedException { int i = 0; for (;;){ i++; x = 0; y = 0; a = 0; b = 0; Thread t1 = new Thread(new Runnable() { @Override public void run() { a = 1; x = b; } }); Thread t2 = new Thread(new Runnable() { @Override public void run() { b = 1; y = a; } }); t1.start(); t2.start(); t1.join(); t2.join(); String result = "第" + i + "次 (" + x + "," + y + ")"; if(x == 0 && y == 0) { System.err.println(result); break; } else { System.out.println(result); } } } }
通過分析,會有三種可能的輸出:[0,1],[1,0],[1,1]。
- 輸出可能1——[0,1]:線程1先執行完,線程2再執行,則會出現x=0,y=1
- 輸出可能1——[1,0]:線程2先執行完,線程1再執行,則會出現x=1,y=0
- 輸出可能1——[1,1]:線程1、線程2交替執行,a=1,b=1,然後執行x=1,y=1,則會出現x=1,y=1
當運行之後會發現上面分析的三種情況確實出現瞭,但是程序最終跳出瞭循環,也就是出現瞭x=0並且y=0的情況,這說明出現瞭指令重排的情況,即線程1中a=1 x=b的指令出現瞭順序調整或線程2中b=1 y=a的指令出現瞭順序調整。
當我們給變量a和b添加volatile關鍵字修飾後(private volatile static int a = 0, b =0;),再次運行發現程序一直在循環輸出,沒有出現x=y=0的情況從而退出循環。
volatile可以禁止指令重排的原因是因為添加瞭lock指令,會添加內存屏障。
五、volatile與內存屏障(Memory Barrier)
1.內存屏障(Memory Barrier)
內存屏障(Memory Barrier)又稱內存柵欄,是一個CPU指令,它的作用有兩個,一是保證特定操作的執行順序,二是保證某些變量的內存可見性(利用該特性實現volatile的內存可見性)。由於編譯器和處理器都能執行指令重排優化。如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,不管什麼指令都不能和這條Memory Barrier指令重排序,也就是說通過插入內存屏障禁止在內存屏障前後的指令執行重排序優化。Memory Barrier的另外一個作用是強制刷出各種CPU的緩存數據,因此任何CPU上的線程都能讀取到這些數據的最新版本。總之,volatile變量正是通過內存屏障(lock指令)實現其在內存中的語義,即可見性和禁止重排優化。
上面的程序示例:synchronized+volatile實現的DCL模式的單例模式,就是利用瞭volatile禁止指令重排的特性。因為myinstance = new Singleton();這句代碼本質上是有三步:1.為對象分配內存空間;2.實例化對象數據;3.將引用指向對象實例的內存空間。如果第一個線程執行創建對象時出現瞭指令重排,比如3排到瞭2之前,那麼線程2在最外層代碼判斷myinstance!=null為true返回對象引用,但是實際上這時候對象尚未初始化完成,這樣是有問題的,需要通過添加volatile關鍵字去禁止指令重排。
2.volatile的內存語義實現
前面提到過重排序分為編譯器重排序和處理器重排序。為瞭實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。下圖是JMM針對編譯器制定的volatile重排序規則表。
舉例來說,第三行最後一個單元格的意思是:在程序中,當第一個操作為普通變量的讀或寫時,如果第二個操作為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讀插入內存屏障後生成的指令序列示意圖
上圖中LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。
上述volatile寫和volatile讀的內存屏障插入策略非常保守。在實際執行時,隻要不改變 volatile寫-讀的內存語義,編譯器可以根據具體情況省略不必要的屏障。
六、JMM對volatile的特殊規則定義
最後我們再Java內存模型中對volatile變量定義的特殊規則的定義。假定T表示一個線程,V和W分別表示兩個volatile型變量,那麼在進行read、load、use、assign、store和write操作時需要滿足如下規則:
隻有當線程T對變量V執行的前一個動作是load的時候,線程T才能對變量V執行use動作;並且,隻有當線程T對變量V執行的後一個動作是use的時候,線程T才能對變量V執行load動作。線程T對變量V的use動作可以認為是和線程T對變量V的load、read動作相關聯的,必須連續且一起出現。
這條規則要求在工作內存中,每次使用V前都必須先從主內存刷新最新的值,用於保證能看見其他線程對變量V所做的修改。
隻有當線程T對變量V執行的前一個動作是assign的時候,線程T才能對變量V執行store動作;並且,隻有當線程T對變量V執行的後一個動作是store的時候,線程T才能對變量V執行assign動作。線程T對變量V的assign動作可以認為是和線程T對變量V的store、write動作相關聯的,必須連續且一起出現。
這條規則要求在工作內存中,每次修改V後都必須立刻同步回主內存中,用於保證其他線程可以看到自己對變量V所做的修改。
假定動作A是線程T對變量V實施的use或assign動作,假定動作F是和動作A相關聯的load或store動作,假定動作P是和動作F相應的對變量V的read或write動作;與此類似,假定動作B是線程T對變量W實施的use或assign動作,假定動作G是和動作B相關聯的load或store動作,假定動作Q是和動作G相應的對變量W的read或write動作。如果A先於B,那麼P先於Q。
這條規則要求volatile修飾的變量不會被指令重排序優化,從而保證代碼的執行順序與程序的順序相同。
下一篇預告——並發編程三大特性:原子性,可見性,有序性,happen-before原則
到此這篇關於詳解Java volatile 內存屏障底層原理語義的文章就介紹到這瞭,更多相關Java volatile 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Java面試必備之JMM高並發編程詳解
- 詳細分析Java內存模型
- Java內存模型JMM與volatile
- Java並發之原子性 有序性 可見性及Happen Before原則
- JMM核心概念之Happens-before原則