上一節我們基本瞭解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
從上面的程式碼可以看出 DCLVolatile instance = new DCLVolatile();的位元組碼主要是如下幾行:
0 new #2
如果這幾條位元組碼實際就是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
你可以抓大放小,只關心建立物件的位元組碼:
10 monitorenter 11 getstatic #7
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中,其中有一個
**變數寫,再是讀,必須保證是先寫,再讀。
,描述了記憶體屏障相關規範:
1)
JSR規範
屏障:**對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。
2)
LoadLoad**
屏障:**對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
3)
StoreStore**
屏障:**對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的資料被讀取完畢。
4)
LoadStore**
屏障:**對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。
網上有很多部落格講解volatile的原理,裡面寫的亂七八糟的,讓人看到頭暈眼花。搞不清楚記憶體屏障,JVM指令各種關係。真心讓人看到有些累。4種記憶體屏障其實是規範定義而已,這一點大家一定要搞明白。
在volatile的JVM實現中,是這麼使用屏障的。
StoreLoad**
上面四種記憶體屏障結合happen-before原則,其實就是一句話:
比如LoadLoadBarrier,就是表示上面一條Load指令(讀指令),下面一條Load指令,不能重排序。
那你肯定就知道了StoreLoadBarrier屏障是什麼意思。就是表示上面一條Store指令(寫指令),下面一條Load指令,不能重排序。
Java程式碼+位元組碼層面分析:位元組碼亂序了會怎麼樣?
再次從JVM層面分析:HotSpot到底怎麼禁止重排序的呢?
再次從JVM層面分析:HotSpot到底怎麼禁止重排序的呢?
注意,上面這些規範只是定義,類似於介面,具體怎麼實現就得看HotSpot的C++程式碼了。 如下圖所示:
如下圖所示:
這裡我們不去深入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種機制。
在瞭解之前,這裡需要回顧下
實際是透過一些C++的fense方法,生成一些組合語言,最終轉換為機器碼,執行CPU指令。所謂的記憶體屏障實際是一條特殊的指令,要求不能換順序。
,之前也提到過,CPU的硬體快取結構實際是可以和JMM記憶體邏輯模型對應上的。
我們先來看下,
計算機的組成和CPU的硬體快取結構
如下圖:
計算機的組成
有了上面的2張圖,你就可以知道,實際CPU執行的是透過共享的記憶體:快取記憶體、RAM記憶體、L3,CPU內部執行緒私有的記憶體L1、L2快取,透過匯流排從逐層將快取讀入每一級快取。如下流程所示:
再來看下CPU核心元件圖:
這樣當java中多個執行緒執行的時候,實際是交給CPU的每個暫存器執行每一個執行緒。一套暫存器+程式計數器可以執行一個執行緒,平常我們說的4核8執行緒,實際指的是8個暫存器。所以Java多執行緒執行的邏輯對應CPU元件如下圖所示:
當你有了上面幾張圖的概念,就可以理解指令在不同CPU和快取直接作用。
RAM記憶體->快取記憶體(L4一般位於匯流排)->L3級快取(CPU共享)->L2級快取(CPU內部私有)->L1級快取(CPU內部私有)。
CPU硬體實現可見性和有序性3種機制
X86 CPU的可以透過fence類指令實現類似記憶體屏障的操作:
a) sfence:在sfence指令前的寫操作當必須在sfence指令後的寫操作前完成。
b) lfence:在lfence指令前的讀操作當必須在lfence指令後的讀操作前完成。
c) mfence:在mfence指令前的讀寫操作當必須在mfence指令後的讀寫操作前完成。
系統fence類指令
這種機制不太適用於所有CPU,所以目前不怎麼採用了。
locc字首指令
它的原子指令,如X86的Intel上,local addl XX指令是一個Full Barraier,會鎖住記憶體子系統來確保執行順序,甚至跨多個CPU。SoftwareLocks通常使用了記憶體屏障或者原子指令,來實現變數可見性和保持程式順序。
上面看上去有點難懂,大家這麼理解就行:
這個指令最早的時候,其實人家用的是一個叫做匯流排加鎖機制。目前應該已經沒有人來用了,他大概的意思是說,某個cpu如果要讀一個數據,會透過一個匯流排,對這個資料加一個鎖,其他的cpu就沒法透過匯流排去讀和寫這個資料了,只有當這個cpu修改完了以後,其他cpu可以讀到最新的資料。
但是由於這樣多執行緒下會造成序列化,效能低,後來結合
IntelCPU lock字首彙編指令保證有序性。Lock字首指令幾乎適用於所有CPU。
進行了最佳化。(這裡如果說的不準確,大家可以提出來)。
所以我們來具體研究下MESI到底透過哪些指令來實現,MESI的機制流程有時如何的。
lock字首指令+匯流排嗅探機制+廣為人知的MESI協議
快取一致性協議有很多,比如除了MESI之外的快取一致性協議還有MSI、MOSI、Synapse Firefly Dragon等等。
這裡用的最多的就是MESI這個協議。
MESI協議
MESI協議規定:對一個共享變數的讀操作可以是多個處理器併發執行的,但是如果是對一個共享變數的寫操作,只有一個處理器可以執行,其實也會透過排他鎖的機制保證就一個處理器能寫。
要想理解這個協議需要具備兩個前提:
1) 熟悉MESI的4個指令
2) 熟悉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協議?
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訊息,從主記憶體(或者是其他處理器)來載入,這個具體怎麼實現要看底層的硬體了,都有可能的。
上述過程如下圖所示:
這套機制其實就是快取一致性在硬體快取模型下的完整的執行原理。
接著再來了解下MESI的4個指令:
到這裡我們從三個層面,Java程式碼和位元組碼->JVM層->CPU硬體原理層面,剖析了Volatile底層原理,相信大家對它的可見性、有序性深刻的理解。
這一節涉及的知識特別多,也特別燒腦,大家理解了它的原理之後,更重要的是記住它的使用場景。我給大家總結如下:
原理:
小結
一句話簡單概括volatile的原理:就是重新整理主記憶體,強制過期其他執行緒的工作記憶體。你可以在不同層面解釋:
場景:
在java程式碼層面
1、
多個執行緒對同一個變數有讀有寫的時候
2、
除了DCL單例,還有執行緒的優雅關閉這些場景,大家可以在評論去發表自己遇見過的場景。
本文由部落格一文多發平臺 OpenWrite 釋出!