併發原理系列五:volatile變量

一、基本概念回顧


在進入正文之前,首先回顧一下Java 內存模型中的一些基本概念:可見性、原子性和有序性。

可見性:

  可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。通常,我們無法確保執行讀操作的線程能適時地看到其他線程寫入的值,有時甚至是根本不可能的事情。為了確保多個線程之間對內存寫入操作的可見性,必須使用同步機制。

  可見性,是指線程之間的可見性,一個線程修改的狀態對另一個線程是可見的。也就是一個線程修改的結果。另一個線程馬上就能看到。比如:用volatile修飾的變量,就會具有可見性。volatile修飾的變量不允許線程內部緩存和重排序,即直接修改內存。所以對其他線程是可見的。但是這裡需要注意一個問題,volatile只能讓被他修飾內容具有可見性,但不能保證它具有原子性。比如 volatile int a = 0;之後有一個操作 a++;這個變量a具有可見性,但是a++ 依然是一個非原子操作,也就是這個操作同樣存在線程安全問題。

  在 Java 中 volatile、synchronized 和 final 實現可見性。

原子性:

  原子是世界上的最小單位,具有不可分割性。比如 a=0;(a非long和double類型) 這個操作是不可分割的,那麼我們說這個操作時原子操作。再比如:a++; 這個操作實際是a = a + 1;是可分割的,所以他不是一個原子操作。非原子操作都會存在線程安全問題,需要我們使用同步技術(sychronized)來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

  在 Java 中 synchronized 和在 lock、unlock 中操作保證原子性。

有序性:

  Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 是因為其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變量在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串行執行。

二、CPU與內存

如下圖所示,一個簡化的CPU與內存的關係圖。CPU在對主內存中的共享變量進行操作的時候,並不是直接操作主內存,那樣速度太慢了,而是將主內存中的變量 “拷貝” 到緩存中(嚴格的說,計算機的緩存分為1、2、3級緩存,這裡簡化為緩存),在高速緩存中執行操作完畢後,再回寫到主內存中。

不難想見,多CPU、多線程的環境下,如果共享變量沒有采用鎖機制,那麼,多個線程併發操作主內存的共享變量(如 int value=0),由於變量在線程間是不可見的,可能出現以下情形(舉例):

  1. A、B線程分別將變量value從主內存讀到各自的緩存中,此時,對於兩個線程來說,value=0,緩存與主內存一致;
  2. 線程A對變量value進行寫操作,如value=10,完成後回寫到主內存,線程B中value仍然為0,主內存與緩存不一致;
  3. 線程B完成對value的寫操作,回寫主內存,覆蓋掉線程A寫入的值;


併發原理系列五:volatile變量

三、Lock前綴指令

關於LOCK指令,Intel手冊的解釋如下:

Causes the processor’s LOCK# signal to be asserted during execution of the accompanying instruction (turns the instruction into an atomic instruction). In a multiprocessor environment, the LOCK# signal insures that the processor has exclusive use of any shared memory while the signal is asserted.

其意為:LOCK指令會使緊跟在其後面的指令變成 原子操作(atomic instruction)。暫時的鎖一下總線,指令執行完了,總線就解鎖了!!!

  • LOCK前綴指令
  • LOCK 指令是一個彙編層面的指令,在一些特殊的場景下,作為前綴加在以下彙編指令之前,保證操作的原子性,這種指令被稱為 “LOCK前綴指令”。

    ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG.

    LOCK前綴導致處理器在執行指令時會置上LOCK#信號,於是該指令就被作為一個原子指令(atomic instruction)執行。在多處理器環境下,置上LOCK#信號可以確保任何一個處理器能獨佔使用任何共享內存。

    鎖總線

    LOCK總線封鎖信號,三態輸出,低電平有效。LOCK有效時表示CPU不允許其它總線主控者佔用總線(CPU與內存等硬件之前的通信需要經過總線)。這個信號由軟件設置,當前指令前加上LOCK前綴時,則在執行這條指令期間LOCK保持有效,阻止其它主控者使用總線。說白了就是LOCK前綴只保證對當前指令要訪問的內存互斥。

    換言之,在多處理器環境中,LOCK#信號確保在聲言該信號期間,處理器可以獨佔任何共享內存(通過鎖住總線,避免其它處理器訪問共享內存),不過成本較高。

    鎖緩存

    在Pentium4、Inter Xeon和P6系列以及之後的處理器中,LOCK#信號一般不鎖總線,而是鎖緩存,畢竟鎖總線開銷的比較大。

    在所有的 X86 CPU 上都具有鎖定一個特定內存地址的能力,當這個特定內存地址被鎖定後,它就可以阻止其它的系統總線讀取或修改這個內存地址。這種能力是通過 LOCK 指令前綴再加上具體操作(如ADD)的彙編指令來實現的。當使用 LOCK 指令前綴時,它會使 CPU 宣告一個 LOCK# 信號,這樣就能確保在多處理器系統或多線程競爭的環境下互斥地使用這個內存地址。當指令執行完畢,這個鎖定動作也就會消失。

    處理器使用嗅探技術保證它的內部緩存、系統內存和其他處理器的緩存的數據在總線上保持一致。例如CPU A嗅探到CPU B打算寫內存地址,且這個地址處於共享狀態,那麼正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執行緩存行填充。這就是MESI協議處於共享S狀態下去修改操作,同時發出Invalid指令,但只有一個是執行成功,另一個失敗接收緩存行失效然後重新讀取


    四、Volatile保證可見性的實現原理


    關於volatile原理,首先要明白JVM的內存模型:共享變量是存在主內存中的,各個線程若要操作共享變量,則需創建一個共享變量的拷貝,這個拷貝存在於線程私有的內存中,操作完成後,再回寫到主內存中。很明顯,這個過程會導致不同線程中共享變量不一致。因此,Java引入volatile關鍵字來保障共享變量在線程間的“可見性”——volatile修飾的變量的所有寫操作都能立刻反應到其它線程中


  • volatile如何保證可見性?
  • 對volatile修飾的變量進行寫操作(賦值),在生成彙編代碼中,會有如下的情形:

    // 寫操作

    0x01a3de1d: movb $0×0,0×1104800(%esi);

    // 內存屏障

    0x01a3de24: lock addl $0×0,(%esp);

    其中,賦值後會再執行一個“lock addl $0×0,(%esp)”操作,這個操作相當於一個內存屏障(Memory Barrier),指令重排時,不能把後面的指令重排到內存屏障之前的位置,如果是單核CPU訪問,則不需要內存屏障;但如果是多核CPU訪問同一塊內存,則需用內存屏障保障一致性。

    多處理器、多線程環境下,若某個線程對聲明瞭volatile的變量進行寫操作,JVM會向處理器發送一條LOCK前綴的指令,將這個變量所在緩存行的數據寫回主內存,LOCK前綴指令通過 “鎖緩存” 可以確保回寫主內存的操作是原子性的。但是,其它處理器的緩存中存儲的仍然是 “舊值” ,並不能保證可見性,因此,還要藉助緩存一致性協議:每個處理器通過嗅探在總線上傳播的數據來檢查自己的緩存值是否過期,當處理器發現自己緩存行對應的內存地址被修改時,就會設置當前緩存行為無效,需要對數據進行修改的時候會重新從主內存中加載。如此,便保證了可見性。

  • volatile不能保證原子性!
  • 為什麼不能保證原子性呢?對於volatile修飾的變量,LOCK前綴指令保證的是其寫操作和回寫主內存的操作是原子性的。什麼是寫操作?如:value=10,對變量value進行寫,這是實實在在的寫,是一個原子操作。

    但是,“value++;”是單純的寫操作嗎?不是!value++的時候會先將value賦值給另外一個臨時變量(設為tmp),tmp屬於工作內存的局部變量表,再將tmp返回到緩存,緩存再返回到主存,這裡需要一些寄存器運算的知識。形象一些,可以把value++拆分成以下偽代碼:

    int tmp = value; //1

    tmp = tmp + 1; //2

    value = tmp; //3

    很明顯,對於變量value而言,最後一步:value=temp才是真正的寫操作,LOCK前綴指令可以保證寫操作和回寫主內存的操作是原子性的。而前面兩步並沒有對value進行任何寫操作,JVM不會做出反應,這就是為什麼volatile不能保證原子性的根本原因。

    五、Volatile禁止指令重排序

    volatile關鍵字禁止指令重排序有兩層意思(不完全禁止):

    • 程序執行到volatile變量的讀或寫時,在其前面的操作肯定全部已經執行完畢,且結果已經對後面的操作可見;在其後面的操作肯定還沒有執行
    • 在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

    lock前綴指令相當於一個內存屏障

    (也稱內存柵欄),內存屏障主要提供3個功能:

    1. 確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;
    2. 強制將對緩存的修改操作立即寫入主存,利用緩存一致性機制,並且緩存一致性機制會阻止同時修改由兩個以上CPU緩存的內存區域數據;
    3. 如果是寫操作,它會導致其他CPU中對應的緩存行無效。
    4. 可以理解為在volatile變量前後會加上讀寫屏障,使得讀之前invalid 隊列已經都執行,寫之後storebuffer裡面的數據也都已經執行,不清楚invalid queue 和 store buffer的可以看前面的文章


    分享到:


    相關文章: