volatile 和 內存屏障


接下來看看volatile是如何解決上面兩個問題的:
被volatile修飾的變量在編譯成字節碼文件時會多個lock指令,該指令在執行過程中會生成相應的 內存屏障,以此來解決可見性跟重排序的問題。
內存屏障的作用:
1.在有內存屏障的地方, 會禁止指令重排序,即屏障下面的代碼不能跟屏障上面的代碼交換執行順序。
2.在 有內存屏障的地方,線程修改完共享變量以后會 馬上把該變量從本地內存寫回到主內存並且讓其他線程本地內存中該變量副本失效(使用MESI協議)

作者:凌風郎少
鏈接:https://www.jianshu.com/p/0c3a349663db
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權並注明出處。

volatile的實現原理

  • 通過對OpenJDK中的unsafe.cpp源碼的分析,會發現被volatile關鍵字修飾的變量會存在一個lock:”的前綴。
  • Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能Lock會對CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖。類似於Lock指令。
  • 在具體的執行上,它先對總線和緩存加鎖,然后執行后面的指令,在Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞直到鎖釋放。最后釋放鎖后會把高速緩存中的臟數據全部刷新回主內存且這個寫回內存的操作會使在其他CPU里緩存了該地址的數據無效

 那么當寫兩條線程Thread-A與Threab-B同時操作主存中的一個volatile變量i時,Thread-A寫了變量i,那么:

         Thread-A發出LOCK#指令

  • 發出的LOCK#指令鎖總線(或鎖緩存行)(因為它會鎖住總線,導致其他CPU不能訪問總線,不能訪問總線就意味着不能訪問系統內存然后釋放鎖最后刷新回主內瞬間完成的,寫回時候其他緩存行失效同時讓Thread-B高速緩存中的緩存行內容失效 
  • Thread-A向主存回寫最新修改的i

Thread-B讀取變量i,那么:

  • Thread-B發現對應地址的緩存行被鎖了,等待鎖的釋放,緩存一致性協議會保證它讀取到最新的值重新從主存讀

由此可以看出,volatile關鍵字的讀和普通變量的讀取相比基本沒差別,差別主要還是在變量的寫操作上。


 為什么static volatile int i = 0; i++;不保證線程安全?

因為i++並不是一個原子操作這是由i++本身特質決定的,它包含了三步(實際上對應的機器碼步驟更多,但是這里分解為三步已經足夠說明問題):

1、獲取i
2、i自增
3、回寫i

A、B兩個線程同時自增i
由於volatile可見性,因此步驟1兩條線程一定拿到的是最新的i,也就是相同的i
但是從第2步開始就有問題了,有可能出現的場景是線程A自增了i並回寫,但是線程B此時已經拿到了i,不會再去拿線程A回寫的i,因此對原值進行了一次自增並回寫
這就導致了線程非安全,也就是你說的多線程技術器結果不對

如果線程A對i進行自增了以后cpu緩存不是應該通知其他緩存,並且重新load i么?

拿的前提是讀,問題是,線程A對i進行了自增,線程B已經拿到了i並不存在需要再次讀取i的場景,當然是不會重新load i這個值的。

ps:也就是線程B的緩存行內容的確會失效。但是此時線程B中i的值已經運行在加法指令中,不存在需要再次從緩存行讀取i的場景。


 volatile是“輕量級”synchronized,保證了共享變量的“可見性”(JMM確保所有線程看到這個變量的值是一致的),當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置為無效狀態並且鎖住緩存行,因此當其他CPU需要讀取這個變量時,要等鎖釋放,並發現自己緩存行是無效的,那么它就會從內存重新讀取。

 volatile是“輕量級”synchronized,保證了共享變量的“可見性”(JMM確保所有線程看到這個變量的值是一致的),使用和執行成本比synchronized低,因為它不會引起線程上下文切換和調度。


工作內存Work Memory其實就是對CPU寄存器和高速緩存的抽象,或者說每個線程的工作內存也可以簡單理解為CPU寄存器和高速緩存。


 volatile作用:

1.鎖總線,其它CPU對內存的讀寫請求都會被阻塞,直到鎖釋放,不過實際后來的處理器都采用鎖緩存替代鎖總線,因為鎖總線的開銷比較大,鎖總線期間其他CPU沒法訪問內存

2.lock后的寫操作會回寫已修改的數據,同時讓其它CPU相關緩存行失效,從而重新從主存中加載最新的數據

3.不是內存屏障卻能完成類似內存屏障的功能,阻止屏障兩遍的指令重排序


volatile只能保證對單次讀/寫的原子性。因為long和double兩種數據類型的操作可分為高32位和低32位兩部分,因此普通的long或double類型讀/寫可能不是原子的。因此,鼓勵大家將共享的long和double變量設置為volatile類型,這樣能保證任何情況下對long和double的單次讀/寫操作都具有原子性。

  隊列集合類LinkedTransferQueue,在使用volatile變量時,追加64字節的方式來優化隊列出隊和入隊的性能。

追加字節能優化性能?這種方式看起來很神奇,但如果深入理解處理器架構就能理解其中的奧秘。讓我們先來看看LinkedTransferQueue這個類,它使用一個內部類類型來定義隊列的頭節點(head)和尾節點(tail),而這個內部類PaddedAtomicReference相對於父類AtomicReference只做了一件事情,就是將共享變量追加到64字節。我們可以來計算下,一個對象的引用占4個字節,它追加了15個變量(共占60個字節),再加上父類的value變量,一共64個字節。

為什么追加64字節能夠提高並發編程的效率呢?因為對於英特爾酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L1、L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行(處理器支持也可以),這意味着,如果隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個緩存行鎖定,那么在緩存一致性機制的作用下,會導致其他處理器不能訪問自己高速緩存中的尾節點,而隊列的入隊和出隊操作則需要不停修改頭節點和尾節點,所以在多處理器的情況下將會嚴重影響到隊列的入隊和出隊效率。

  Doug lea使用追加到64字節的方式來填滿高速緩沖區的緩存行,避免頭節點和尾節點加載到同一個緩存行,使頭、尾節點在修改時不會互相鎖定。 

那么是不是在使用volatile變量時都應該追加到64字節呢?不是的。在兩種場景下不應該使用這種方式。

 緩存行非64字節寬的處理器。如P6系列和奔騰處理器,它們的L1和L2高速緩存行是32個字節寬。

 共享變量不會被頻繁地寫。因為使用追加字節的方式需要處理器讀取更多的字節到高速緩沖區,這本身就會帶來一定的性能消耗,如果共享變量不被頻繁寫的話,鎖的幾率也非常小,就沒必要通過追加字節的方式來避免相互鎖定。


 volatile關鍵字使用的是Lock指令,volatile的作用取決於Lock指令。CAS不是保證原子的更新,而是使用死循環保證更新成功時候只有一個線程更新不包括主工作內存的同步 CAS配合volatile既保證了只有一個線程更新又保證了多個線程更新獲得的是最新的值互不影響。


 volatile的變量在進行寫操作時,會在前面加上lock質量前綴。

 Lock前綴,Lock不是一種內存屏障,但是它能完成類似內存屏障的功能。Lock會對CPU總線和高速緩存加鎖,可以理解為CPU指令級的一種鎖

 Lock前綴是這樣實現的

 先對總線/緩存加鎖然后執行后面的指令最后釋放鎖后會把高速緩存中的臟數據全部刷新回主內存

 Lock鎖住總線的時候,其他CPU的讀寫請求都會被阻塞,直到鎖釋放。Lock后的寫操作會讓其他CPU相關的cache失效,從而從新從內存加載最新的數據,這個是通過緩存一致性協議做的。 


 lock前綴指令相當於一個內存屏障(也稱內存柵欄)既不是Lock中使用了內存屏障,也不是內存屏障使用了Lock指令,內存屏障主要提供3個功能:

  1. 確保指令重排序時不會把其后面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的后面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
  2. 強制將對緩存的修改操作立即寫入主存,利用緩存一致性機制,並且緩存一致性機制會阻止同時修改由兩個以上CPU緩存的內存區域數據;
  3. 如果是寫操作,它會導致其他CPU中對應的緩存行無效。

 內存屏障CPU指令如果你的字段是volatileJava內存模型將在寫操作后插入一個寫屏障指令,在讀操作前插入一個讀屏障指令。

下面是基於保守策略的JMM內存屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。

在每個volatile寫操作的后面插入一個StoreLoad屏障。

在每個volatile讀操作的前面插入一個LoadLoad屏障。

在每個volatile讀操作的后面插入一個LoadStore屏障。

內存屏障,又稱內存柵欄,是一組處理器指令,用於實現對內存操作的順序限制 

內存屏障可以被分為以下幾種類型
LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及后續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及后續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及后續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及后續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。        在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能。

為什么會有內存屏障

  • 每個CPU都會有自己的緩存(有的甚至L1,L2,L3),緩存的目的就是為了提高性能,避免每次都要向內存取。但是這樣的弊端也很明顯:不能實時的和內存發生信息交換,分在不同CPU執行的不同線程對同一個變量的緩存值不同。
  • volatile關鍵字修飾變量可以解決上述問題,那么volatile是如何做到這一點的呢?那就是內存屏障內存屏障是硬件層的概念,不同的硬件平台實現內存屏障的手段並不是一樣,java通過屏蔽這些差異,統一由jvm來生成內存屏障的指令Lock是軟件指令。

內存屏障是什么

  • 硬件層的內存屏障分為兩種Load Barrier  Store Barrier讀屏障寫屏障
  • 內存屏障有兩個作用:
  1. 阻止屏障兩側的指令重排序
  2. 強制把寫緩沖區/高速緩存中的臟數據等寫回主內存,讓緩存中相應的數據失效
  • 對於Load Barrier來說,在指令前插入Load Barrier,可以讓高速緩存中的數據失效,強制從新從主內存加載數據
  • 對於Store Barrier來說,在指令后插入Store Barrier,能讓寫入緩存中的最新數更新寫入主內存,讓其他線程可見

 java內存屏障

 StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。

volatile語義中的內存屏障

  • volatile的內存屏障策略非常嚴格保守,非常悲觀且毫無安全感的心態:

在每個volatile寫操作前插入StoreStore屏障這個屏障前后的2Store指令不能交換順序,在寫操作后插入StoreLoad屏障這個屏障前后的2Store Load指令不能交換順序
在每個volatile讀操作前插入LoadLoad屏障這個屏障前后的2Load指令不能交換順序,在讀操作后插入LoadStore屏障這個屏障前后的2Load Store指令不能交換順序

    • 由於內存屏障的作用,避免了volatile變量和其它指令重排序、線程之間實現了通信,使得volatile表現出了鎖的特性。
    • Java中對於volatile修飾的變量,編譯器在生成字節碼時,會在指令序列中插入內存屏障禁止處理器重排序。

 Java通過幾種原子操作完成工作內存和主內存的交互:

 lock:作用於主內存,鎖住主內存主變量。

 unlock:作用於主內存,解鎖主內存主變量

 read:作用主內存,主內存傳遞到工作內存。

 load:作用於工作內存,主內存傳遞來的值賦給工作內存工作變量。

 use:作用工作內存,工作內存工作變量值傳給執行引擎。

 assign:作用工作內存,引擎的結果值賦值給工作內存工作變量

 store:作用於工作內存的變量,工作內存工作變量傳送到主內存中。

 write:作用於主內存的變量,工作內存傳來工作變量賦值給主內存主變量。‘

 read and load 從主存復制變量到當前工作內存

use and assign  執行代碼,改變共享變量值 
store and write 用工作內存數據刷新主存相關內容

 其中use and assign 可以多次出現

 但是這一些操作並不是原子性,也就是在read load之后,如果主內存count變量發生修改之后,線程工作內存中的值由於已經加載,不會產生對應的變化,所以計算出來的結果會和預期不一樣.


免責聲明!

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



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