Volatile的底層原理

JaxYoun發表於2024-08-22

Volatile的底層原理

volatile的特點

被volatile修飾的變數具有如下特點:

  • 1.保證此變數對所有的執行緒的可見性,不能保證它具有原子性(可見性,是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的)

  • 2.禁止指令重排序最佳化

  • 3.volatile 的讀效能消耗與普通變數幾乎相同,但是寫操作稍慢,因為它需要在原生代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行

透過在編譯期,給目標變數新增ACC_VOLATILE標識,最終在cpu指令前形成lock字首。

  • 執行緒對變數賦值後,都會立即重新整理到主存,並且通知其他執行緒其工作記憶體中的快取失效,再次訪問必須從主存載入。

  • 編譯期、執行期形成記憶體屏障,變數的讀、寫前的邏輯,不能重排到屏障後。

JMM記憶體模型

Java記憶體模式是一種虛擬機器規範,它用於遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的併發效果。

JMM規範了Java虛擬機器與計算機記憶體是如何協同工作的:規定了一個執行緒如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在需要時如何同步的訪問共享變數。

JMM記憶體模型把虛擬機器記憶體分為主記憶體和工作記憶體

主記憶體

主記憶體是處理器共享的儲存空間。主記憶體用於儲存共享變數,是多個執行緒之間共享的資料儲存區域。多個執行緒可以同時訪問主記憶體中的共享變數,但是執行緒對共享變數的修改不一定會立即同步到主記憶體中。

Java記憶體模型規定:執行緒對共享變數的修改操作必須先線上程自己的工作記憶體中進行,然後可能會被延遲到主記憶體中去

工作記憶體

每個執行緒都有一個私有的工作記憶體。工作記憶體是 JMM 的一個抽象概念,並不真實存在。工作記憶體是暫存器和快取記憶體的抽象。我們可以約等於理解為工作記憶體即為cpu的暫存器或者快取記憶體。執行緒執行的時候,首先從主記憶體讀值,再儲存為工作記憶體中的副本,然後交給cpu執行,執行完畢後再給副本賦值,隨後工作記憶體再把值傳回給主存。

主記憶體和工作記憶體間的互動

JMM中定義瞭如下8種操作,來完成主記憶體和工作記憶體的資料互動:

  • Lock(鎖定):作用於主記憶體中的變數,表示變數被一個執行緒獨佔的狀態。

  • Unlock(解鎖):作用於主記憶體中的變數,將變數從鎖定狀態(Lock)釋放,釋放後的變數可被其他執行緒鎖定(Lock)

  • Read(讀取):作用於主記憶體中的變數,將變數從主記憶體傳輸到工作記憶體中的過程

  • Load(載入):作用於工作記憶體中的變數,把read操作從主記憶體中得到的變數的值放入工作記憶體的變數副本中。

  • Use(使用):作用於工作記憶體中的變數,把工作記憶體中變數傳遞給執行引擎用於計算等等。(可理解為使用這個變數)

  • Assign(賦值):作用於工作記憶體中的變數,把一個從執行引擎接收到的值賦值給工作記憶體中的變數。(可理解為給變數賦值。)

  • Store(儲存):作用於工作記憶體中的變數,把工作記憶體中的一個變數傳送到主記憶體中,以便隨後write 操作使用。

  • Write(寫入):作用於主記憶體中的變數,把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

需要注意一下幾點:

  1. read和load ;store和write 是成對出現的,才能實現資料完整的複製
  2. 執行了assign(賦值),就必須執行store和write 把更改同步到主記憶體
  3. use前必須執行load,load前必須執行read
  4. lock只允許一個執行緒同時執行
  5. lock一個變數的時候,工作記憶體中的此變數的值將會清空,所以use(使用)前必須重新read(讀取)和load(載入)初始化變數的值。
  6. unlock前必須把變數刷會主記憶體(即store和write)

一、可見性問題

volatile修飾的變數如何能刷回主記憶體

透過對OpenJDK中的unsafe.cpp原始碼的分析,會發現被volatile關鍵字修飾的變數會存在一個“lock:”的字首。

Lock字首,Lock不是一種記憶體屏障,但是它能完成類似記憶體屏障的功能。Lock會對CPU匯流排和快取記憶體加鎖,可以理解為CPU指令級的一種鎖。當CPU發現這個指令時,立即會做兩件事情

1.會將當前處理器快取行的資料直接寫回到系統記憶體中

2.這個寫回記憶體的操作會使在其他CPU裡快取了該地址的資料無效。這是透過cpu匯流排快取一致協議來保證的

快取一致性協議

每個處理器透過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡。

所以,如果一個變數被volatile所修飾的話,在每次資料變化之後,其值都會被強制刷入主存。而其他處理器的快取由於遵守了快取一致性協議,也會把這個變數的值從主存載入到自己的快取中。這就保證了一個volatile在併發程式設計中,其值在多個快取中是可見的。

二、重排序問題

在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。從java程式碼一直到最後指令被執行,有多個步驟下會重排指令。

編譯器重排序

編譯器在編譯的時候對沒有資料依賴性的操作可以重排序,重排序後不會影響程式語意

由於編譯器重的原因,在併發程式設計的時候就會出現意想不到的問題。

指令集重排序

處理器改變對應機器語言的執行順序,重排後不會影響程式語意。

這是一個典型的獲取單例的例子,但是這樣寫是有問題的。如果執行緒A和執行緒B同時呼叫getInstance來獲取單例,可能其中一個執行緒呼叫tools的時候會報空指標異常。為什麼呢?

可能會有讀者提問,不是判斷了tools == null了嗎?為什麼還會報空指標異常。

如果瞭解位元組碼指令的讀者會知道對應一個new關鍵字的時候,處理位元組碼的常規順序是

1.記憶體分配
2.初始化記憶體例項
3.引用指向記憶體例項
這裡的2和3 的順序可能被重排,所以當引用指向這塊記憶體的時候記憶體其實可能沒有初始化,如果使用這個引用可能報空指標異常。

記憶體系統重排序

處理器快取記憶體的資料在刷會主記憶體的時候可能會亂序

volatile是怎麼處理重排序問題的

volatile透過 “記憶體屏障” 的方式來防止指令被重排,為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。大多數的處理器都支援記憶體屏障的指令。

下面是基於保守策略的JMM記憶體屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障,禁止上面的普通寫和他重排在每個volatile寫操作的後面插入一個StoreLoad屏障,禁止跟下面的volatile讀/寫重排在每個volatile讀操作的後面插入一個LoadLoad屏障,禁止下面的普通讀和voaltile讀重排在每個volatile讀操作的後面插入一個LoadStore屏障,禁止下面的普通寫和volatile讀重排

相關文章