1. 程式人生 > >多執行緒與高併發(四)volatile關鍵字

多執行緒與高併發(四)volatile關鍵字

上一篇學習了synchronized的關鍵字,synchronized是阻塞式同步,線上程競爭激烈的情況下會升級為重量級鎖,而volatile是一個輕量級的同步機制。

前面學習了Java的記憶體模型,知道各個執行緒會將共享變數從主記憶體中拷貝到工作記憶體,然後執行引擎會基於工作記憶體中的資料進行操作處理。一個CPU中的執行緒讀取主存資料到CPU快取,然後對共享物件做了更改,但CPU快取中的更改後的物件還沒有flush到主存,此時執行緒對共享物件的更改對其它CPU中的執行緒是不可見的。

而volatile修飾的變數給java虛擬機器特殊的約定,執行緒對volatile變數的修改會立刻被其他執行緒所感知,即不會出現資料髒讀的現象,從而保證資料的“可見性”。

我們可以先簡單的理解:被volatile修飾的變數能夠保證每個執行緒能夠獲取該變數的最新值,從而避免出現數據髒讀的現象。

一、三個特性

在分析volatile之前,我們先看下多執行緒的三個特性:原子性,有序性和可見性。

1.1 原子性

原子性是指一個操作是不可中斷的,要麼全部執行成功要麼全部執行失敗。即多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所幹擾。

看下面幾行程式碼:

int a = 10;  //語句1
a++;  //語句2
int b=a; //語句3
a = a+1; //語句4

上面的4行程式碼中,只有語句1才是原子操作。

語句1直接將數值10賦值給a,也就是說執行緒執行這個語句的會直接將數值10寫入到工作記憶體中。

語句2實際上包含了三個操作:1. 讀取變數a的值;2:對a進行加一的操作;3.將計算後的值再賦值給變數a。

語句3包含兩個操作:1:讀取a的值;2:再將a的值寫入工作記憶體。

語句4與語句2類似,也是三個操作。

從這裡可以看出,只有簡單的讀取、賦值(而且必須是將數字賦值給某個變數,變數之間的相互賦值不是原子操作)才是原子操作。

1.2 有序性

有序性是指程式執行的順序按照程式碼的先後順序執行。

Java記憶體模型具備一些先天的“有序性”,即不需要通過任何手段就能夠得到保證的有序性,這個通常也稱為 happens-before 原則。如果兩個操作的執行次序無法從happens-before原則推匯出來,那麼它們就不能保證它們的有序性,虛擬機器可以隨意地對它們進行重排序。

前面執行緒安全篇中學習過happens-before原則,可以去前篇看看。

1.3 可見性

可見性是指當一個執行緒修改了共享變數後,其他執行緒能夠立即得知這個修改。

而普通的共享變數不能保證可見性,因為普通共享變數被修改之後,什麼時候被寫入主存是不確定的,當其他執行緒去讀取時,此時記憶體中可能還是原來的舊值,因此無法保證可見性。

synchronized能夠保證任一時刻只有一個執行緒執行該程式碼塊,並且在釋放鎖之前會將對變數的修改重新整理到主存當中,那麼自然就不存在原子性和可見性問題了,執行緒的有序性當然也可以保證。

下面我們來看看volatile關鍵字。

二、volatile的使用

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

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

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

2.1 可見性

先看下面的程式碼:

public class VolatileTest {
    private static boolean isOver = false;
    private static int a = 1;

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {
                    a++;
                }

            }
        });
        thread.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isOver = true;
    }
}

這裡的程式碼會出現死迴圈,原因在於雖然在主執行緒中改變了isOver的值,但是這個值的改變對於我們新開執行緒中並不可見,線上程的本地記憶體未被修改,所以就會出現死迴圈。

如果我們用volatile關鍵字來修飾變數,則不會出現此情形

private static volatile boolean isOver = false;

這說明volatile關鍵字實現了可見性。

2.2 有序性

再看下面程式碼:

public class Singleton {
    private volatile static Singleton instance;

    private Singleton() {
    }

    public Singleton getInstance() {
        if (instance == null) {//步驟1
            synchronized (Singleton.class) {//步驟2
                if (instance == null) {//步驟3
                    instance = new Singleton();//步驟4
                }
            }
        }
        return instance;
    }

}

這個是大家很熟悉的單例模式double check,在這裡看到使用了volatile字修飾,如果不使用的話,這裡可能會出現重排序的情況。

因為instance = new Singleton()這條語句實際上包含了三個操作:

1.分配物件的記憶體空間;

2.初始化物件;

3.設定instance指向剛分配的記憶體地址。 步驟2和步驟3可能會被重排序,流程變為1->3->2

如果2和3進行了重排序的話,執行緒B進行判斷if(instance==null)時就會為true,而實際上這個instance並沒有初始化成功,將會讀取到一個沒有初始化完成的物件。

用volatile修飾的話就可以禁止2和3操作重排序,從而避免這種情況。volatile包含禁止指令重排序的語義,其具有有序性。

2.3 原子性

看下面程式碼:

public class VolatileExample {
    private static volatile int counter = 0;

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++)
                        counter++;
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
}

啟10個執行緒,每個執行緒都自加10000次,如果不出現執行緒安全的問題最終的結果應該就是:10*10000 = 100000;可是執行多次都是小於100000的結果,問題在於 volatile並不能保證原子性,counter++這並不是一個原子操作,包含了三個步驟:1.讀取變數counter的值;2.對counter加一;3.將新值賦值給變數counter。如果執行緒A讀取counter到工作記憶體後,其他執行緒對這個值已經做了自增操作後,那麼執行緒A的這個值自然而然就是一個過期的值,因此,總結果必然會是小於100000的。

如果讓volatile保證原子性,必須符合以下兩條規則:

  1. 運算結果並不依賴於變數的當前值,或者能夠確保只有一個執行緒修改變數的值;

  2. 變數不需要與其他的狀態變數共同參與不變約束

三、實現原理

上面看到了volatile的使用,volatile能夠保證可見性和有序性,那它的實現原理是什麼呢?

在生成彙編程式碼時會在volatile修飾的共享變數進行寫操作的時候會多出Lock字首的指令,Lock字首的指令在多核處理器下會引發了兩件事情:

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

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

為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取(L1,L2或其他)後再進行操作,但操作完不知道何時會寫到記憶體。如果對聲明瞭volatile的變數進行寫操作,JVM就會向處理器傳送一條Lock字首的指令,將這個變數所在快取行的資料寫回到系統記憶體。但是,就算寫回到記憶體,如果其他處理器快取的值還是舊的,再執行計算操作就會有問題。所以,在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定成無效狀態,當處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。volatile的實現原則:

  1. Lock字首的指令會引起處理器快取寫回記憶體;

  2. 一個處理器的快取回寫到記憶體會導致其他處理器的快取失效;

  3. 當處理器發現本地快取失效後,就會從記憶體中重讀該變數資料,即可以獲取當前最新值。

3.1 記憶體語義

理解了volatile關鍵字的大體實現原理,那對內volatile的記憶體語義也相對好理解,看下面的程式碼:

public class VolatileExample2 {
    private int a = 0;
    private boolean flag = false;

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

    public void reader() {
        if (flag) {      
            int i = a; 
        }
    }
}

假設執行緒A先執行writer方法,執行緒B隨後執行reader方法,初始時執行緒的本地記憶體中flag和a都是初始狀態,下圖是執行緒A執行volatile寫後的狀態圖。

如果添加了volatile變數寫後,執行緒中本地記憶體中共享變數就會置為失效的狀態,因此執行緒B再需要讀取從主記憶體中去讀取該變數的最新值。下圖就展示了執行緒B讀取同一個volatile變數的記憶體變化示意圖。

對volatile寫和volatile讀的記憶體語義做個總結。

  • 執行緒A寫一個volatile變數,實質上是執行緒A向接下來將要讀這個volatile變數的某個執行緒 發出了(其對共享變數所做修改的)訊息。

  • 執行緒B讀一個volatile變數,實質上是執行緒B接收了之前某個執行緒發出的(在寫這個volatile 變數之前對共享變數所做修改的)訊息。

  • 執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,這個過程實質上是執行緒A通過 主記憶體向執行緒B傳送訊息。

 

3.2 記憶體語義的實現

我們知道,JMM是允許編譯器和處理器對指令序列進行重排序的,但我們也可以用一些特殊的方式組織指令阻止指令重排序,這個方式就是增加記憶體屏障。我們先來簡答瞭解下記憶體屏障,JMM把記憶體屏障指令分為4類:

StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支援該屏障(其他型別的屏障不一定被所有處理器支援)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩衝區中的資料全部重新整理到記憶體中(Buffer Fully Flush)。

瞭解完記憶體屏障後,我們再來看下volatile的重排序規則:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。

  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。

  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

要實現volatile的重排序規則,需要來增加一些記憶體屏障,為了保證在任意處理器平臺都可以實現,記憶體屏障插入策略非常保守,主要做法如下:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障。

  • 在每個volatile寫操作的後面插入一個StoreLoad屏障。

  • 在每個volatile讀操作的後面插入一個LoadLoad屏障。

  • 在每個volatile讀操作的後面插入一個LoadStore屏障。

需要注意的是:volatile寫是在前面和後面分別插入記憶體屏障,而volatile讀操作是在後面插入兩個記憶體屏障

StoreStore屏障:禁止上面的普通寫和下面的volatile寫重排序;

StoreLoad屏障:防止上面的volatile寫與下面可能有的volatile讀/寫重排序

LoadLoad屏障:禁止下面所有的普通讀操作和上面的volatile讀重排序

LoadStore屏障:禁止下面所有的普通寫操作和上面的volatile讀重排序

volatile寫插入記憶體屏障後生成的指令序列示意圖:

volatile讀插入記憶體屏障後生成的指令序列示意圖:

&n