1. 程式人生 > >併發程式設計(三)volatile與記憶體屏障

併發程式設計(三)volatile與記憶體屏障

#Java通過幾種原子操作完成工作記憶體和主記憶體的互動:
lock:作用於主記憶體,把變數標識為執行緒獨佔狀態。
unlock:作用於主記憶體,解除獨佔狀態。
read:作用主記憶體,把一個變數的值從主記憶體傳輸到執行緒的工作記憶體。
load:作用於工作記憶體,把read操作傳過來的變數值放入工作記憶體的變數副本中。
use:作用工作記憶體,把工作記憶體當中的一個變數值傳給執行引擎。
assign:作用工作記憶體,把一個從執行引擎接收到的值賦值給工作記憶體的變數。
store:作用於工作記憶體的變數,把工作記憶體的一個變數的值傳送到主記憶體中。
write:作用於主記憶體的變數,把store操作傳來的變數的值放入主記憶體的變數中。
#什麼是記憶體屏障(Memory Barrier)?
記憶體屏障(memory barrier)是一個CPU指令。基本上,它是這樣一條指令: a) 確保一些特定操作執行的順序; b) 影響一些資料的可見性(可能是某些指令執行後的結果)。編譯器和CPU可以在保證輸出結果一樣的情況下對指令重排序,使效能得到優化。插入一個記憶體屏障,相當於告訴CPU和編譯器先於這個命令的必須先執行,後於這個命令的必須後執行。記憶體屏障另一個作用是強制更新一次不同CPU的快取

。例如,一個寫屏障會把這個屏障前寫入的資料重新整理到快取,這樣任何試圖讀取該資料的執行緒將得到最新值,而不用考慮到底是被哪個cpu核心或者哪顆CPU執行的。
#記憶體屏障的種類
幾乎所有的處理器至少支援一種粗粒度的屏障指令,通常被稱為“柵欄(Fence)”,它保證在柵欄前初始化的load和store指令,能夠嚴格有序的在柵欄後的load和store指令之前執行。無論在何種處理器上,這幾乎都是最耗時的操作之一(與原子指令差不多,甚至更消耗資源),所以大部分處理器支援更細粒度的屏障指令。
記憶體屏障的一個特性是將它們運用於記憶體之間的訪問。儘管在一些處理器上有一些名為屏障的指令,但是正確的/最好的屏障使用取決於記憶體訪問的型別。下面是一些屏障指令的通常分類,正好它們可以對應上常用處理器上的特定指令(有時這些指令不會導致操作)。
LoadLoad 屏障
序列:Load1,Loadload,Load2
確保Load1所要讀入的資料能夠在被Load2和後續的load指令訪問前讀入。通常能執行預載入指令或/和支援亂序處理的處理器中需要顯式宣告Loadload屏障,因為在這些處理器中正在等待的載入指令能夠繞過正在等待儲存的指令。 而對於總是能保證處理順序的處理器上,設定該屏障相當於無操作。
StoreStore 屏障
序列:Store1,StoreStore,Store2
確保Store1的資料在Store2以及後續Store指令操作相關資料之前對其它處理器可見(例如向主存重新整理資料)。通常情況下,如果處理器不能保證從寫緩衝或/和快取向其它處理器和主存中按順序重新整理資料,那麼它需要使用StoreStore屏障。
LoadStore 屏障
序列: Load1; LoadStore; Store2
確保Load1的資料在Store2和後續Store指令被重新整理之前讀取。在等待Store指令可以越過loads指令的亂序處理器上需要使用LoadStore屏障。
StoreLoad Barriers
序列: Store1; StoreLoad; Load2
確保Store1的資料在被Load2和後續的Load指令讀取之前對其他處理器可見。StoreLoad屏障可以防止一個後續的load指令 不正確的使用了Store1的資料,而不是另一個處理器在相同記憶體位置寫入一個新資料。正因為如此,處理器為了在屏障前讀取同樣記憶體位置存過的資料,必須使用一個StoreLoad屏障將儲存指令和後續的載入指令分開。Storeload屏障在幾乎所有的現代多處理器中都需要使用,但通常它的開銷也是最昂貴的。它們昂貴的部分原因是它們必須關閉通常的略過快取直接從寫緩衝區讀取資料的機制。這可能通過讓一個緩衝區進行充分重新整理(flush),以及其他延遲的方式來實現。
執行StoreLoad的指令也會同時獲得其他三種屏障的效果。所以StoreLoad可以作為最通用的(但通常也是最耗效能)的一種Fence。(這是經驗得出的結論,並不是必然)。反之不成立,為了達到StoreLoad的效果而組合使用其他屏障並不常見。
#Volatile的官方定義
Java語言規範第三版中對volatile的定義如下: java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。
Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個欄位被宣告成volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
#volatile關鍵字的作用
能保證可見性和防止指令重排序
#volatile與synchronized對比
Volatile變數修飾符如果使用恰當的話,它比synchronized的使用和執行成本會更低,因為它不會引起執行緒上下文的切換和排程
#volatile如何保證可見性、防止指令重排序
volatile保持記憶體可見性和防止指令重排序的原理,本質上是同一個問題,也都依靠記憶體屏障得到解
在x86處理器下通過工具獲取JIT編譯器生成的彙編指令來看看對Volatile進行寫操作CPU會做什麼事情。
Java程式碼: instance = new Singleton();//instance是volatile變數
彙編程式碼: 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);
lock字首指令相當於一個記憶體屏障(也稱記憶體柵欄),記憶體屏障主要提供3個功能:
1、 確保指令重排序時不會把其後面的指令排到記憶體屏障之前的位置,也不會把前面的指令排到記憶體屏障的後面;即在執行到記憶體屏障這句指令時,在它前面的操作已經全部完成;
2、 強制將對快取的修改操作立即寫入主存,利用快取一致性機制,並且快取一致性機制會阻止同時修改由兩個以上CPU快取的記憶體區域資料;
3、如果是寫操作,它會導致其他CPU中對應的快取行無效。
一個處理器的快取回寫到記憶體會導致其他處理器的快取失效。
處理器使用嗅探技術保證它的內部快取、系統記憶體和其他處理器的快取的資料在總線上保持一致。例如CPU A嗅探到CPU B打算寫記憶體地址,且這個地址處於共享狀態,那麼正在嗅探的處理器將使它的快取行無效,
在下次訪問相同記憶體地址時,強制執行快取行填充。
volatile關鍵字通過“記憶體屏障”來防止指令被重排序。
為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。然而,對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,Java記憶體模型採取保守策略。
下面是基於保守策略的JMM記憶體屏障插入策略:
在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的後面插入一個StoreLoad屏障。
在每個volatile讀操作的後面插入一個LoadLoad屏障。
在每個volatile讀操作的後面插入一個LoadStore屏障。
#volatile為什麼不能保證原子性
原子操作是一些列的操作要麼全做,要麼全不做,而volatile 是一種弱的同步機制,只能確保共享變數的更新操作及時被其他執行緒看到,以最常用的i++來說吧,包含3個步驟
1,從記憶體讀取i當前的值 2,加1 3,把修改後的值重新整理到記憶體,volatile無法保證這三個不被打斷的執行完畢,如果在重新整理到記憶體之前有中斷,此時被其他執行緒修改了,之前的值就無效了
#volatile的適用場景
volatile是在synchronized效能低下的時候提出的。如今synchronized的效率已經大幅提升,所以volatile存在的意義不大。
狀態標記(while(flag){})
double check(單例模式)
這裡寫圖片描述

上述volatile寫和volatile讀的記憶體屏障插入策略非常保守。在實際執行時,只要不改變volatile寫-讀的記憶體語義,編譯器可以根據具體情況省略不必要的屏障。下面我們通過具體的示例程式碼來說明:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;           //第一個volatile讀
        int j = v2;           // 第二個volatile讀
        a = i + j;            //普通寫
        v1 = i + 1;          // 第一個volatile寫
        v2 = j * 2;          //第二個 volatile寫
    }

    …                    //其他方法
}

針對readAndWrite()方法,編譯器在生成位元組碼時可以做如下的優化:
這裡寫圖片描述
注意,最後的StoreLoad屏障不能省略。因為第二個volatile寫之後,方法立即return。此時編譯器可能無法準確斷定後面是否會有volatile讀或寫,為了安全起見,編譯器常常會在這裡插入一個StoreLoad屏障。
上面的優化是針對任意處理器平臺,由於不同的處理器有不同“鬆緊度”的處理器記憶體模型,記憶體屏障的插入還可以根據具體的處理器記憶體模型繼續優化。以x86處理器為例,上圖中除最後的StoreLoad屏障外,其它的屏障都會被省略。
這裡寫圖片描述
前文提到過,x86處理器僅會對寫-讀操作做重排序。X86不會對讀-讀,讀-寫和寫-寫操作做重排序,因此在x86處理器中會省略掉這三種操作型別對應的記憶體屏障。在x86中,JMM僅需在volatile寫後面插入一個StoreLoad屏障即可正確實現volatile寫-讀的記憶體語義。這意味著在x86處理器中,volatile寫的開銷比volatile讀的開銷會大很多(因為執行StoreLoad屏障開銷會比較大)。
參考:https://www.jianshu.com/p/c93df8da8488