java基礎---volatile底層實現原理詳解


 

大家都知道生產中可以使用volatile達到保證可見性和指令重排的目的。但是對其實現原理並不是很清楚,為了加深學習和理解感覺很有必要來寫篇博客總結一下。

JMM—java內存模型

想知道volatile實現原理首先得去了下解JMM,我們都知道JVM會為每一個thread開辟一塊自己的工作空間,在我們操作變量時是從主內存拿到變量的一個副本,然后對副本進行操作后再刷新到主內存中這么一個總體的流程。
在這里插入圖片描述
先簡單來看一下如果要改變一個變量值需要經過哪些操作:
1.首先會執行一個read操作將主內存中的值讀取出來
2.執行load操作將值副本寫入到工作內存中
3.當前線程執行user操作將工作內存中的值拿出在經過執行引擎運算
4.將運算后的值進行assign操作寫會到工作內存。
5.線程將當前工作內存中新的值存儲回主內存,注意只是此處還沒有寫入到主內存的共享變量,主內存中的值還沒有改變。
6.最后一步執行write操作將主內存中的值刷新到共享變量,到此處一個簡單的流程就走完了。

下圖的8種操作是定義在java內存模型當中的,我們的任何操作都需要通過這幾種方式來進行。
在這里插入圖片描述
簡單看了一下操作流程后繼續回到volatile關鍵字,在多個個線程工作內存看起來互無關聯的情況下是怎么做到保證變量的可見性的?

這里我們不得不先去了解一個名詞:總線 ------什么是總線?它是干什么的?

度娘給出的解釋: 由於總線是連接各個部件的一組信號線。通過信號線上的信號表示信息,通過約定不同信號的先后次序即可約定操作如何實現。簡單來說就是我們的cpu和內存進行交互就得通過總線,它們不能隔空產生連接。總線就是一條共享的通信鏈路,它用一套線路來連接多個子系統。

總線按功能和規范可分為五大類型:

  • 數據總線(Data Bus):在CPU與RAM之間來回傳送需要處理或是需要儲存的數據。
  • 地址總線(Address Bus):用來指定在RAM(Random Access Memory)之中儲存的數據的地址。
  • 控制總線(Control Bus):將微處理器控制單元(Control Unit)的信號,傳送到周邊設備。
  • 擴展總線(Expansion Bus):外部設備和計算機主機進行數據通信的總線,例如ISA總線,PCI總線。
  • 局部總線(Local Bus):取代更高速數據傳輸的擴展總線。

最初實現就是通過總線加鎖的方式也就是上面的lock與unlock操作,但是這種方式存在很大的弊端。會將我們的並行轉換為串行,從而失去了多線程的意義。這里不詳細展開了解一下即可。下面才是我們真正需要認識的

MESI緩存一致性協議:

CPU在摩爾定律的指導下以每18個月翻一番的速度在發展,然而內存和硬盤的發展速度遠遠不及CPU。這就造成了高性能能的內存和硬盤價格及其昂貴。然而CPU的高度運算需要高速的數據。為了解決這個問題,CPU廠商在CPU中內置了少量的高速緩存以解決I\O速度和CPU運算速度之間的不匹配問題。為了解決這個問題CPU廠商采用了緩存的解決方案,知道目前我們正在使用的多級的緩存結構。我們可以到任務管理器看一下:
在這里插入圖片描述
目前流行的多級緩存結構:
在這里插入圖片描述
多核CPU的情況下有多個一級緩存,如何保證緩存內部數據的一致,不讓系統數據混亂。這里就引出了一個一致性的協議MESI。這里我們大概只需要有這么一個概念就可以。而當我們共享變量用volatile修飾后就會幫我們在總線開啟一個MESI緩存協議。同時會開啟CPU總線嗅探機制(監聽),當其中一個線程將修改后的值傳輸到總線時,就會觸發總線的監控告訴其他正在使用當前共享變量的線程使當前工作內存中的值失效。這個時候工作空間中的副本變量就沒有了,從而需要重新去獲取新的值。

底層實現主要是通過匯編Lock前綴指令,它會鎖定這塊內存區域的緩存(緩存行鎖定)並寫回到主內存。總的來說就是Lock指令會將當前處理器緩存行數據立即寫回到系統內存從而保證多線程數據緩存的時效性。這個寫回內存的操作同時會引起在其它CPU里緩存了該內存地址的數據失效(MESI協議)。

為了保證在從工作內存刷新回主內存這個階段主內存數據的安全性,在store前會使用內存模型當中的lock操作來鎖定當前主內存中的共享變量。當主內存變量在write操作后才會將當前lock釋放掉,別的線程才能繼續進來獲取新的值。

查看Java的匯編指令: 想要實際去看下底層的匯編指令,需要在jre/bin目錄下添加額外的兩個包
下載鏈接:百度網盤鏈接,提取碼:d753

在這里插入圖片描述
然后配置idea,在 VM options 選項中輸入:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*類名.方法名或者-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly就可以在控制台看到輸出的lock匯編指令。
在這里插入圖片描述
我們都知道volatile並不能保證原子性,到這里也可以解釋通為什么了。假設當前有兩個線程同時對共享變量進行+1運算,Thread1比Thread2先進行了Lock操作拿到了鎖,此時由於我們的總線嗅探機制Thread2就會知道共享變量值已經修改過了,從而導致當前Thread2工作內存中的副本變量失效。只能再次去主內存中取新的值,但這樣無形之中Thread2就已經浪費掉了一次操作機會。從而導致最終結果小於預期的情況出現。(比如最常用到的那種兩個線程同時對一個volatile修飾的int進行加減運算的例子)

提示:
如 long a = 100L long b = a+1
在這里a+1並不是我們想象中的原子操作因為long在java中占8個子節一個64位寫操作實際上將會被拆分為2個32位的操作,這一行為的直接后果將會導致最終的結果是不確定的並且缺少原子性的保證。
在Java虛擬機規范中同樣也有類似的描述:“For the purposesof the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each32-bit half. This can result in a situation where a thread sees the first 32 bitsof a 64-bit value from one write, and the second 32 bits from anotherwrite.”

翻譯:對於Java編程語言內存模型來說,對非易失性長值或雙值的一次寫操作被視為兩次單獨的寫操作:一次寫32位的一半。這可能導致這樣一種情況,一個線程看到一個64位值的前32位從一個寫,和第二個32位從另一個寫。

官網地址

指令重排

在之前很經典的單例設計模式中為了防止DCL在指令重排后導致線程不安全的情況,就使用了volatile來防止指令重排。

我們知道為了提高程序執行的性能,編譯器和執行器(處理器)通常會對指令做一些優化(重排序)。volatile通過內存屏障實現了防止指令重排的目的。同時lock前綴指令相當於一個內存屏障,它會告訴CPU和編譯器先於這個命令的必須先執行,后於這個命令的必須后執行。內存屏障另一個作用是強制更新一次不同CPU的緩存。例如,一個寫屏障會把這個屏障前寫入的數據刷新到緩存,這樣任何試圖讀取該數據的線程將得到最新值,而不用考慮到底是被哪個cpu核心或者哪顆CPU執行的。

不同硬件實現內存屏障的方式不同,Java內存模型屏蔽了這種底層硬件平台的差異,由JVM來為不同的平台生成相應的機器碼。
Java內存屏障主要有Load和Store兩類:

  • 對Load Barrier來說,在讀指令前插入讀屏障,可以讓高速緩存中的數據失效,重新從主內存加載數據
  • 對Store Barrier來說,在寫指令之后插入寫屏障,能讓寫入緩存的最新數據寫回到主內存
    為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。然而,對於編譯器來說,發現一個最優布置來最小化插入屏障的總數幾乎不可能,為此,Java內存模型采取保守策略:
  • 在每個volatile寫操作的前面插入一個StoreStore屏障。
  • 在每個volatile寫操作的后面插入一個StoreLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadLoad屏障。
  • 在每個volatile讀操作的后面插入一個LoadStore屏障。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM