1. 程式人生 > >架構師五分鐘帶你讀懂,Volatile的作用及原理

架構師五分鐘帶你讀懂,Volatile的作用及原理

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

文章簡介

分析volatile的作用以及底層實現原理,這也是大公司喜歡問的問題

內容導航

  • volatile的作用
  • 什麼是可見性
  • volatile原始碼分析

一、volatile的作用

在多執行緒中,volatile和synchronized都起到非常重要的作用,synchronized是通過加鎖來實現執行緒的安全性。而volatile的主要作用是在多處理器開發中保證共享變數對於多執行緒的可見性。

可見性的意思是,當一個執行緒修改一個共享變數時,另外一個執行緒能讀取到修改以後的值。接下來通過一個簡單的案例來演示可見性問題

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

  • 定義一個共享變數 stop
  • 在main執行緒中建立一個子執行緒 thread,子執行緒讀取到 stop的值做迴圈結束的條件
  • main執行緒中修改stop的值為 true
  • 當 stop沒有增加volatile修飾時,子執行緒對於主執行緒的 stop=true的修改是不可見的,這樣將導致子執行緒出現死迴圈
  • 當 stop增加了volatile修飾時,子執行緒可以獲取到主執行緒對於 stop=true的值,子執行緒while迴圈條件不滿足退出迴圈

增加volatile關鍵字以後,main執行緒對於共享變數 stop值的更新,對於子執行緒 thread可見,這就是volatile的作用

這段程式碼有些人測試不出效果,是因為JVM沒有優化導致的,在cmd控制檯輸入java -version,如果顯示的是 JavaHotSpot(TM)**ServerVM,就能正常演示,如果是 JavaHotSpot(TM)**ClientVM,需要設定成 Server模式

什麼是可見性,以及volatile是如何保證可見性的呢?

二、什麼是可見性

在併發程式設計中,執行緒安全問題的本質其實就是 原子性、有序性、可見性;接下來主要圍繞這三個問題進行展開分析其本質,徹底瞭解可見性的特性

  • 原子性 和資料庫事務中的原子性一樣,滿足原子性特性的操作是不可中斷的,要麼全部執行成功要麼全部執行失敗
  • 有序性 編譯器和處理器為了優化程式效能而對指令序列進行重排序,也就是你編寫的程式碼順序和最終執行的指令順序是不一致的,重排序可能會導致多執行緒程式出現記憶體可見性問題
  • 可見性 多個執行緒訪問同一個共享變數時,其中一個執行緒對這個共享變數值的修改,其他執行緒能夠立刻獲得修改以後的值

為了徹底瞭解這三個特性,我們從兩個層面來分析,第一個層面是硬體層面、第二個層面是JMM層面

從硬體層面分析三大特性

原子性、有序性、可見性這些問題,我們可以認為是基於多核心CPU架構下的存在的問題。因為在單核CPU架構下,所有的執行緒執行都是基於CPU時間片切換,所以不存在併發問題 (在IntelPentium4開始,引入了超執行緒技術,也就是一個CPU核心模擬出2個執行緒的CPU,實現多執行緒並行)。

CPU快取記憶體執行緒設計的目的是充分利用CPU達到實時性的效果,但是很多時候CPU的計算任務還需要和記憶體進行互動,比如讀取記憶體中的運算資料、將處理結果寫入到記憶體。在理想情況下,儲存器應該是非常快速的執行一條指令,這樣CPU就不會受到儲存器的限制。但目前技術無法滿足,所以就出現了其他的處理方式。

架構師五分鐘帶你讀懂,Volatile的作用及原理

 


儲存器頂層是CPU中的暫存器,儲存容量小,但是速度和CPU一樣快,所以CPU在訪問暫存器時幾乎沒有延遲;接下來就是CPU的快取記憶體;最後就是記憶體。

架構師五分鐘帶你讀懂,Volatile的作用及原理

 


快取記憶體從下到上越接近CPU訪問速度越快,同時容量也越小。現在的大部分處理器都有二級或者三級快取,分別是L1/L2/L3, L1又分為L1-d的資料快取和L1-i的指令快取。其中L3快取是在多核CPU之間共享的。

原子性

在多核CPU架構下,在同一時刻對同一共享變數執行 decl指令(遞減指令,相當於i--,它分為三個過程:讀->改->寫,這個指令涉及到兩次記憶體操作,那麼在這種情況下i的結果是無法預測的。這就是原子性問題

處理器如何解決原子性問題呢?

其實這個問題稍微提煉一下,無非就是多執行緒並行訪問同一個共享資源的時候的原子性問題,如果把問題放大到分散式架構裡面,這個問題的解決方法就是鎖。所以在CPU層面,提供了兩種鎖的機制來保證原子性

匯流排鎖

如果多個處理器同時對同一共享變數進行 decl指令操作,那這個操作一定不是原子的,也就是執行的結果和預期結果不一致。如下圖所示,我們期望的結果是3,但是有可能結果是2

 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

如果要解決這個問題,就需要是的CPU0在更新共享變數時,CPU1就不能操作快取了該共享變數記憶體地址的快取,所以處理器提供了匯流排鎖來解決問題,處理器會提供一個LOCK#訊號,當一個處理器在總線上輸出這個訊號時,其他處理器的請求會被阻塞,那麼該處理器就可以獨佔共享記憶體

匯流排鎖有一個弊端,匯流排鎖相當於使得多個CPU由並行執行變成了序列,使得CPU的效能嚴重下降,所以在P6系列以後的處理器中,引入了快取鎖。

快取鎖

我們只需要保證 多個執行緒操作同一個被快取的共享資料的原子性就行,所以只需要鎖定被快取的共享物件即可。所謂快取鎖是指被快取在處理器中的共享資料,在Lock操作期間被鎖定,那麼當被修改的共享記憶體的資料回寫到記憶體時,處理器不在總線上宣告LOCK#訊號,而是修改內部的記憶體地址,並通過 快取一致性機制來保證操作的原子性。

什麼是快取一致性呢?

快取一致性所謂快取一致性,就是多個CPU核心中快取的同一共享資料的資料一致性,而(MESI)使用比較廣泛的快取一致性協議。MESI協議實際上是表示快取的四種狀態
  • M(Modify) 表示共享資料只快取在當前CPU快取中,並且是被修改狀態,也就是快取的資料和主記憶體中的資料不一致
  • E(Exclusive) 表示快取的獨佔狀態,資料只快取在當前CPU快取中,並且沒有被修改
  • S(Shared) 表示資料可能被多個CPU快取,並且各個快取中的資料和主記憶體資料一致
  • I(Invalid) 表示快取已經失效
 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 


每個CPU核心不僅僅知道自己的讀寫操作,也會監聽其他Cache的讀寫操作CPU的讀取會遵循幾個原則
  • 如果快取的狀態是I,那麼就從記憶體中讀取,否則直接從快取讀取
  • 如果快取處於M或者E的CPU 嗅探到其他CPU有讀的操作,就把自己的快取寫入到記憶體,並把自己的狀態設定為S
  • 只有快取狀態是M或E的時候,CPU才可以修改快取中的資料,修改後,快取狀態變為M

可見性

CPU快取記憶體以及指令重排序都會造成可見性問題,接下來從兩個角度來分析

MESI優化帶來的可見性問題

前面說過MESI協議,也就是快取一致性協議。這個協議存在一個問題,就是當CPU0修改當前快取的共享資料時,需要傳送一個訊息給其他快取了相同資料的CPU核心,這個訊息傳遞給其他CPU核心以及收到訊息完成各自快取狀態的切換這個過程中,CPU會等待所有快取響應完成,這樣會降低處理器的效能。為了解決這個問題,引入了 StoreBufferes儲存快取。


處理器把需要寫入到主記憶體中的值先寫入到儲存快取中,然後繼續去處理其他指令。當所有的CPU核心返回了失效確認時,資料才會被最終提交。但是這種優化又會帶來另外的問題。

如果某個CPU嘗試將其他CPU佔有的共享資料寫入到記憶體,訊息提交給store buffer以後,當前CPU繼續做其他事情,而如果後面的指令依賴於這個被寫入記憶體的最新資料(由於store buffer還沒有寫入到記憶體),就會產生可見性問題(也就是值還沒有更新到記憶體中,這個時候讀取到的共享資料的值是錯誤的)。

Store Bufferes帶來的CPU記憶體的亂序訪問導致的可見性問題

Store Bufferes中的資料何時寫入到記憶體中是不確定的,那麼意味著這個過程的執行順序也是不確定的,比如下面這個例子

exeToCPU0和exeToCPU1分別在兩個獨立的cpu核心上執行,假如CPU0 快取了 isFinish這個共享變數,並且狀態為(E->獨佔),而value可能是(S共享狀態被其他CPU核心修改以後變為I(失效狀態)。

這種情況下value的快取資料變更路徑為, value將失效狀態需要響應給觸發快取更新的CPU核心,接著該CPU將 StoreBufferes寫入到記憶體,這就會導致value會比isFinish更遲的拋棄儲存快取。那麼就可能出現CPU1讀取到了isFinish的值為true,而value的值不等於10的情況。

這種CPU的記憶體亂序訪問,會帶來可見性問題。

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

從硬體層面很難去知道軟體層面上的這種前後依賴關係,所以沒有辦法通過某種手段自動去解決,因此CPU層面提供了 memory barrier(記憶體屏障)的指令,從硬體層面來看這個 memroy barrier就是CPU flush store bufferes中的指令。軟體層面可以決定在適當的地方來插入記憶體屏障。

CPU層面的記憶體屏障什麼是記憶體屏障?從前面的內容基本能有一個初步的猜想,記憶體屏障就是將 store bufferes中的指令寫入到記憶體,從而使得其他訪問同一共享記憶體的執行緒的可見性。X86的memory barrier指令包括lfence(讀屏障) sfence(寫屏障) mfence(全屏障)
  • Store Memory Barrier(寫屏障) 告訴處理器在寫屏障之前的所有已經儲存在儲存快取(store bufferes)中的資料同步到主記憶體,簡單來說就是使得寫屏障之前的指令的結果對屏障之後的讀或者寫是可見的
  • Load Memory Barrier(讀屏障) 處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的記憶體更新對於讀屏障之後的讀操作是可見的
  • Full Memory Barrier(全屏障) 確保屏障前的記憶體讀寫操作的結果提交到記憶體之後,再執行屏障後的讀寫操作

有了記憶體屏障以後,對於上面這個例子,我們可以這麼來改,從而避免出現可見性問題

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

總的來說,記憶體屏障的作用可以通過防止CPU對記憶體的亂序訪問來保證共享資料在多執行緒並行執行下的可見性

有序性

有序性簡單來說就是程式程式碼執行的順序是否按照我們編寫程式碼的順序執行,一般來說,為了提高效能,編譯器和處理器會對指令做重排序,重排序分3類

  • 編譯器優化重排序,在不改變單執行緒程式語義的前提下,改變程式碼的執行順序
  • 指令集並行的重排序,對於不存在資料依賴的指令,處理器可以改變語句對應指令的執行順序來充分利用CPU資源
  • 記憶體系統的重排序,也就是前面說的CPU的記憶體亂序訪問問題

也就是說,我們編寫的原始碼到最終執行的指令,會經過三種重排序

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

有序性會帶來可見性問題,所以可以通過記憶體屏障指令來進位制特定型別的處理器重排序

從JMM層面解決執行緒併發問題

從硬體層面的分析瞭解到原子性、有序性、可見性的本質以後,知道硬體層面針對這三個問題的解決辦法,原子性是通過匯流排鎖或快取鎖來實現,而有序性和可見性可以通過記憶體屏障來解決。那麼在軟體層面,如何解決原子性、有序性、可見性問題呢?答案就是: JMM(JavaMemoryModel)記憶體模型

硬體層面的原子性、有序性、可見性在不同的CPU架構和作業系統中的實現可能都不一樣,而Java語言的特性是 write once,run anywhere,意味著JVM層面需要遮蔽底層的差異,因此在JVM規範中定義了JMM。

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

(JMM記憶體模型的抽象結構)

JMM屬於語言級別的抽象記憶體模型,可以簡單理解為對硬體模型的抽象,它定義了共享記憶體中多執行緒程式讀寫操作的行為規範,也就是在虛擬機器中將共享變數儲存到記憶體以及從記憶體中取出共享變數的底層細節。通過這些規則來規範對記憶體的讀寫操作從而保證指令的正確性,它解決了CPU多級快取、處理器優化、指令重排序導致的記憶體訪問問題,保證了併發場景下的可見性。需要注意的是,JMM並沒有限制執行引擎使用處理器的暫存器或者快取記憶體來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在JMM中,也會存在快取一致性問題和指令重排序問題。只是JMM把底層的問題抽象到JVM層面,再基於CPU層面提供的記憶體屏障指令,以及限制編譯器的重排序來解決併發問題

Java記憶體模型定義了執行緒和記憶體的互動方式,在JMM抽象模型中,分為主記憶體、工作記憶體;主記憶體是所有執行緒共享的,一般是例項物件、靜態欄位、陣列物件等儲存在堆記憶體中的變數。工作記憶體是每個執行緒獨佔的,執行緒對變數的所有操作都必須在工作記憶體中進行,不能直接讀寫主記憶體中的變數,執行緒之間的共享變數值的傳遞都是基於主記憶體來完成。

在JMM中,定義了8個原子操作來實現一個共享變數如何從主記憶體拷貝到工作記憶體,以及如何從工作記憶體同步到主記憶體,互動如下

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

8個原子操作指令 lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。 unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。 read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用 load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。 use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。 assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。 store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。 write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

順序一致性

如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行read和load操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store和write操作。JMM只要求這兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是read和load之間,store和write之間是可以插入其他指令的,如對主記憶體中的變數a、b進行訪問時,可能的順序是read a,read b,load b, load a。

JMM不保證未同步程式的執行結果與該程式在順序一致性模型中的執行結果一致,因為如果想要保證執行結果一致,意味著JMM需要進位制處理器和編譯器的優化,這對於程式的執行效能會產生很大的影響。所以在未同步程式的執行中,由於執行順序的不確定性導致結果無法預測。我們可以使用同步原語比如 synchronized,volatile、final來實現程式的同步操作來保證順序一致性

假如有兩個執行緒A和B並行執行,A和B執行緒分別都有3個操作,在程式中的順序是 A1->A2->A3, B1->B2->B3。

假設這兩個程式沒有使用同步原語,那麼執行緒並行執行的效果可能是

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

(此圖來自併發程式設計的藝術)如果這兩個程式使用了監視器鎖來實現正確同步,那麼執行的過程一定是

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

(此圖來自併發程式設計的藝術)

重排序

CPU層面的記憶體亂序訪問屬於重排序的一部分,同時我們還提到了編譯器的優化執行的重排序。重排序是一種優化手段,但是在多執行緒併發中,會導致可見性問題。

編譯器的重排序是指,在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序來優化程式的效能.

編譯器的重排序和CPU的重排序的原則一樣,會遵守資料依賴性原則,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序,比如下面的程式碼,這三種情況在單執行緒裡面如果改變程式碼的執行順序,都會導致結果不一致,所以重排序不會對這類的指令做優化,也就是需要滿足 as-if-serial語義

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

as-if-serial語義as-if-serial語義的意思是不管怎麼重排序,單執行緒程式的執行結果不能被改變,編譯器、處理器都必須遵守這個語義JMM層面的記憶體屏障為了保證記憶體可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障來禁止特定型別的處理器的重排序,在JMM中把記憶體屏障分為四類

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

屏障的作用這裡就不重複再說了,實際上JMM層面的記憶體屏障就是對CPU層面的記憶體屏障指令做的包裝,作用是通過在合適的位置插入記憶體屏障來保證可見性

JVM是如何在JMM層面解決原子性、有序性、可見性問題的呢?

相信通過上面的分析,基本上有了答案

  • 原子性:Java中提供了兩個高階指令 monitorenter和 monitorexit,也就是對應的synchronized同步鎖來保證原子性
  • 可見性:volatile、synchronized、final都可以解決可見性問題
  • 有序性:synchronized和volatile可以保證多執行緒之間操作的有序性,volatile會禁止指令重排序

三、volatile原始碼分析

如果你看到這個章節了,意味著你對可見性有一個清晰的認識了,也知道JMM是基於禁止指令重排序來實現可見性的,那麼我們再來分析volatile的原始碼,就會簡單很多

基於最開始演示的這段程式碼作為入口

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

通過 javap-vVolatileDemo.class檢視位元組碼指令

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

注意被修飾了volatile關鍵字的 stop欄位,會多一個 ACC_VOLATILE的flag,在給 stop複製的時候,呼叫的位元組碼是 putstatic,這個位元組碼會通過BytecodeInterpreter直譯器來執行,找到Hotspot的原始碼 bytecodeInterpreter.cpp檔案,搜尋 putstatic指令定位到程式碼

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

其他程式碼不用管,直接看 cache->is_volatile()這段程式碼,cache是 stop在常量池快取中的一個例項,這段程式碼是判斷這個cache是否是被 volatile修飾, is_volatile()方法的定義在 accessFlags.hpp檔案中,程式碼如下

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

is_volatile是判斷是否有 ACC_VOLATILE這個flag,很顯然,通過 volatile修飾的stop的位元組碼中是存在這個flag的,所以 is_volatile()返回true

接著,根據當前欄位的型別來給 stop賦值,執行 release_byte_field_put方法賦值,這個方法的實現在 oop.inline.hpp中

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

賦值的動作被包裝了一層,看看 OrderAccess::release_store做了什麼事情呢?這個方法的定義在 orderAccess.hpp中,具體的實現,根據不同的作業系統和CPU架構,呼叫不同的實現

 

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

 

以 orderAccess_linux_x86.inline.hpp為例,找到 OrderAccess::release_store的實現,程式碼如下

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

可以看到其實Java的volatile操作,在JVM實現層面第一步是給予了C++的原語實現。c/c++中的volatile關鍵字,用來修飾變數,通常用於語言級別的 memory barrier。被volatile宣告的變量表示隨時可能發生變化,每次使用時,都必須從變數i對應的記憶體地址讀取,編譯器對操作該變數的程式碼不再進行優化

賦值操作完成以後,如果大家仔細看了前面putstatic的程式碼,就會發現還會執行一個 OrderAccess::storeload();的程式碼,這個程式碼的實現是在 orderAccess_linux_x86.inline.hpp,它其實就是一個storeload記憶體屏障,JVM層面的四種記憶體屏障的定義以及實現

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

當呼叫 storeload屏障時,它會呼叫fence()方法

架構師五分鐘帶你讀懂,Volatile的作用及原理

 

os::is_MP()判斷是否是多核,如果是單核,那麼就不存在記憶體不可見或者亂序的問題 __volatile__:禁止編譯器對程式碼進行某些優化.

Lock :彙編指令,lock指令會鎖住操作的快取行(cacheline), 一般用於read-Modify-write的操作;用來保證後續的操作是原子的

cc代表的是暫存器,memory代表是記憶體;這邊同時用了”cc”和”memory”,來通知編譯器記憶體或者暫存器內的內容已經發生了修改,要重新生成載入指令(不可以從快取暫存器中取)

這邊的read/write請求不能越過lock指令進行重排,那麼所有帶有lock prefix指令(lock ,xchgl等)都會構成一個天然的x86 Mfence(讀寫屏障),這裡用lock指令作為記憶體屏障,然後利用asm volatile("" ::: "cc,memory")作為編譯器屏障. 這裡並沒有使用x86的記憶體屏障指令(mfence,lfence,sfence),應該是跟x86的架構有關係,x86處理器是強一致記憶體模型

storeload屏障是固定呼叫的方法?為什麼要固定呼叫呢?

原因是:避免volatile寫與後面可能有的volatile讀/寫操作重排序。因為編譯器常常無法準確判斷在一個volatile寫的後面是否需要插入一個StoreLoad屏障。為了保證能正確實現volatile的記憶體語義,JMM在採取了保守策略:在每個volatile寫的後面,或者在每個volatile讀的前面插入一個StoreLoad屏障。因為volatile寫-讀記憶體語義的常見使用模式是:一個寫執行緒寫volatile變數,多個讀執行緒讀同一個volatile變數。當讀執行緒的數量大大超過寫執行緒時,選擇在volatile寫之後插入StoreLoad屏障將帶來可觀的執行效率的提升。從這裡可以看到JMM在實現上的一個特點:首先確保正確性,然後再去追求執行效率

volatile是通過防止指令重排序來實現多執行緒對於共享記憶體的可見性

總結

到目前為止,我們已經算是比較深入的分析了volatile的作用和原理,由於這個領域確實涉及太多內容,所有有些東西可以沒有在文章中體現,大家如果有興趣建議系統再看看.

推薦一個交流學習交流圈子:142019080 裡面會分享一些資深架構師錄製的視訊錄影:有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化這些成為架構師必備的知識體系。還能領取免費的學習資源,目前受益