volatile的記憶體語義與應用

Tu9oh0st發表於2019-07-11

volatile的記憶體語義

volatile的特性

理解volatile特性的一個好方法是把對volatile變數的單個讀/寫,堪稱是使用同一個鎖對這些單個讀/寫操作做了同步。

鎖的happens-before規則保證釋放鎖和獲取鎖的兩個執行緒之間的記憶體可見性,這意味著對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。

鎖的語義決定了臨界區程式碼的執行具有原子性。即使是64位的long型和double型變數,只要它是volatile變數,對該變數的讀/寫就具有原子性。如果是多個volatile操作或類似於volatile++這種複合操作,這些操作整體上不具有原子性。

volatile變數自身具有下列特性。

  • 可見性。對一個volatile變數的讀,總是能看到(任意執行緒)對這個volatile變數最後的寫入。
  • 原子性:對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

volatile寫-讀的記憶體語義

volatile寫的記憶體語義如下。

當寫一個volatile變數時,JMM會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。

volatile讀的記憶體語義如下。

當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數。

對volatile寫和volatile讀的記憶體語義做個總結。

  • 執行緒A寫一個volatile變數,實質上是執行緒A向接下來將要讀這個volatile變數的某個執行緒發出了(其對共享變數所做修改的)訊息。
  • 執行緒B讀一個volatile變數,實質上是執行緒B接受了之前某個執行緒發出的(在寫這個volatile變數之前對共享變數所做的修改的)訊息。
  • 執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過主記憶體執行緒B傳送訊息。

volatile的記憶體語義與應用

volatile記憶體語義的實現

volatile重排序規則表

volatile的記憶體語義與應用

從表中我們可以看出。

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

保守策略下,volatile寫插入記憶體屏障後生成的指令序列示意圖:

volatile的記憶體語義與應用

在保守策略下,volatile讀插入記憶體屏障後生成的指令序列示意圖:

volatile的記憶體語義與應用

JSR-133為什麼要增強volatile的記憶體語義

嚴格限制編譯器和處理器對volatile變數與普通變數的重排序,確保volatile的寫-讀和鎖的釋放-獲取具有相同的記憶體語義。

在功能上,鎖比volatile更強大;在可伸縮性和執行效能上,volatile更加優勢。

轉載自併發程式設計網 – ifeve.com參考連結地址: JSR133中文版

volatile的應用

在多執行緒併發程式設計中synchronized和volatile都扮演著重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證了共享變數的“可見性”。可見性的意思是當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。如果volatile變數修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因為它不會引起執行緒上下文的切換和排程。

1.volatile的定義與實現原理

Java語言規範第3版中對volatile的定義如下:Java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保通過排他鎖單獨獲得這個變數。Java語言提供了volatile,在某些情況下比鎖要更加方便。如果一個欄位被宣告成volatile,Java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。

在瞭解volatile實現原理之前,我們先來看下與其實現原理相關的CPU術語與說明。

術語 英文單詞 術語描述
記憶體屏障 memory barriers 是一組處理器指令,用於實現對記憶體操作的順序限制
緩衝行 cache line 快取中可以分配的最小儲存單位。處理器填寫快取線時會載入整個快取線,需要使用多個主記憶體讀週期
原子操作 atomic operations 不可中斷的一個或一系列操作
緩衝行填充 cache line fill 當處理器識別到從記憶體中讀取運算元是可快取的,處理器讀取整個快取行到適當的快取(L1,L2,L3的或所有)
快取命中 cache hit 如果進行告訴快取行填充操作的記憶體位置仍然是下次處理器訪問的地址時,處理器從快取中讀取運算元,而不是從記憶體讀取
寫命中 write hit 當處理器將運算元寫回到一個記憶體快取的區域時,它首先會檢查這個快取的記憶體地址是否在快取行中,如果存在一個有效的快取行,則處理器將這個運算元寫回到快取,而不是寫回到記憶體,這個操作被稱為寫命中
寫缺失 write misses the cache 一個有效的快取行被寫入到不存在的記憶體區域

volatile是如何來保證可見性的呢?讓我們在X86處理器下通過工具獲取JIT編譯器生成的彙編指令來檢視對volatile進行寫操作時,CPU會做什麼事情。

Java程式碼如下:

instance = new Singleton(); //instance是volatile變數

轉變成彙編程式碼,如下:

0x01a3de1d: movb $0x0,0x1104800(%esi);

oxo1a3de24: lock add1 $0x0,(%esp);

有volatile變數修飾的共享變數進行寫操作的時候會多出第二行彙編程式碼,通過查IA-32架構軟體開發者手冊可知,Lock字首的指令在多核處理器下會引發了兩件事情。

1) 將當前處理器快取行的資料寫回到系統記憶體。

2) 這個寫回記憶體的操作會使在其他CPU裡快取了該記憶體地址的資料無效。

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到記憶體快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。如果對宣告瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在匯流排上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。

下面來具體講解volatile的兩條實現原則。

1) Lock字首指令會引起處理器快取回寫到記憶體。Lock字首指令導致在執行指令期間,聲名處理器的LOCK#訊號。在多處理器環境中,LOCK#訊號確保在聲名該訊號期間,處理器可以獨佔任何共享記憶體。但是,在最近的處理器裡,LOCK#訊號一般不鎖匯流排,而是鎖快取,畢竟鎖匯流排開銷的比較大。對於Intel486和Pentium處理器,在鎖操作時,總是在匯流排上宣告LOCK#訊號。但在P6和目前的處理器中,如果訪問的記憶體區域已經快取在處理器內部,則不會宣告LOCK#訊號。相反,它會鎖定這塊記憶體區域的快取並會寫到記憶體,並使用快取一致性機制來確保修改的原子性,此操作被稱為“快取鎖定”,快取一致性機制會阻止同時修改由兩個以上處理器快取的記憶體區域資料。

2) 一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。IA-32處理器和inter 64處理器使用MESI(修改、獨佔、共享、無效)控制協議去維護內部快取和其他處理器快取的一致性。在多核處理器系統中進行操作的時候,IA-32和inter 64處理器能嗅探其他處理器訪問系統記憶體和它們的內部快取。處理器使用嗅探技術保證它的內部快取、系統記憶體和其他處理器的快取的資料在匯流排上保持一致。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器來檢測其他處理器打算寫記憶體地址,而這個地址當前處於共享狀態,那麼正在嗅探的處理器將使它的快取行無效,在下次訪問相同記憶體地址時,強制執行快取行填充。

2.volatile的使用優化

著名的Java併發程式設計大師Dourg Lea在JDK7的併發包裡新增一個佇列集合類LinkedTransferQueue,它在使用volatile變數時,用一種追加位元組的方式來優化佇列出隊和入隊的效能。LinkedTransferQueue的程式碼如下

//佇列中的頭部節點
private transient final PaddedAtomicReference<QNode> head;
//佇列中的尾部節點
private transient final PaddedAtomicReferfence<QNode> tail;
static final class PaddedAtomicReference<T> extends AtomicReference {
    //使用很多4個位元組的引用追加64個位元組
    Object p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,pa,pb,pc,pd,pe;
    PaddedAtomicReference(T r){
        super(r);
    }
}
public class AtomicReference <V> implements java.io.Serializable{
    private volatile V value;
    //省略其他程式碼
}

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

為什麼追加64位元組能夠提高併發程式設計的效率呢?因為對於英特爾酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L1、L2或L3快取的快取記憶體行是64個位元組寬,不支援部分填充快取行,這意味著,如果佇列的頭節點和尾節點都不足64位元組的話,處理器會將它們都讀到同一個快取記憶體行中,在多處理器下每個處理器都會快取同樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個快取行鎖定,那麼在快取一致性機制的作用下,會導致其他處理器不能訪問自己快取記憶體中的尾節點,而佇列的入隊和出隊操作則需要不停修改頭節點和尾節點,所以在多處理器的情況下將會嚴重影響到佇列的入隊和出隊效率。Douglea使用追加到64位元組的方式來填滿高速緩衝區的快取行,避免頭節點和尾節點載入到同一個快取行,使頭、尾節點在修改時不會互相鎖定。

那麼是不是在使用volatile變數時都應該追加到64位元組呢?不是的。在兩種場景下不應該使用這種方式:

  1. 快取行非64位元組寬的處理器。如P6系列和奔騰處理器,它們的L1和L2告訴快取行是32個位元組寬。
  2. 共享變數不會被頻繁地寫。因為使用追加位元組地方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,如果共享變數不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。

參考資料

相關文章