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,那麼:java

         Thread-A發出LOCK#指令編程

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

Thread-B讀取變量i,那麼:緩存

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

由此能夠看出,volatile關鍵字的讀和普通變量的讀取相比基本沒差異,差異主要仍是在變量的寫操做上。安全


 爲何static volatile int i = 0; i++;不保證線程安全?多線程

由於i++並非一個原子操做這是由i++自己特質決定的,它包含了三步(實際上對應的機器碼步驟更多,可是這裏分解爲三步已經足夠說明問題):架構

一、獲取i
二、i自增
三、回寫i併發

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

若是線程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字節可以提升併發編程的效率呢?由於對於英特爾酷睿i七、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L一、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變量發生修改以後,線程工做內存中的值因爲已經加載,不會產生對應的變化,因此計算出來的結果會和預期不同.

相關文章
相關標籤/搜索