淺析volatile關鍵字

語言: CN / TW / HK

theme: scrolls-light

小知識,大挑戰!本文正在參與“程式設計師必備小知識”創作活動。

初步認識volatile

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

下面這段程式碼,演示了一個使用了volatile和沒有使用volatile關鍵字對變數更新的影響。

``` public class VolatileTest {

public static void main(String[] args) throws InterruptedException {
    VolatileTest test = new VolatileTest();
    test.start();
    for (;;) {
        if (test.isFlag()) {
            System.out.println("hi");
        }
    }
}

} class VolatileTest extends Thread { private /volatile/ boolean flag = false;

public boolean isFlag() {
    return flag;
}

@Override
public void run() {
    try {
        Thread.sleep(1000);
    } catch (Exception ex) {
        ex.printStackTrace();
    }
    flag = true;
    System.out.println("flag = " + flag);
}

}

``` 執行之後會發現,如果沒加volatile關鍵字就不會輸出hi這個結果,但是執行緒中明明改了flag變數的值啊,這裡就是volatile在這段程式碼中所起的作用了。

volatile的特性一:保證可見性

volatile可以使得在多處理器環境下保證了共享變數的可見性,那麼到底什麼是可見性? 解決記憶體可見性問題方式的一種是加鎖,但是使用鎖太笨重,因為它會帶來執行緒上下文的切換開銷。Java提供了一種弱形式的同步,也就是volatile關鍵字。該關鍵字確保對一個變數的更新對其他執行緒馬上可見。

當一個變數被宣告為volatile時,執行緒在寫入變數時不會把值快取在暫存器或者其他地方,而是會把值重新整理回主記憶體。

當其他執行緒讀取該共享變數時,會從主記憶體重新獲取最新值,而不是使用當前執行緒的工作記憶體中的值。

理解volatile保證可見性的一個好方法是把對volatile變數的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步。

檢視上述程式碼的彙編指令的時候發現,在修改帶有volatile修飾的成員變數時,會多出一個lock指令。lock指令是一種控制指令,在多執行緒環境下,lock彙編指令可以基於匯流排鎖或者快取鎖的機制來達到可見性的效果。

volatile的特性二:禁止指令重排

重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段。

重排序的型別

一個好的記憶體模型實際上會放鬆對處理器和編譯器規則的束縛,也就是說軟體技術和硬體技術都為同一個目標,而進行奮鬥:在不改變程式執行結果的前提下,儘可能提高執行效率。JMM對底層儘量減少約束,使其能夠發揮自身優勢。因此,在執行程式時,為了提高效能,編譯器和處理器常常會對指令進行重排序。一般重排序可以分為如下三種:

編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序;

指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序;

記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行的。 這裡還得提一個概念,as-if-serial。

不管怎麼重排序,單執行緒下的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。

volatile如何保證不會被執行重排序

java編譯器會在生成指令系列時在適當的位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。為了實現volatile的記憶體語義,JMM會限制特定型別的編譯器和處理器重排序,JMM會針對編譯器制定volatile重排序規則表:

表.PNG 但是volatile寫是在前面和後面分別插入記憶體屏障,而volatile讀操作是在後面插入兩個記憶體屏障。

指令重排.PNG

volatile寫讀如下:

讀.PNG

寫.PNG

volatile的特性三:不保證原子性

所謂原子性就是:不可分割,也即某個執行緒在做某個具體業務時,中間不可以被加塞或者分割,需要整體完整,要麼同時成功 要麼同時失敗。 看下如下程式碼:

``` public class VolatileAtomic {

public static void main(String[] args) {
    MyTest myTest= new MyTest();
    for(int i = 1; i <= 20; i++) {
        new Thread(() -> {
            for (int j = 1; j <= 1000; j++) {
                myTest.addNum();
            }
        }, String.valueOf(i)).start();
    }
    while(Thread.activeCount()>2){
        Thread.yield();
    }
    System.out.println(Thread.currentThread().getName()+"\t number= "+myTest.num);
}

}

class MyTest { public volatile int num = 0; public void addNum() { num++; } }

``` 添加了volatile,最終結果應該為20000,實際輸出小於等於20000,說明volatile不保證原子性。 volatile不保證原子性是因為num++在多執行緒下是非執行緒安全的。num++方法編譯成位元組碼後,分為以下三步執行的: 1.從主存中複製 i 的值並複製到 CPU 的工作記憶體中。

2.CPU 取工作記憶體中的值,然後執行 i++操作,完成後重新整理到工作記憶體。

3.將工作記憶體中的值更新到主存。

原本執行緒1在自己的工作空間中將num改為1,寫回主記憶體,主記憶體由於記憶體可見性,通知執行緒2 3,num=1;執行緒2通過變數的副本拷貝,將num拷貝並++,num=2;再次寫入主記憶體通知執行緒3,num=2,執行緒3通過變數的副本拷貝,將num拷貝並++,num=3; 然而 多執行緒競爭排程的原因,1號執行緒剛剛要寫1的時候被掛起,2號執行緒將1寫入主記憶體,此時應該通知其他執行緒,主記憶體的值更改為1,由於執行緒操作極快,還沒有通知到其他執行緒,剛才被掛起的執行緒1 將num=1 又再次寫入了主記憶體,主記憶體的值被覆蓋,出現了丟失寫值;

這種問題可以使用synchronized 或者使用原子變數 來解決。原子變數通過呼叫unsafe類的cas方法實現了原子操作,由於CAS是一種系統原語,原語屬於作業系統用於範疇,是由若干條指令組成,用於完成某個功能的一個過程,並且原語的執行必須是連續的,在執行過程中不允許中斷,也即是說CAS是一條原子指令,不會造成所謂的資料不一致的問題。

總結

volatile是java虛擬機器提供的輕量級的同步機制,主要有以下幾點:保證可見性,禁止指令重排,不保證原子性,volatile只能作用於屬性,用volatile修飾屬性,這樣compilers就不會對這個屬性做指令重排序。 volatile可以在單例雙重檢查中實現可見性和禁止指令重排序,從而保證安全性。

「其他文章」