
上一節我們基本了解Volatile的作用,從JMM層面簡單分析了下volatile可見性的實作要求,發現JMM設定了一些操作要求,在這些要求下,可以保證執行緒間的可見性,可是具體實作是怎么實作的呢?
但是你要想理解這個實作是比較難的,之前提到按照三個層面給大家講解,如下圖所示:

其實上一節通過JMM分析volatile是歸于JVM層面分析的一部分而已,
你要想完全弄清楚volatile的可見性和有序性,你還要繼續分析位元組碼層面的JVM指令標記是什么?Hotspot實作的JSR記憶體屏障是什么意思?最終實作的C++代碼發出的匯編指令是什么?以及硬體層面如何實作可見性和有序性的?
所以這一節我們來繼續研究其余的部分,首先從最簡單的一個例子看起,之后手寫出一個DCL單例,通過這個例子我們來真正的弄清楚java代碼層面到JVM層面再到CPU層面的volatile原理,
讓我們開始吧!
從手寫一個DCL單例開始分析volatile
從手寫一個DCL單例開始分析volatile
在寫DCL單例前我們先簡單寫一個volatile的例子,從java代碼和位元組碼層面分析volatile底層原理,代碼如下:
public class DCLVolatile {
volatile int i = 10;
}
你可以在IntelliJ中通過jclasslib插件(自行百度安裝)可以看到編譯后的位元組碼格式,這個volatile變數int i對應的格式如下:

而通常不加volatile的變數,比如int m 的位元組碼標識如下所示:

可以看出在java代碼層面volatile修飾的變數通過javac靜態編譯后,變成了帶有Access flags 0x0040這個特殊標記的變數,這樣之后就可以被JVM識別出來,這里是常量,如果是靜態的instance物件是0x004a,非靜態的是0x0042,
手寫DCL單例,第一步你需要應該宣告一個volatile的實體變數,(后面會將為什么是volatile的,大家不要著急),
代碼如下:
public class DCLVolatile {
private static volatile DCLVolatile instance; //0x004a
}
所以在這個層面你可以得到如下的一張圖:

接著你需要了解一個物件創建的時候的位元組碼指令,以便于之后分析指令重排序的問題,代碼如下:
public class DCLVolatile {
/**
* ByteCode:Access Flag 0x004a
*/
private static volatile DCLVolatile instance;
private DCLVolatile(){
}
/**
* ByteCode:
* 0 new #2 <org/mfm/learn/juc/volatiles/DCLVolatile>
* 3 dup
* 4 invokespecial #3 <org/mfm/learn/juc/volatiles/DCLVolatile.<init>>
* 7 astore_0
* 8 aload_0
* 9 areturn
* @return
*/
public static DCLVolatile getInstance() {
DCLVolatile instance = new DCLVolatile();
return instance;
}
從上面的代碼可以看出 DCLVolatile instance = new DCLVolatile();的位元組碼主要是如下幾行:
0 new #2 <org/mfm/learn/juc/volatiles/DCLVolatile>
3 dup
4 invokespecial #3 <org/mfm/learn/juc/volatiles/DCLVolatile.<init>>
7 astore_0
如果這幾條位元組碼實際就是JVM指令,具體意思可以查閱官方的JVM指令手冊,這里我直接用大白話給大家解釋下:
new 肯定就是創建一個物件,注意這里只是在堆中分配空間,(叫半初始化)此時instance = null,并沒有指向堆空間
dup其實就是入運算元堆疊一個變數instance,
invokespecial其實執行了初始化操作,使用instance參考指向堆分配的空間,
astore_0將一個數值從運算元堆疊存盤到區域變數表,
JVM指令 JVM除了對底層硬體記憶體模型進行了抽象,對執行CPU指令同樣進行了抽象,這樣可以更好地做到跨平臺性, 既然JVM將底層CPU執行指令的程序進行了抽象,這里我們不去細講JVM,抽象的內容大致可以概況為如下一句話: 執行class檔案的時候是通過在記憶體結構,一套復雜的入堆疊出堆疊機制執行class中的各個JVM指令,在執行指令層面,它有自己一套獨特的JVM指令集,而這寫JVM指令就是來源于我們寫好的Java代碼,
上面程序如下圖所示:

你可以接著完善DCL單例最終為:
public class DCLVolatile {
private static volatile DCLVolatile instance;
private DCLVolatile(){
}
public static DCLVolatile getInstance()
if( instance == null){
synchronized (DCLVolatile.class){
if(instance == null){
instance = new DCLVolatile();
}
}
}
return instance;
}
}
上面這段代碼,double判斷+ synchronized+valotile這就是典型的 DCL單例,執行緒安全的,可以保證多個執行緒獲取instance是執行緒安全,且是同一個物件,synchronized是為了保證多執行緒同時創建物件的這個操作的安全性,double判斷+volotile是為了保證這個創建操作的可見性和有序性,
上面的輸出結果證明了這個是執行緒安全的單例,
你可以測驗下:
public static void main(String[] args) {
new Thread(()->{
DCLVolatile instance = DCLVolatile.getInstance();
System.out.println(instance);
}).start();
new Thread(()->{
DCLVolatile instance = DCLVolatile.getInstance();
System.out.println(instance);
}).start();
}
輸出如下:
org.mfm.learn.juc.volatiles.DCLVolatile@71219ecd
org.mfm.learn.juc.volatiles.DCLVolatile@71219ecd
上面的輸出結果證明了這個是執行緒安全的單例,
Java代碼+位元組碼層面分析:為什么會亂序?
Java代碼+位元組碼層面分析:為什么會亂序?
volatile的可見性體現:
instance == null是volatile的讀,instance = new DCLVolatile();是volatile的寫,執行緒之間是可見的,
volatile的有序性體現:
要想知道為什么它保證了有序性,需要了解為什么會有亂序、DCL中,位元組碼亂序了會怎么樣,
一個一個來看下,首先是為什么會亂序?
所有的編程語言最侄訓變成01的機器碼,讓CPU硬體可以認識,你寫的java代碼也一樣,java代碼到CPU執行指令的程序如下圖所示:

圖中標紅色的就是可能指令重排的地方, 因為了提高并發度和指令執行速度,CPU或者編譯器會進行指令的優化和重排,但是我們有時候不希望指令重排,打亂順序可能造成一些有序性問題,這時候就需要一些方法來控制和實作這一點了,Java中volatile關鍵字就是一種方法,
書曰重排序:是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段, 在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是as-if-serial語意允許對存在控制依賴的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果, 其實可以理解為,就是cpu為了優化代碼的執行效率,它不會按順序執行代碼,會打亂代碼的執行順序,前提是不影響單執行緒順序執行的結果,(當然了,只考慮cpu級別的重排序,還有其他的)
Java代碼+位元組碼層面分析:位元組碼亂序了會怎么樣?
Java代碼+位元組碼層面分析:位元組碼亂序了會怎么樣?
了解了為什么會亂序后,接著我們看下位元組碼亂序了會怎么樣?
回到上面的DCL單例的代碼中,上面你了解了創建一個物件的位元組碼后,你需要分析下完善后的getInstance方法位元組碼,如下:
0 getstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
3 ifnonnull 37 (+34)
6 ldc #8 <org/mfm/learn/juc/volatiles/DCLVolatile>
8 dup
9 astore_0
10 monitorenter
11 getstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
14 ifnonnull 27 (+13)
17 new #8 <org/mfm/learn/juc/volatiles/DCLVolatile>
20 dup
21 invokespecial #9 <org/mfm/learn/juc/volatiles/DCLVolatile.<init>>
24 putstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
40 areturn
你可以抓大放小,只關心創建物件的位元組碼:
10 monitorenter
11 getstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
14 ifnonnull 27 (+13)
17 new #8 <org/mfm/learn/juc/volatiles/DCLVolatile>
20 dup
21 invokespecial #9 <org/mfm/learn/juc/volatiles/DCLVolatile.<init>>
24 putstatic #7 <org/mfm/learn/juc/volatiles/DCLVolatile.instance>
27 aload_0
28 monitorexit
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
monitorenter是synchronized的指令,現在可以先忽略,后面我們講Synchronized的時候會詳細講解,
創建物件的位元組核心還是3步
1) 分配空間,半初始化 new
2) 之后進行賦值操作 invokespecial
3) 再之后進行參考指向物件 astore_1
大家可以想象下,如果兩個執行緒同時呼叫getInstance方法,
執行緒1獲取到sychronized的鎖,第一次創建instance的時候,如果2)3)步的指令發生了重排序,如果沒有volatile禁止重排序的話,如下代碼創建的instance就可能不是同一個物件了,
public static DCLVolatile getInstance() {
if( instance == null){
synchronized (DCLVolatile.class){
if(instance == null){
instance = new DCLVolatile();
}
}
}
return instance;
}
執行緒2獲取到了instance可能是一個半初始化的物件,也就是null,直接使用的話肯定會有問題,就會創建一個新的instance,不是單例了,這就是有序性造成的問題,
如下圖所示:

再次從JVM層面分析:JVM指令怎么執行的?
再次從JVM層面分析:JVM指令怎么執行的?
經過上面DCL單例的例子,相信你已經對java代碼到位元組碼的volatile的作用有了進一步了解,具體怎么實作可見性和有序性的根本原理呢?這還是在JVM層面實作的,所以下面,我們接著進入JVM層面來分析,
接下來你會明白上面的JVM指令具體如何執行,由誰執行,又遵循哪些規范和規則?
讓我們來一一看下,
JVM指令具體如何執行
JVM首先就是通過類加載器加載class到JVM記憶體區域,之后又通過執行引擎來執行JVM指令,
不同的過JDK版本有不同的的JVM實作,有耳熟能詳的HotSpot,有淘寶自己的JVM實作,還有J9、OpenJDK等其他的JVM實作……
但JDK1.8后,最常見的就是HotSpot的JVM的實作,它是一套主要以C++代碼為主實作的JVM虛擬機,我們就以HotSpot舉例,
上述程序如下圖所示:

那么,編譯好的位元組碼檔案被JVM通過類加載器加載到記憶體結構之后,會被HotSpot來進行調度和執行對應的JVM指令,
怎么執行的呢?
HotSpot是通過內部的解釋器、JIT動態編譯器(含Client(C1)編譯器、Server(C2)編譯器)來執行JVM指令,
如下圖所示:

HotSpot是JVM規范的一個實作,它遵循了很多JVM虛擬機規范和JSR規范,
什么是規范? 規范可以打個比喻,規范就好比插座的插槽、插頭,它們定義了2孔和3孔的間距等等,所有的廠家都得遵循這個規范,才能讓所有的插頭插入插板,只要這個插頭符合規范,可以是任何牌子,也就是任何廠商的實作,而Java領域有很多規范,一般是由一個公共組織JCP來定義的,定義的規范是JSR-XXX,這個其實也有點像java中的介面和實作類的感覺,說白了就是具體事物的抽象定義,
JVM的虛擬機規范定義了一些規則,和可見性和有序性有關的規則是happen-before 規則:要求8種情況不能亂序執行,(可以自行百度)其中有一條很重要的規則就是:
volatile變數規則:對一個變數的寫操作先行發生于后面對這個變數的讀操作,volatile變數寫,再是讀,必須保證是先寫,再讀,
Java中,其中有一個JSR規范,描述了記憶體屏障相關規范:
-
LoadLoad****屏障:對于這樣的陳述句Load1; LoadLoad; Load2,在Load2及后續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢,
-
StoreStore****屏障:對于這樣的陳述句Store1; StoreStore; Store2,在Store2及后續寫入操作執行前,保證Store1的寫入操作對其它處理器可見,
-
LoadStore****屏障:對于這樣的陳述句Load1; LoadStore; Store2,在Store2及后續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢,
-
StoreLoad****屏障:對于這樣的陳述句Store1; StoreLoad; Load2,在Load2及后續所有讀取操作執行前,保證Store1的寫入對所有處理器可見,
網上有很多博客講解volatile的原理,里面寫的亂七八糟的,讓人看到頭暈眼花,搞不清楚記憶體屏障,JVM指令各種關系,真心讓人看到有些累,4種記憶體屏障其實是規范定義而已,這一點大家一定要搞明白,
在volatile的JVM實作中,是這么使用屏障的,

上面四種記憶體屏障結合happen-before原則,其實就是一句話:
比如LoadLoadBarrier,就是表示上面一條Load指令(讀指令),下面一條Load指令,不能重排序,
那你肯定就知道了StoreLoadBarrier屏障是什么意思,就是表示上面一條Store指令(寫指令),下面一條Load指令,不能重排序,
注意,上面這些規范只是定義,類似于介面,具體怎么實作就得看HotSpot的C++代碼了, 如下圖所示:

Java代碼+位元組碼層面分析:位元組碼亂序了會怎么樣?
再次從JVM層面分析:HotSpot到底怎么禁止重排序的呢?
再次從JVM層面分析:HotSpot到底怎么禁止重排序的呢?
實際是通過一些C++的fense方法,生成一些匯編語言,最終轉換為機器碼,執行CPU指令,所謂的記憶體屏障實際是一條特殊的指令,要求不能換順序,
如下圖所示:

這里我們不去深入HotSopt原始碼,在里面也看不出來發送給CPU的指令,需要通過工具才能看出來,你可以通過JIT生成代碼反匯編工具:(HSDIS),看出來發送給CPU的匯編代碼指令,注意,匯編代碼是給人看到,實際CPU還是識別0/1的機器碼,來執行Cpu指令的,
通過HSDIS工具,可以執行得到如下JIT反匯編語言:!

好了到了這里,基本JVM這一層面的volatile原理,就給大家分析清楚了,可以看到,volatile最侄訓轉換為一條CPU的lock前綴指令,
從CPU層面分析:volatile底層原理
從CPU層面分析:volatile底層原理
JVM不同的實作,對發送給CPU的指令實際都一些差異,而且在歷史上,CPU實作方式也可能不同,主要有如下三種機制:

前一個小節提到了lock前綴指令,是最常提到的的方式,適用于所有CPU,所有CPU都支持這個指令,lock前綴指令的之前是鎖總線這個硬體的傳輸,由于性能太差,后面優化成了總線嗅探機制+MESI協議,這樣好處是可以跨平臺,沒有CPU硬體的各種限制,
據我所知,起碼OpenJDK和HotSpot是使用lock這種方式的這樣的(這個考證起來比較困難,如果這里寫的不對,歡迎各位大神指出!)
除了lock前綴指令,也可以通過一些fence指令做到可見性和有序性的保證,當然耳熟能詳的通過MESI協議也可以做到,
下面我們分別來看下這3種機制,
在了解之前,這里需要回顧下計算機的組成和CPU的硬體快取結構,之前也提到過,CPU的硬體快取結構實際是可以和JMM記憶體邏輯模型對應上的,
我們先來看下,計算機的組成如下圖:

再來看下CPU核心組件圖:

有了上面的2張圖,你就可以知道,實際CPU執行的是通過共享的記憶體:高速快取、RAM記憶體、L3,CPU內部執行緒私有的記憶體L1、L2快取,通過總線從逐層將快取讀入每一級快取,如下流程所示:
RAM記憶體->高速快取(L4一般位于總線)->L3級快取(CPU共享)->L2級快取(CPU內部私有)->L1級快取(CPU內部私有),
這樣當java中多個執行緒執行的時候,實際是交給CPU的每個暫存器執行每一個執行緒,一套暫存器+程式計數器可以執行一個執行緒,平常我們說的4核8執行緒,實際指的是8個暫存器,所以Java多執行緒執行的邏輯對應CPU組件如下圖所示:

當你有了上面幾張圖的概念,就可以理解指令在不同CPU和快取直接作用,
CPU硬體實作可見性和有序性3種機制
系統fence類指令
X86 CPU的可以通過fence類指令實作類似記憶體屏障的操作:
a) sfence:在sfence指令前的寫操作當必須在sfence指令后的寫操作前完成,
b) lfence:在lfence指令前的讀操作當必須在lfence指令后的讀操作前完成,
c) mfence:在mfence指令前的讀寫操作當必須在mfence指令后的讀寫操作前完成,
這種機制不太適用于所有CPU,所以目前不怎么采用了,
- locc前綴指令
IntelCPU lock前綴匯編指令保證有序性,Lock前綴指令幾乎適用于所有CPU,
它的原子指令,如X86的Intel上,local addl XX指令是一個Full Barraier,會鎖住記憶體子系統來確保執行順序,甚至跨多個CPU,SoftwareLocks通常使用了記憶體屏障或者原子指令,來實作變數可見性和保持程式順序,
上面看上去有點難懂,大家這么理解就行:
這個指令最早的時候,其實人家用的是一個叫做總線加鎖機制,目前應該已經沒有人來用了,他大概的意思是說,某個cpu如果要讀一個資料,會通過一個總線,對這個資料加一個鎖,其他的cpu就沒法通過總線去讀和寫這個資料了,只有當這個cpu修改完了以后,其他cpu可以讀到最新的資料,
但是由于這樣多執行緒下會造成串行化,性能低,后來結合lock前綴指令+總線嗅探機制+廣為人知的MESI協議進行了優化,(這里如果說的不準確,大家可以提出來),
所以我們來具體研究下MESI到底通過哪些指令來實作,MESI的機制流程有時如何的,
MESI協議
快取一致性協議有很多,比如除了MESI之外的快取一致性協議還有MSI、MOSI、Synapse Firefly Dragon等等,
這里用的最多的就是MESI這個協議,
什么是MESI協議?
MESI協議規定:對一個共享變數的讀操作可以是多個處理器并發執行的,但是如果是對一個共享變數的寫操作,只有一個處理器可以執行,其實也會通過排他鎖的機制保證就一個處理器能寫,
要想理解這個協議需要具備兩個前提:
-
熟悉MESI的4個指令
-
熟悉CUP結構和快取行的資料結構
首先先來了解下快取行的概念:
快取行默認是64位元組Byte,(程式區域性原理,當讀取一條資料的時候,也會讀取它附近的元素,很大可能會用到)經過工業界實踐,可以充分發揮總線CPU針腳等一次性讀取資料的能力,提高效率,
一般情況,快取行的基本單位是一個64位元組的資料,用于在L1、L2、L3、高速快取Cache間傳輸資料,
處理器高速快取的底層資料結構實際是一個拉鏈散串列的結構,就是有很多個bucket,每個bucket掛了很多的cache entry,每個cache entry由三個部分組成:tag、cache line和flag,其中的cache line就是快取的資料,
tag指向了這個快取資料在主記憶體中的資料的地址,flag標識了快取行的狀態,另外要注意的一點是,cache line中可以包含多個變數的值,

接著再來了解下MESI的4個指令:
MESI協議規定了一組訊息,就說各個處理器在操作記憶體資料的時候,都會往總線發送訊息,而且各個處理器還會不停的從總線嗅探最新的訊息,通過這個總線的訊息傳遞來保證各個處理器的協作,
之前說過那個cache entry的flag代表了快取資料的狀態,MESI協議中劃分為:
(1)invalid:無效的,標記為I,這個意思就是當前cache entry無效,里面的資料不能使用
(2)shared:共享的,標記為S,這個意思是當前cache entry有效,而且里面的資料在各個處理器中都有各自的副本,但是這些副本的值跟主記憶體的值是一樣的,各個處理器就是并發的在讀而已
(3)exclusive:獨占的,標記為E,這個意思就是當前處理器對這個資料獨占了,只有他可以有這個副本,其他的處理器都不能包含這個副本
(4)modified:修改過的,標記為M,只能有一個處理器對共享資料更新,所以只有更新資料的處理器的cache entry,才是exclusive狀態,表明當前執行緒更新了這個資料,這個副本的資料跟主記憶體是不一樣的
到底底層是如何實作這套MESI的機制,通過哪些指令,這個指令干了什么事情,才能保證說,我剛才說的那種效果,修改本地快取,立馬刷主存,其他cpu本地快取立馬工期,重新從主存加載,
下面來詳細的圖解MESI協議的作業原理:
讀I->S
處理器0讀取某個變數的資料時,首先會根據index、tag和offset從高速快取的拉鏈散串列讀取資料,如果發現狀態為I,也就是無效的,此時就會發送read訊息到總線
接著主記憶體會回傳對應的資料給處理器0,處理器0就會把資料放到高速快取里,同時cache entry的flag狀態是S,如下圖所示:

CPU1:S->I->I-ack
在處理器0對一個資料進行更新的時候,如果資料狀態是S,則此時就需要發送一個invalidate訊息到總線,嘗試讓其他的處理器的高速快取的cache entry全部變為I,以獲得資料的獨占鎖,
其他的處理器1會從總線嗅探到invalidate訊息,此時就會把自己的cache entry設定為I,也就是過期掉自己本地的快取,然后就是回傳invalidate ack訊息到總線,傳遞回處理器0,處理器0必須收到所有處理器回傳的ack訊息
CPU0:S->I-ack->E->M
接著處理器0就會將cache entry先設定為E,獨占這條資料,在獨占期間,別的處理器就不能修改資料了,因為別的處理器此時發出invalidate訊息,這個處理器0是不會回傳invalidate ack訊息的,除非他先修改完再說
接著處理器0就是修改這條資料,接著將資料設定為M,也有可能是把資料此時強制寫回到主記憶體中,具體看底層硬體實作
然后其他處理器此時這條資料的狀態都是I了,那如果要讀的話,全部都需要重新發送read訊息,從主記憶體(或者是其他處理器)來加載,這個具體怎么實作要看底層的硬體了,都有可能的,
上述程序如下圖所示:

這套機制其實就是快取一致性在硬體快取模型下的完整的執行原理,
小結
到這里我們從三個層面,Java代碼和位元組碼->JVM層->CPU硬體原理層面,剖析了Volatile底層原理,相信大家對它的可見性、有序性深刻的理解,
這一節涉及的知識特別多,也特別燒腦,大家理解了它的原理之后,更重要的是記住它的使用場景,我給大家總結如下:
原理:
一句話簡單概括volatile的原理:就是重繪主記憶體,強制過期其他執行緒的作業記憶體,你可以在不同層面解釋:
在java代碼層面
場景:
1、 多個執行緒對同一個變數有讀有寫的時候
2、 多個執行緒需要保證有序性和可見性的時候
除了DCL單例,還有執行緒的優雅關閉這些場景,大家可以在評論去發表自己遇見過的場景,
本文由博客群發一文多發等運營工具平臺 OpenWrite 發布
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/331951.html
標籤:Java
