volatile的內存語義與應用

volatile的內存語義

volatile的特性

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

鎖的happens-before規則保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這意味着對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。編程

鎖的語義決定了臨界區代碼的執行具備原子性。即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。緩存

volatile變量自身具備下列特性。多線程

  • 可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性:對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操做不具備原子性。

volatile寫-讀的內存語義

volatile寫的內存語義以下。架構

當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。併發

volatile讀的內存語義以下。app

當讀一個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讀插入內存屏障後生成的指令序列示意圖:

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字節可以提升併發編程的效率呢?由於對於英特爾酷睿i七、酷睿、Atom和NetBurst,以及Core Solo和Pentium M處理器的L一、L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行,這意味着,若是隊列的頭節點和尾節點都不足64字節的話,處理器會將它們都讀到同一個高速緩存行中,在多處理器下每一個處理器都會緩存一樣的頭、尾節點,當一個處理器試圖修改頭節點時,會將整個緩存行鎖定,那麼在緩存一致性機制的做用下,會致使其餘處理器不能訪問本身高速緩存中的尾節點,而隊列的入隊和出隊操做則須要不停修改頭節點和尾節點,因此在多處理器的狀況下將會嚴重影響到隊列的入隊和出隊效率。Douglea使用追加到64字節的方式來填滿高速緩衝區的緩存行,避免頭節點和尾節點加載到同一個緩存行,使頭、尾節點在修改時不會互相鎖定。

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

  1. 緩存行非64字節寬的處理器。如P6系列和奔騰處理器,它們的L1和L2告訴緩存行是32個字節寬。
  2. 共享變量不會被頻繁地寫。由於使用追加字節地方式須要處理器讀取更多的字節到高速緩衝區,這自己就會帶來必定的性能消耗,若是共享變量不被頻繁寫的話,鎖的概率也很是小,就不必經過追加字節的方式來避免相互鎖定。

參考資料

相關文章
相關標籤/搜索