1. 程式人生 > 實用技巧 >工作 5 年了,竟然不知道 volatile 關鍵字?

工作 5 年了,竟然不知道 volatile 關鍵字?

“工作 5 年了,竟然不知道 volatile 關鍵字!”

聽著剛面試完的架構師一頓吐槽,其他幾個同事也都參與這次吐槽之中。

都說國內的面試是“面試造航母,工作擰螺絲”,有時候你就會因為一個問題被PASS。

你工作幾年了?知道 volatile 關鍵字嗎?

今天就讓我們一起來學習一下 volatile 關鍵字,做一個在可以面試中造航母的螺絲工!

volatile

Java語言規範第三版中對 volatile 的定義如下:

java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。

Java語言提供了 volatile,在某些情況下比鎖更加方便。

如果一個欄位被宣告成 volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。

“工作 5 年了,竟然不知道 volatile 關鍵字!”

聽著剛面試完的架構師一頓吐槽,其他幾個同事也都參與這次吐槽之中。

都說國內的面試是“面試造航母,工作擰螺絲”,有時候你就會因為一個問題被PASS。

你工作幾年了?知道 volatile 關鍵字嗎?

今天就讓我們一起來學習一下 volatile 關鍵字,做一個在可以面試中造航母的螺絲工!

福利 福利 福利 免費領取Java架構技能地圖 注意了是免費送

免費領取 要的+V 領取

volatile

Java語言規範第三版中對 volatile 的定義如下:

java程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致的更新,執行緒應該確保通過排他鎖單獨獲得這個變數。

Java語言提供了 volatile,在某些情況下比鎖更加方便。

如果一個欄位被宣告成 volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。

語義

一旦一個共享變數(類的成員變數、類的靜態成員變數)被 volatile 修飾之後,那麼就具備了兩層語義:

  1. 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。

  2. 禁止進行指令重排序。

  • 注意

如果 final 變數也被宣告為 volatile,那麼這就是編譯時錯誤。

ps: 一個意思是變化可見,一個是永不變化。自然水火不容。

問題引入

  • Error.java
//執行緒1
boolean stop = false;
while(!stop){
    doSomething();
}
 
//執行緒2
stop = true;
複製程式碼

這段程式碼是很典型的一段程式碼,很多人在中斷執行緒時可能都會採用這種標記辦法。

問題分析

但是事實上,這段程式碼會完全執行正確麼?即一定會將執行緒中斷麼?

不一定,也許在大多數時候,這個程式碼能夠把執行緒中斷,但是也有可能會導致無法中斷執行緒(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死迴圈了)。

下面解釋一下這段程式碼為何有可能導致無法中斷執行緒。

在前面已經解釋過,每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。

那麼當執行緒 2 更改了 stop 變數的值之後,但是還沒來得及寫入主存當中,執行緒 2 轉去做其他事情了,

那麼執行緒 1 由於不知道執行緒 2 對 stop 變數的更改,因此還會一直迴圈下去。

使用 volatile

第一:使用 volatile 關鍵字會強制將修改的值立即寫入主存;

第二:使用 volatile 關鍵字的話,當執行緒2進行修改時,會導致執行緒1的工作記憶體中快取變數stop的快取行無效(反映到硬體層的話,就是CPU的L1或者L2快取中對應的快取行無效);

第三:由於執行緒1的工作記憶體中快取變數 stop 的快取行無效,所以執行緒 1 再次讀取變數 stop 的值時會去主存讀取。

那麼線上程 2 修改 stop 值時(當然這裡包括 2 個操作,修改執行緒 2 工作記憶體中的值,然後將修改後的值寫入記憶體), 會使得執行緒 1 的工作記憶體中快取變數 stop 的快取行無效,然後執行緒 1 讀取時, 發現自己的快取行無效,它會等待快取行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。

那麼執行緒 1 讀取到的就是最新的正確的值。

volatile 保證原子性嗎

從上面知道 volatile 關鍵字保證了操作的可見性,但是 volatile 能保證對變數的操作是原子性嗎?

問題引入

public class VolatileAtomicTest {

    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final VolatileAtomicTest test = new VolatileAtomicTest();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    test.increase();
                }
            }).start();
        }

        //保證前面的執行緒都執行完
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(test.inc);
    }
}
複製程式碼
  • 計算結果是多少?

你可能覺得是 10000,但是實際是比這個數要小。

原因

可能有的朋友就會有疑問,不對啊,上面是對變數 inc 進行自增操作,由於 volatile 保證了可見性, 那麼在每個執行緒中對inc自增完之後,在其他執行緒中都能看到修改後的值啊,所以有10個執行緒分別進行了 1000 次操作,那麼最終inc的值應該是 1000*10=10000。

這裡面就有一個誤區了,volatile 關鍵字能保證可見性沒有錯,但是上面的程式錯在沒能保證原子性。

可見性只能保證每次讀取的是最新的值,但是 volatile 沒辦法保證對變數的操作的原子性。

  • 解決方式

使用 Lock synchronized 或者 AtomicInteger

volatile 能保證有序性嗎

volatile關鍵字禁止指令重排序有兩層意思:

  1. 當程式執行到 volatile 變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

  2. 在進行指令優化時,不能將在對 volatile 變數訪問的語句放在其後面執行,也不能把 volatile 變數後面的語句放到其前面執行。

例項

  • 例項一
//x、y為非volatile變數
//flag為volatile變數
 
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;        //語句4
y = -1;       //語句5
複製程式碼

由於 flag 變數為 volatile 變數,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。

但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

並且 volatile 關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

  • 例項二
//執行緒1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//執行緒2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);
複製程式碼

前面舉這個例子的時候,提到有可能語句2會在語句1之前執行,那麼久可能導致 context 還沒被初始化,而執行緒2中就使用未初始化的context去進行操作,導致程式出錯。

這裡如果用 volatile 關鍵字對 inited 變數進行修飾,就不會出現這種問題了,因為當執行到語句2時,必定能保證 context 已經初始化完畢。

常見使用場景

而 volatile 關鍵字在某些情況下效能要優於 synchronized,

但是要注意 volatile 關鍵字是無法替代 synchronized 關鍵字的,因為 volatile 關鍵字無法保證操作的原子性。

通常來說,使用 volatile 必須具備以下2個條件:

  1. 對變數的寫操作不依賴於當前值

  2. 該變數沒有包含在具有其他變數的不變式中

實際上,這些條件表明,可以被寫入 volatile 變數的這些有效值獨立於任何程式的狀態,包括變數的當前狀態。

事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程式在併發時能夠正確執行。

常見場景

  • 狀態標記量
volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
複製程式碼
  • 單例 double check
public class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
複製程式碼

JSR-133 的增強

在 JSR-133 之前的舊 Java 記憶體模型中,雖然不允許 volatile 變數之間重排序,但舊的 Java 記憶體模型允許 volatile 變數與普通變數之間重排序。

在舊的記憶體模型中,VolatileExample 示例程式可能被重排序成下列時序來執行:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                      //1
        flag = true;                //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;            //4
        }
    }
}
複製程式碼
  • 時間線
時間線:----------------------------------------------------------------->
執行緒 A:(2)寫 volatile 變數;                                  (1)修改共享變數 
執行緒 B:                    (3)讀取 volatile 變數; (4)讀共享變數
複製程式碼

在舊的記憶體模型中,當1和2之間沒有資料依賴關係時,1和2之間就可能被重排序(3和4類似)。

其結果就是:讀執行緒B執行4時,不一定能看到寫執行緒A在執行1時對共享變數的修改。

因此在舊的記憶體模型中 ,volatile 的寫-讀沒有監視器的釋放-獲所具有的記憶體語義。

為了提供一種比監視器鎖更輕量級的執行緒之間通訊的機制,

JSR-133專家組決定增強 volatile 的記憶體語義:

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

從編譯器重排序規則和處理器記憶體屏障插入策略來看,只要 volatile 變數與普通變數之間的重排序可能會破壞 volatile 的記憶體語意, 這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止。

volatile 實現原理

術語定義

術語英文單詞描述
共享變數 Shared variables 在多個執行緒之間能夠被共享的變數被稱為共享變數。共享變數包括所有的例項變數,靜態變數和陣列元素。他們都被存放在堆記憶體中,volatile 只作用於共享變數
記憶體屏障 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);
0x01a3de24: lock addl $0x0,(%esp);
複製程式碼

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

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

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

處理器為了提高處理速度,不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完之後不知道何時會寫到記憶體,

如果對聲明瞭 volatile 變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。

但是就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。

所以在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了, 當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡。

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

Lock 字首指令會引起處理器快取回寫到記憶體

Lock 字首指令導致在執行指令期間,聲言處理器的 LOCK# 訊號。

在多處理器環境中,LOCK# 訊號確保在聲言該訊號期間,處理器可以獨佔使用任何共享記憶體。(因為它會鎖住匯流排,導致其他CPU不能訪問匯流排,不能訪問匯流排就意味著不能訪問系統記憶體),但是在最近的處理器裡,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個位元組。

  • LinkedTransferQueue.java
/** 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個位元組寬。

第二:共享變數不會被頻繁的寫。

因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,共享變數如果不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。

ps: 忽然覺得術業想專攻,博學與睿智缺一不可。

double/long 執行緒不安全

Java虛擬機器規範定義的許多規則中的一條:所有對基本型別的操作,除了某些對long型別和double型別的操作之外,都是原子級的。

目前的JVM(java虛擬機器)都是將32位作為原子操作,並非64位。

當執行緒把主存中的 long/double型別的值讀到執行緒記憶體中時,可能是兩次32位值的寫操作,顯而易見,如果幾個執行緒同時操作,那麼就可能會出現高低2個32位值出錯的情況發生。

要線上程間共享long與double欄位時,必須在synchronized中操作,或是宣告為volatile。


作者:老馬嘯西風
連結:https://juejin.im/post/6885690021823643656
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

語義

一旦一個共享變數(類的成員變數、類的靜態成員變數)被 volatile 修飾之後,那麼就具備了兩層語義:

  1. 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。

  2. 禁止進行指令重排序。

  • 注意

如果 final 變數也被宣告為 volatile,那麼這就是編譯時錯誤。

ps: 一個意思是變化可見,一個是永不變化。自然水火不容。

問題引入

  • Error.java
//執行緒1
boolean stop = false;
while(!stop){
    doSomething();
}
 
//執行緒2
stop = true;
複製程式碼

這段程式碼是很典型的一段程式碼,很多人在中斷執行緒時可能都會採用這種標記辦法。

問題分析

但是事實上,這段程式碼會完全執行正確麼?即一定會將執行緒中斷麼?

不一定,也許在大多數時候,這個程式碼能夠把執行緒中斷,但是也有可能會導致無法中斷執行緒(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死迴圈了)。

下面解釋一下這段程式碼為何有可能導致無法中斷執行緒。

在前面已經解釋過,每個執行緒在執行過程中都有自己的工作記憶體,那麼執行緒1在執行的時候,會將stop變數的值拷貝一份放在自己的工作記憶體當中。

那麼當執行緒 2 更改了 stop 變數的值之後,但是還沒來得及寫入主存當中,執行緒 2 轉去做其他事情了,

那麼執行緒 1 由於不知道執行緒 2 對 stop 變數的更改,因此還會一直迴圈下去。

使用 volatile

第一:使用 volatile 關鍵字會強制將修改的值立即寫入主存;

第二:使用 volatile 關鍵字的話,當執行緒2進行修改時,會導致執行緒1的工作記憶體中快取變數stop的快取行無效(反映到硬體層的話,就是CPU的L1或者L2快取中對應的快取行無效);

第三:由於執行緒1的工作記憶體中快取變數 stop 的快取行無效,所以執行緒 1 再次讀取變數 stop 的值時會去主存讀取。

那麼線上程 2 修改 stop 值時(當然這裡包括 2 個操作,修改執行緒 2 工作記憶體中的值,然後將修改後的值寫入記憶體), 會使得執行緒 1 的工作記憶體中快取變數 stop 的快取行無效,然後執行緒 1 讀取時, 發現自己的快取行無效,它會等待快取行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。

那麼執行緒 1 讀取到的就是最新的正確的值。

volatile 保證原子性嗎

從上面知道 volatile 關鍵字保證了操作的可見性,但是 volatile 能保證對變數的操作是原子性嗎?

問題引入

public class VolatileAtomicTest {

    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final VolatileAtomicTest test = new VolatileAtomicTest();
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    test.increase();
                }
            }).start();
        }

        //保證前面的執行緒都執行完
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(test.inc);
    }
}
複製程式碼
  • 計算結果是多少?

你可能覺得是 10000,但是實際是比這個數要小。

原因

可能有的朋友就會有疑問,不對啊,上面是對變數 inc 進行自增操作,由於 volatile 保證了可見性, 那麼在每個執行緒中對inc自增完之後,在其他執行緒中都能看到修改後的值啊,所以有10個執行緒分別進行了 1000 次操作,那麼最終inc的值應該是 1000*10=10000。

這裡面就有一個誤區了,volatile 關鍵字能保證可見性沒有錯,但是上面的程式錯在沒能保證原子性。

可見性只能保證每次讀取的是最新的值,但是 volatile 沒辦法保證對變數的操作的原子性。

  • 解決方式

使用 Lock synchronized 或者 AtomicInteger

volatile 能保證有序性嗎

volatile關鍵字禁止指令重排序有兩層意思:

  1. 當程式執行到 volatile 變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

  2. 在進行指令優化時,不能將在對 volatile 變數訪問的語句放在其後面執行,也不能把 volatile 變數後面的語句放到其前面執行。

例項

  • 例項一
//x、y為非volatile變數
//flag為volatile變數
 
x = 2;        //語句1
y = 0;        //語句2
flag = true;  //語句3
x = 4;        //語句4
y = -1;       //語句5
複製程式碼

由於 flag 變數為 volatile 變數,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。

但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

並且 volatile 關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

  • 例項二
//執行緒1:
context = loadContext();   //語句1
inited = true;             //語句2
 
//執行緒2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);
複製程式碼

前面舉這個例子的時候,提到有可能語句2會在語句1之前執行,那麼久可能導致 context 還沒被初始化,而執行緒2中就使用未初始化的context去進行操作,導致程式出錯。

這裡如果用 volatile 關鍵字對 inited 變數進行修飾,就不會出現這種問題了,因為當執行到語句2時,必定能保證 context 已經初始化完畢。

常見使用場景

而 volatile 關鍵字在某些情況下效能要優於 synchronized,

但是要注意 volatile 關鍵字是無法替代 synchronized 關鍵字的,因為 volatile 關鍵字無法保證操作的原子性。

通常來說,使用 volatile 必須具備以下2個條件:

  1. 對變數的寫操作不依賴於當前值

  2. 該變數沒有包含在具有其他變數的不變式中

實際上,這些條件表明,可以被寫入 volatile 變數的這些有效值獨立於任何程式的狀態,包括變數的當前狀態。

事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程式在併發時能夠正確執行。

常見場景

  • 狀態標記量
volatile boolean flag = false;
 
while(!flag){
    doSomething();
}
 
public void setFlag() {
    flag = true;
}
複製程式碼
  • 單例 double check
public class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
複製程式碼

JSR-133 的增強

在 JSR-133 之前的舊 Java 記憶體模型中,雖然不允許 volatile 變數之間重排序,但舊的 Java 記憶體模型允許 volatile 變數與普通變數之間重排序。

在舊的記憶體模型中,VolatileExample 示例程式可能被重排序成下列時序來執行:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;

    public void writer() {
        a = 1;                      //1
        flag = true;                //2
    }

    public void reader() {
        if (flag) {                //3
            int i =  a;            //4
        }
    }
}
複製程式碼
  • 時間線
時間線:----------------------------------------------------------------->
執行緒 A:(2)寫 volatile 變數;                                  (1)修改共享變數 
執行緒 B:                    (3)讀取 volatile 變數; (4)讀共享變數
複製程式碼

在舊的記憶體模型中,當1和2之間沒有資料依賴關係時,1和2之間就可能被重排序(3和4類似)。

其結果就是:讀執行緒B執行4時,不一定能看到寫執行緒A在執行1時對共享變數的修改。

因此在舊的記憶體模型中 ,volatile 的寫-讀沒有監視器的釋放-獲所具有的記憶體語義。

為了提供一種比監視器鎖更輕量級的執行緒之間通訊的機制,

JSR-133專家組決定增強 volatile 的記憶體語義:

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

從編譯器重排序規則和處理器記憶體屏障插入策略來看,只要 volatile 變數與普通變數之間的重排序可能會破壞 volatile 的記憶體語意, 這種重排序就會被編譯器重排序規則和處理器記憶體屏障插入策略禁止。

volatile 實現原理

術語定義

術語英文單詞描述
共享變數 Shared variables 在多個執行緒之間能夠被共享的變數被稱為共享變數。共享變數包括所有的例項變數,靜態變數和陣列元素。他們都被存放在堆記憶體中,volatile 只作用於共享變數
記憶體屏障 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);
0x01a3de24: lock addl $0x0,(%esp);
複製程式碼

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

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

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

處理器為了提高處理速度,不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完之後不知道何時會寫到記憶體,

如果對聲明瞭 volatile 變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。

但是就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。

所以在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了, 當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器要對這個資料進行修改操作的時候,會強制重新從系統記憶體裡把資料讀到處理器快取裡。

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

Lock 字首指令會引起處理器快取回寫到記憶體

Lock 字首指令導致在執行指令期間,聲言處理器的 LOCK# 訊號。

在多處理器環境中,LOCK# 訊號確保在聲言該訊號期間,處理器可以獨佔使用任何共享記憶體。(因為它會鎖住匯流排,導致其他CPU不能訪問匯流排,不能訪問匯流排就意味著不能訪問系統記憶體),但是在最近的處理器裡,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個位元組。

  • LinkedTransferQueue.java
/** 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個位元組寬。

第二:共享變數不會被頻繁的寫。

因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,共享變數如果不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。

ps: 忽然覺得術業想專攻,博學與睿智缺一不可。

double/long 執行緒不安全

Java虛擬機器規範定義的許多規則中的一條:所有對基本型別的操作,除了某些對long型別和double型別的操作之外,都是原子級的。

目前的JVM(java虛擬機器)都是將32位作為原子操作,並非64位。

當執行緒把主存中的 long/double型別的值讀到執行緒記憶體中時,可能是兩次32位值的寫操作,顯而易見,如果幾個執行緒同時操作,那麼就可能會出現高低2個32位值出錯的情況發生。

要線上程間共享long與double欄位時,必須在synchronized中操作,或是宣告為volatile。