1. 程式人生 > >Java並發(3)- 聊聊Volatile

Java並發(3)- 聊聊Volatile

我們 public 判斷 out 每次 ada 事務 分析 ron

引言

談到volatile關鍵字,大多數開發者都有一定了解,可以說是開發者非常熟悉,深入之後又非常陌生的一個關鍵字。相當於輕量的synchronized,也叫輕量級鎖,與synchronized相比性能上開銷較少,同時又具備了可見性、有序性以及部分原子性,是Java並發需中非常重要的一個關鍵字。這篇文章我們將從volatile底層原理上來深入剖析他是怎麽保證可見性、有序性以及部分原子性的,同時也會總結一些volatile關鍵字的典型應用場景。

volatile的“部分”原子性

所謂原子性,就是說一個操作是一個完整的整體,在其他線程看來這個操作要麽未開始,要麽已完成,不會看到中間的操作過程,跟事務有點相似。

那為什麽說volatile只具有“部分”原子性,因為從本質上來說volatile是不具備原子性的,他修飾的只是單個變量,大部分情況下單個變量的讀取和賦值本身就具有原子性,但有一個例外,就是32位Java虛擬機下的long/double型變量操作。

在32位Java虛擬機下,long/double型變量的讀寫操作會分為兩部分,先讀寫高32位,在讀寫低32位,或者相反,這樣如果沒有將變量聲明為volatile變量,在多線程讀寫時就有可能導致結果不可預知,因為對單個long/double型變量的讀寫並不是一個整體,也就是不具備原子性,只有使用volatile修飾之後,對單個long/double型變量的讀寫才具備了原子性的特點。在64位Java虛擬機下,long/double型變量讀寫本身就具有原子性,如果只是為了簡單的讀寫就不需要使用volatile修飾。

需要明白的是volatile僅僅只保證變量的讀和寫是原子性操作,並不能保證對變量的復合操作也是原子性的,這是需要註意的地方,最為經典的場景就是對單個變量進行自增和自減。

private volatile static int increaseI = 0;

public static void main(String[] args) {
    for (int i = 0; i < 100000; i++) {
        Thread thread = new Thread(new Runnable() {
            
            @Override
            public void run() {
                
                increaseI++;
            }
        }, String.valueOf(i));
        thread.start();
    }
    
    while(Thread.activeCount()>1)  
        Thread.yield();
    System.out.println(increaseI);
}

如果大家經過測試,會發現很多時候,打印出來的結果不是100000。這就是因為volatile修飾的變量只能保證變量的讀寫是原子性的,而increaseI++是一個復合操作,他可以簡單分為:

var = increaseI; //步驟1:將increaseI的值加載到寄存器var

var = var + 1;//步驟2:將寄存器var的值增加1

increaseI = var;//步驟3:將寄存器var的值寫入increaseI

volatile只能保證第一步和第三部單個操作的原子性,並不能保證整個自增和自減過程的原子性,也就是說volatile修飾的increaseI++並不是原子操作。下圖也可以說明這個問題:

技術分享圖片

volatile的可見性

關於可見性,在前面的《Java並發(2)- 聊聊happens-before》一文中說過,為了提高操作效率,共享變量的讀寫都是在線程的本地內存中進行的,當對變量進行更新後,並不會及時將變量的結果刷新回主內存,在多線程環境下,其他線程就不會及時讀取到最新的變量值。我們可以從下面的代碼來分析這一點。

private static boolean flag = false;
    
private static void refershFlag() throws InterruptedException {
    
    Thread threadA = new Thread(new Runnable() {
        
        @Override
        public void run() {
            while (!flag) {
                //do something
            }
        }
    });
    
    Thread threadB = new Thread(new Runnable() {
        
        @Override
        public void run() {
            
            flag = true;
        }
    });
    
    DateFormat dateFormat  = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
    
    System.out.println("threadA start" + dateFormat.format(new java.util.Date()));
    threadA.start();
    
    Thread.sleep(100);
    
    threadB.start();
    
    threadA.join();
    System.out.println("threadA end" + dateFormat.format(new java.util.Date()));
}

//threadA start2018/07/25 16:48:41

按正常邏輯來說B線程更新變量flag後,A線程應該馬上退出,但實際上很多時候B線程並不會立刻退出,這是因為虛擬機考慮到共享變量沒有采用volatile修飾,默認該變量不需要多線程訪問,於是做了優化,導致flag共享變量沒有及時刷新回主內存,同時其他線程也沒有及時去主內存讀取的結果。那我們給flag變量加上volatile標示會怎麽樣呢?

private volatile static boolean flag = false;

//threadA start2018/07/25 16:48:59
//threadA end2018/07/25 16:48:59

可以看到A線程馬上退出了,從這點可以看出volatile的可見性。

volatile的有序性

JMM在happens-before規則的基礎上保證了單線程和正確同步多線程的有序性,其中就有一條volatile變量規則:對一個volatile變量的寫操作happen—before後面對該變量的讀操作。

這其中有兩點要註意:第一點,針對同一個volatile變量的寫、讀操作之間才有happens-before關系;第二點,有時間上的先後順序,必須是寫操作happen—before讀操作。在《Java並發(2)- 聊聊happens-before》重排序的例子中就很好的說明了volatile禁止重排序的特性。

public class AAndB {

    int x = 0;
    int y = 0;
    int a = 0;
    int b = 0;
    
    public void awrite() {

        a = 1;
        x = b;
    }
    
    public void bwrite() {

        b = 1;
        y = a;
    }
}

public class AThread extends Thread{

    private AAndB aAndB;
    
    public AThread(AAndB aAndB) {
        
        this.aAndB = aAndB;
    }
    
    @Override
    public void run() {
        super.run();
        
        this.aAndB.awrite();
    }
}

public class BThread extends Thread{

    private AAndB aAndB;
    
    public BThread(AAndB aAndB) {
        
        this.aAndB = aAndB;
    }
    
    @Override
    public void run() {
        super.run();
        
        this.aAndB.bwrite();
    }
}

private static void testReSort() throws InterruptedException {

    AAndB aAndB = new AAndB();

    for (int i = 0; i < 10000; i++) {
        AThread aThread = new AThread(aAndB);
        BThread bThread = new BThread(aAndB);

        aThread.start();
        bThread.start();

        aThread.join();
        bThread.join();

        if (aAndB.x == 0 && aAndB.y == 0) {
            System.out.println("resort");
        }

        aAndB.x = aAndB.y = aAndB.a = aAndB.b = 0;

    }

    System.out.println("end");
}

當A線程和B線程都出現了重排序可能會打印出resort,但將變量都變為volatile變量後便不會再出現這種狀況。

volatile的兩個典型使用場景

1 用來標示狀態量。
狀態量標示就是通過一個boolean類型變量來判斷邏輯是否需要執行。就是上面volatile的可見性中的代碼:

Thread threadA = new Thread(new Runnable() {
    
    @Override
    public void run() {
        while (!flag) {
            //do something
        }
    }
});

Thread threadB = new Thread(new Runnable() {
    
    @Override
    public void run() {
        
        flag = true;
    }
});

如果使用synchronized或者鎖寫法上將會比較復雜,但如果用volatile來修飾變量就很好的解決了這個問題,保證了狀態量的及時刷新回主內存同時其他線程也會強制更新。

2 double-check問題
double-check問題應該是volatile使用最多的場景了。如下代碼所示:

public class DoubleCheck {

    private volatile static DoubleCheck instance = null;
    
    private DoubleCheck() {
        
    }
    
    public static DoubleCheck getInstance() {
        
        if (null == instance) {   //步驟一
            synchronized (DoubleCheck.class) {
                if (null == instance) {   //步驟二
                    instance = new DoubleCheck();   //步驟三
                }
            }
        }
        return instance;
    }
    
    public static void main(String[] args) throws InterruptedException {

        DoubleCheck doubleCheck = DoubleCheck.getInstance();
    }
}

代碼中步驟三並不是原子性的,和之前的自增有點類似,可以分為三步:
3.1 為DoubleCheck分配內存地址 alloc memory address
3.2 初始化對象DoubleCheck init DoubleCheck
3.3 將引用地址指向instance instance > memory address
在CPU看來3.2和3.3並不存在依賴關系,是有可能會重排序的,如果將3.2和3.3重排序:
技術分享圖片

線程2在步驟一時判斷instance不為空的情況下,實際上對象並沒有初始化,3.2並沒有執行。導致接下來使用對象發生錯誤。此時使用volatile修飾instance變量就可以防止3.2和3.3重排序,這樣就保證了多線程訪問時代碼的正確性。
我們可以查看到匯編代碼中在使用volatile關鍵字後在步驟三中多了lock指令來保證當前執行的有序性:
不使用volatile:
技術分享圖片

使用volatile
技術分享圖片

volatile背後的原理

在DoubleCheck的匯編代碼中我們看到加了volatile關鍵字後匯編代碼中多了一行lock指令,那麽這個指令代表什麽意思呢?
lock指令有兩個功能:

  1. 對CPU總線和高速緩存加鎖,加鎖之後執行後面的指令,然後釋放鎖時將高速緩存中的數據刷新回主內存。
  2. lock會讓其他CPU高速緩存中的緩存行失效,其他CPU讀取時必須要從主內存加載最新數據。
    簡單來說就是lock指令可以實現緩存一致性。通過lock指令的這兩個功能,我們就可以很簡單的理解當共享變量flag用volatile修飾後,每次更新flag的值都會導致緩存行的數據強制刷新最新值到主內存,volatile變量之前的數據也會被刷新回主內存。同時其他線程必須到主內存讀取最新flag的值。這樣就實現了共享變量的可見性以及有序性。


    參考資料:
    《深入理解Java虛擬機》
    《Java並發編程的藝術》

Java並發(3)- 聊聊Volatile