多線程與高並發(四)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讀插入內存屏障后生成的指令序列示意圖:

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM