[Java併發編程(五)] Java volatile 的實現原理

[Java併發編程(五)] Java volatile 的實現原理

簡介

在多線程併發編程中 synchronized 和 volatile 都扮演着重要的角色,volatile 是輕量級的 synchronized,它在多處理器開發中保證了共享變量的「可見性」。可見性的意思是當一個線程修改一個共享變量時,另一個線程能讀到這個修改的值。它在某些狀況下比synchronized 的開銷更小,本文將深刻分析在硬件層面上 Intel 處理器是如何實現 volatile 的,經過深刻分析能幫助咱們正確的使用 volatile 變量。java

正文

術語定義

術語 英文單詞 描述
共享變量 在多個線程之間可以被共享的變量被稱爲共享變量。共享變量包括全部的實例變量,靜態變量和數組元素。他們都被存放在堆內存中,volatile只做用於共享變量。
內存屏障 Memory Barriers 是一組處理器指令,用於實現對內存操做的順序限制。
緩衝行 Cache line 緩存中能夠分配的最小存儲單位。處理器填寫緩存線時會加載整個緩存線,須要使用多個主內存讀週期。
原子操做 Atomic operations 不可中斷的一個或一系列操做。
緩存行填充 Cache line fill 當處理器識別到從內存中讀取操做數是可緩存的,處理器讀取整個緩存行到適當的緩存(L1,L2,L3的或全部)
緩存命中 Cache hit 若是進行高速緩存行填充操做的內存位置仍然是下次處理器訪問的地址時,處理器從緩存中讀取操做數,而不是從內存。
寫命中 Write hit 當處理器將操做數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,若是存在一個有效的緩存行,則處理器將這個操做數寫回到緩存,而不是寫回到內存,這個操做被稱爲寫命中。
寫缺失 Write misses the cache 一個有效的緩存行被寫入到不存在的內存區域。

volatile 官方定義

Java 語言規範第三版中對 volatile 的定義以下: java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致的更新,線程應該確保經過排他鎖單獨得到這個變量。Java 語言提供了 volatile ,在某些狀況下比鎖更加方便。若是一個字段被聲明成 volatile ,java 線程內存模型確保全部線程看到這個變量的值是一致的。編程

爲何要使用 volatile

volatile 變量修飾符若是使用恰當的話,它比 synchronized 的使用和執行成本會更低,由於它不會引發線程上下文的切換和調度。數組

Volatile 的實現原理

那麼 volatile 是如何來保證可見性的呢?在 x86 處理器下經過工具獲取 JIT 編譯器生成的彙編指令來看看對 volatile 進行寫操做 CPU 會作什麼事情。緩存

Java 代碼: instance = new Singleton(); // instance 是 volatile 變量
彙編代碼: 0x01a3de1d: movb $0x0,0x1104800(%esi);0x01a3de24: lock addl $0x0,(%esp);

有 volatile 變量修飾的共享變量進行寫操做的時候會多第二行彙編代碼,經過查 IA-32 架構軟件開發者手冊可知,lock 前綴的指令在多核處理器下會引起了兩件事情。多線程

  • 將當前處理器緩存行的數據會寫回到系統內存。
  • 這個寫回內存的操做會引發在其餘 CPU 裏緩存了該內存地址的數據無效。

處理器爲了提升處理速度,不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存(L1,L2或其餘)後再進行操做,但操做完以後不知道什麼時候會寫到內存,若是對聲明瞭 volatile 變量進行寫操做,JVM 就會向處理器發送一條 Lock 前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。可是就算寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題,因此在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現本身緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器要對這個數據進行修改操做的時候,會強制從新從系統內存裏把數據讀處處理器緩存裏。架構

這兩件事情在 IA-32 軟件開發者架構手冊的第三冊的多處理器管理章節(第八章)中有詳細闡述。併發

Lock前綴指令會引發處理器緩存回寫到內存。 Lock 前綴指令致使在執行指令期間,聲言處理器的 LOCK# 信號。在多處理器環境中,LOCK# 信號確保在聲言該信號期間,處理器能夠獨佔使用任何共享內存。(由於它會鎖住總線,致使其餘C PU 不能訪問總線,不能訪問總線就意味着不能訪問系統內存),可是在最近的處理器裏,LOCK# 信號通常不鎖總線,而是鎖緩存,畢竟鎖總線開銷比較大。在8.1.4章節有詳細說明鎖定操做對處理器緩存的影響,對於 Intel486 和 Pentium 處理器,在鎖操做時,老是在總線上聲言 LOCK# 信號。但在 P6 和最近的處理器中,若是訪問的內存區域已經緩存在處理器內部,則不會聲言 LOCK# 信號。相反地,它會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性,此操做被稱爲「緩存鎖定」,緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據。編程語言

一個處理器的緩存回寫到內存會致使其餘處理器的緩存無效。 IA-32 處理器和 Intel 64 處理器使用 MESI(修改,獨佔,共享,無效)控制協議去維護內部緩存和其餘處理器緩存的一致性。在多核處理器系統中進行操做的時候,IA-32 和 Intel 64 處理器能嗅探其餘處理器訪問系統內存和它們的內部緩存。它們使用嗅探技術保證它的內部緩存,系統內存和其餘處理器的緩存的數據在總線上保持一致。例如在 Pentium 和 P6 family 處理器中,若是經過嗅探一個處理器來檢測其餘處理器打算寫內存地址,而這個地址當前處理共享狀態,那麼正在嗅探的處理器將無效它的緩存行,在下次訪問相同內存地址時,強制執行緩存行填充。高併發

Volatile 的使用優化

著名的 Java 併發編程大師 Doug Lea 在 JDK7 的併發包裏新增一個隊列集合類 LinkedTransferQueue,他在使用 Volatile 變量時,用一種追加字節的方式來優化隊列出隊和入隊的性能。工具

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

/** head of the queue */
    private transient final PaddedAtomicReference<QNode> head;
    
    /** tail of the queue */
    private transient final PaddedAtomicReference<QNode> tail;
    
    static final class PaddedAtomicReference <T> extends AtomicReference <T> {
    
      // enough padding for 64bytes with 4byte refs
      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;
    
      //省略其餘代碼
    
    }

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

那麼是否是在使用 volatile 變量時都應該追加到 64 字節呢?不是的。在兩種場景下不該該使用這種方式。第一:緩存行非64字節寬的處理器,如 P6 系列和奔騰處理器,它們的 L1 和 L2 高速緩存行是 32 個字節寬。第二:共享變量不會被頻繁的寫。由於使用追加字節的方式須要處理器讀取更多的字節到高速緩衝區,這自己就會帶來必定的性能消耗,共享變量若是不被頻繁寫的話,鎖的概率也很是小,就不必經過追加字節的方式來避免相互鎖定。

參考

ifeve: 聊聊併發(一)深刻分析Volatile的實現原理

結束

相關文章
相關標籤/搜索