volatile底層原理剖析

在理解volatile之前,我們先來熟悉下計算機執行程序的過程

要執行我們的應用程序,首先將我們的程序從磁盤上讀取到內存中,內存裏這個時候存放了要執行的指令和數據,要執行一條指令的時候,指令寄存器根據程序計數器PC中存放的下一條待執行指令的地址,從內存中將指令取出來,cpu再根據指令內容將內存中的數據讀取到數據寄存器中,ALU(算數邏輯單元)計算得出結果,將結果數據寫回到內存。

那麼volatile是用來幹什麼的呢?
volatile有兩大作用:保證線程間可見禁止指令重排

線程間可見

volatile可以保證線程的可見性,那麼是什麼造成了線程間不可見,這裏還涉及到一個知識點:緩存一致性協議

我們的應用程序都是交給cpu來執行的,對於cpu來說,它的運行速度是非常快的,相對於CPU來說,內存的速度是非常慢的,而cpu要讀取內存中的內容,那這個時候該怎麼辦?如果能夠把內存中的內容緩存在cpu的內部,這個時候cpu直接在自己內部讀取,速度會快很多,不然的話,這個效率也太低了。

所以在cpu和主內存之間增加了一系列的緩存,目前市面上流行的計算機主板配置,一般是三級緩存,一級緩存和二級緩存在cpu內部,速度是非常快的。我們應用程序的每個線程都是運行在cpu裏的,cpu去讀取主內存中一份數據的時候,會將數據讀取到自己內部的緩存當中,這個時候線程纔可以去執行,在默認情況下,cpu後續會一直讀取緩存當中的這個份數據,它不會主動去內存中再次讀取,當我們的多個線程運行在不同的cpu當中,每個線程都會讀取自己所在cpu緩存當中的數據,經過計算,再把值寫回主內存,這個時候就存在數據同步不一致的問題

而cpu從內存中讀取數據的時候,並不是一位一位讀的,而是分塊讀取,比如說你要讀取一位數據,cpu其實是會把這一整塊數據都先讀進緩存。大部分cpu都是一次性讀取64個字節的數據,這64個字節被稱之爲緩存行。代表cpu從內存中讀取數據的基本單位。例如cpu要讀取A的值,它不會只把A讀取出來,而是把A所在的整個的這一塊64個字節放入到緩存當中。

爲了解決數據同步不一致這個問題,推出了緩存一致性協議,緩存一次性協議有多種,像MSI、MESI、MOSI、Synapse等,不同cpu有不同的協議,我們常用的intel cpu採用的是MESI,MESI協議就是把每一個緩存行標記成不同的狀態,MESI協議有4種狀態:

  • Modified:代表此緩存行已經被修改過了
  • Exclusive:獨佔此緩存行
  • Shared:共享此緩存行
  • Invalid:該緩存行已失效,因爲已經被改過了

一個cpu更改了一個緩存行,只需要通知其他cpu該緩存行已失效,需要重新從內存當中讀取數據。這個就是緩存一致性協議。

但其實volatile的底層實現跟緩存一致性協議是沒有關係的,JVM採用lock鎖總線的方式保證線程間可見,不管有沒有緩存一致性協議,使用了volatile,JVM都能夠保證線程間的可見性。

禁止指令重排

cpu亂序執行

相對於內存來說,cpu的運行速度是非常快的,如果說cpu在執行一條指令的時候還要一直等待內存的話,那麼就很容易造成cpu的空轉。所以cpu在發現兩條指令之間沒有任何關係的時候,在執行第一條指令的過程中,乾脆就把第二天指令也拿過來運行。這個時候就有一個現象,就是我們在代碼中寫的是先執行第一條指令,再執行第二條指令,但實際上cpu是有可能先執行完了第二條指令,再執行的第一條指令。

那麼volatile是怎麼禁止指令重排序的呢?這裏我們先來了解一下一個概念:內存屏障

volatile在jvm層級有jvm級別的內存屏障,除此之外還有底層的內存屏障

JVM級別內存屏障

JVM規範要求java虛擬機對volatile關鍵字修飾的數據進行讀寫的時候,必需加一道屏障,插入一個內存屏障,相當於告訴CPU先於這個命令的操作必須先執行,後於這個命令的操作必須後執行。內存屏障有兩個作用:

  • 阻止屏障兩側的指令重排序
  • 強制把寫緩衝區/高速緩存中的髒數據等寫回主內存,讓緩存中相應的數據失效

硬件層面的內存屏障分爲Load Barrier Store Barrier讀屏障寫屏障

對於Load Barrier來說,在指令前插入Load Barrier,可以讓高速緩存中的數據失效,強制從新從主內存加載新數據。

對於Store Barrier來說,在指令後插入Store Barrier,能讓寫入緩存中的最新數據更新寫入主內存,讓其他線程可見。

jvm規範把讀、寫屏障排列組合形成4種內存屏障

LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障

Load就是讀,Store就是寫

1)LoadLoad屏障

上邊有一條load指令,下邊有一條load指令,這兩條指令不可以重排序,上面的load指令讀完,下面的load才能讀。

2)StoreStore屏障

上邊有一條store指令,下邊有一條store指令,這兩條指令不可以重排序,上面的store指令寫完,下面的store才能寫。

3)LoadStore屏障

上邊有一條load指令,下邊有一條store指令,這兩條指令不可以重排序,上面的load讀完,下面的store才能寫。

4)StoreLoad屏障

上邊有一條store指令,下邊有一條load指令,這兩條指令不可以重排序,上面的store寫完,下面的load才能讀。

到這裏,我們總結一下,jvm規範要求對於volatile修飾內存的讀寫操作,前後都要添加內存屏障,前後都有了屏障,就絕對不會發生指令重排這種問題。

那麼jvm層級是怎麼實現volatile的細節的呢?

寫入操作:

假如要對volatile修飾的內存進行寫入的操作,寫入就是store操作。那麼在它的上邊會有一個StoreStore屏障,下邊會有一個StoreLoad屏障,上邊的store寫完了,我才能執行寫入操作,我寫完了,其它人才能讀。

讀取操作:

讀就是store操作,那麼在它的上邊會有一個LoadLoad屏障,下邊會有一個LoadStore屏障。上邊的讀完了,我才能讀,得等我讀完了,你才能寫。

此外,JVM還規定了在8種情況下不可以進行重排序(happens-before原則),具體規則略過。

我們說的這些都是JVM層級的規範,那麼在java虛擬機當中具體是如何實現的呢?

內存屏障底層實現

現在的cpu大都是支持內存屏障的,比如說X86 CPU,它有三條這樣的指令支持內存屏障:sfence(相當於store)、lfence(相當於load)、mfence(讀寫操作)。除此之外,還有一條萬能的指令:lock(鎖總線/緩存)。而我們的hospot虛擬機實現內存屏障就是使用lock鎖總線的方式(lock addl,addl是一個加0的空操作),因爲所有的cpu都是有lock指令的。

一個線程在對volatile修飾的內存進行讀寫操作的時候,會在前面加上lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能lock在執行的時候,會對總線/緩存加鎖,然後執行後面的指令。因爲所有的cpu要想訪問內存,都必須通過總線。在Lock鎖住總線的時候,其它線程都不能夠訪問內存,直到鎖釋放。釋放鎖的時候會把緩存中的數據刷新回主內存,而且這個寫回主存的操作會使得在其他CPU裏的緩存失效。這樣一來也就沒辦法進行指令重排序了,同時也保證了線程間可見。

既然鎖住了總線,那爲什麼volatile還是無法保證線程安全呢?

簡單來說,修改volatile值包括3步:

  1. Load,讀取值到cpu緩存中
  2. 計算,修改值
  3. Store,將緩存中的值寫回到內存

但是從Load到Store並不是安全的,不能保證沒有其他線程修改。因爲volatile並不能保證這4步作爲一個原子操作。

假如有A、B兩個線程要執行t=t+1的操作,剛開始的時候,線程A通過Load將t=0的值讀取到cpu緩存中,這個時候恰好cpu時間片用完了,線程B獲得執行權,讀取t的值也爲0。這個時候線程A再執行完+1操作,寫回內存。但是此時線程B已經讀取過t的值了,不會再去拿一遍t的值,因此執行完+1操作後,寫回內存,這個時候t的值還是1;也就是出現了線程不安全問題。

不對,如果線程A執行自增了並寫會主內存後,不是應該應該通知其他線程重新加載t最新的值麼?

拿到新值的前提是load,但此時線程B已經拿到了t的值,並不需要再次load t的值,當然是獲取不到t最新的值的

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章