Java之先行發生原則與volatile關鍵字詳解


volatile關鍵字可以說是Java虛擬機提供的最輕量級的同步機制,但是它並不容易完全被正確、完整地理解,以至於許多程序員都習慣不去使用它,遇到需要處理多線程數據競爭問題的時候一律使用synchronized來進行同步。了解volatile變量的語義對了解多線程操作的其他特性很有意義,在本文中我們將介紹volatile的語義到底是什么。由於volatile關鍵字與Java內存模型(Java Memory Model,JMM)有較多的關聯,因此在介紹volatile關鍵字前我們會先介紹下Java內存模型。

Java內存模型請參考Java內存模型

 

這8種內存訪問操作以及上述規則限定,再加上稍后介紹的對volatile的一些特殊規定,就已經完全確定了Java程序中哪些內存訪問操作在並發下是安全的。由於這種定義相當嚴謹但又十分煩瑣,實踐起來很麻煩。所以,下文將介紹定義的一個等效判斷原則——先行發生原則,用來確定一個訪問在並發環境下是否安全。

1. 先行發生原則

Java語言中有一個“先行發生”(happens-before)的原則。這個原則非常重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,我們可以通過幾條規則解決並發環境下兩個操作之間是否可能存在沖突的所有問題。

現在就來看看“先行發生”原則指的是什么。先行發生是Java內存模型中定義的兩項操作之間的偏序關系,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等。這句話不難理解,但它意味着什么呢?我們可以舉個例子來說明一下,如代碼中所示的這3句偽代碼。

//以下操作在線程A中執行
k=1;
//以下操作在線程B中執行
j=k;
//以下操作在線程C中執行
k=2;

假設線程A中的操作“k=1”先行發生於線程B的操作“j=k”,那么可以確定在線程B的操作執行后,變量j的值一定等於1,得出這個結論的依據有兩個:一是根據先行發生原則,“k=1”的結果可以被觀察到;二是線程C還沒“登場”,線程A操作結束之后沒有其他線程會修改變量k的值。現在再來考慮線程C,我們依然保持線程A和線程B之間的先行發生關系,而線程C出現在線程A和線程B的操作之間,但是線程C與線程B沒有先行發生關系,那j的值會是多少呢?答案是不確定!1和2都有可能,因為線程C對變量k的影響可能會被線程B觀察到,也可能不會,這時候線程B就存在讀取到過期數據的風險,不具備多線程安全性。

 

下面是Java內存模型下一些“天然的”先行發生關系,這些先行發生關系無須任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關系不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。

  • 程序次序規則(Program Order Rule):在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生於書寫在后面的操作。准確地說,應該是控制流順序而不是程序代碼順序,因為要考慮分支、循環等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於后面對同一個鎖的lock操作。這里必須強調的是同一個鎖,而“后面”是指時間上的先后順序。
  • volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操作先行發生於后面對這個變量的讀操作,這里的“后面”同樣是指時間上的先后順序。
  • 線程啟動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷發生。
  • 對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

Java語言無須任何同步手段保障就能成立的先行發生規則就只有上面這些了,下面演示一下如何使用這些規則去判定操作間是否具備順序性,對於讀寫共享變量的操作來說,就是線程是否安全,讀者還可以從下面這個例子中感受一下“時間上的先后順序”與“先行發生”之間有什么不同。

private int value=0;
pubilc void setValue(int value){ 
    this.value=value;
}
public int getValue(){ 
    return value;
}

上面的代碼是一組再普通不過的getter/setter方法,假設存在線程A和B,線程A先(時間上的先后)調用了“setValue(1)”,然后線程B調用了同一個對象的“getValue()”,那么線程B收到的返回值是什么?

答案是不確定。

為什么呢?

接下來我們依次分析一下先行發生原則中的各項規則,由於兩個方法分別由線程A和線程B調用,不在一個線程中,所以程序次序規則在這里不適用;由於沒有同步塊,自然就不會發生lock和unlock操作,所以管程鎖定規則不適用;由於value變量沒有被volatile關鍵字修飾,所以volatile變量規則不適用;后面的線程啟動、終止、中斷規則和對象終結規則也和這里完全沒有關系。因為沒有一個適用的先行發生規則,所以最后一條傳遞性也無從談起,因此我們可以判定盡管線程A在操作時間上先於線程B,但是無法確定線程B中“getValue()”方法的返回結果,換句話說,這里面的操作不是線程安全的。

 

那怎么修復這個問題呢?

我們至少有兩種比較簡單的方案可以選擇:

  1. 要么把getter/setter方法都定義為synchronized方法,這樣就可以套用管程鎖定規則;
  2. 要么把value定義為volatile變量,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景,這樣就可以套用volatile變量規則來實現先行發生關系。

通過上面的例子,我們可以得出結論:一個操作“時間上的先發生”不代表這個操作會是“先行發生”,那如果一個操作“先行發生”是否就能推導出這個操作必定是“時間上的先發生”呢?很遺憾,這個推論也是不成立的,一個典型的例子就是多次提到的“指令重排序”,演示例子如下代碼所示

//以下操作在同一個線程中執行 
int i=1;
int j=2;

代碼清單的兩條賦值語句在同一個線程之中,根據程序次序規則,“int i=1”的操作先行發生於“int j=2”,但是“int j=2”的代碼完全可能先被處理器執行,這並不影響先行發生原則的正確性,因為我們在這條線程之中沒有辦法感知到這點。

上面兩個例子綜合起來證明了一個結論:時間先后順序與先行發生原則之間基本沒有太大的關系,所以我們衡量並發安全問題的時候不要受到時間順序的干擾,一切必須以先行發生原則為准。

 

2. volatile詳解

2.1. volatile的特性

Java內存模型對volatile專門定義了一些特殊的訪問規則,當一個變量定義為volatile之后,它將具備兩種特性。

  1. 保證此變量對所有線程的可見性,即當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程間傳遞均需要通過主內存來完成,例如,線程A修改一個普通變量的值,然后向主內存進行回寫,另外一條線程B在線程A回寫完成了之后再從主內存進行讀取操作,新變量值才會對線程B可見。
  2. 禁止指令重排序優化。普通的變量僅僅會保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。因為在一個線程的方法執行過程中無法感知到這點,這也就是Java內存模型中描述的所謂的“線程內表現為串行的語義”(Within-Thread As-If-Serial Semantics)。

2.2. volatile能保證原子性嗎?

volatile並不能保證原子性,導致volatile變量的運算在並發下一樣是不安全的

如:多線程下的自增運算

public class VolatileTest {
    
    public static volatile int race = 0;
 
    private static final int THREADS_COUNT = 20;
    
    public static void increase() {
        race++;
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
 
        while (Thread.activeCount() > 1) {
            Thread.yield();
        }
        System.out.println(race);
    }
}
View Code

如果IDEA下這段代碼執行出現死循環,請使用DEBUG運行即可,具體原因可以看:面試必問的CAS,你懂了嗎?

例子解析:

這段代碼發起了20個線程,每個線程對race變量進行10000次自增操作,如果這段代碼能夠正確並發的話,最后輸出的結果應該是200000。運行完這段代碼之后,並不會獲得期望的結果,而且會發現每次運行程序,輸出的結果都不一樣,都是一個小於200000的數字,這是為什么呢?

問題就出現在自增運算“race++”之中,我們用Javap反編譯這段代碼后會發現只有一行代碼的increase()方法在Class文件中是由4條字節碼指令構成的,從字節碼層面上很容易就分析出並發失敗的原因了:當getstatic指令把race的值取到操作棧頂時,volatile關鍵字保證了race的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他線程可能已經把race的值加大了,而在操作棧頂的值就變成了過期的數據,所以putstatic指令執行后就可能把較小的race值同步回主內存之中。

getstatic // 獲取靜態變量race,並將值壓入棧頂
iconst_1  // 將int值1推送至棧頂
iadd      // 將棧頂兩個int型數值相加並將結果壓入棧頂
putstatic // 為靜態變量race賦值

從這個例子我們可以確定volatile是不能保證原子性的,要保證運算的原子性可以使用java.util.concurrent.atomic包下的一些原子操作類。例如最常見的: AtomicInteger。

 

2.3. volatile能保證有序性嗎?

在上面volatile的特性中提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。

詳見一篇博客里的雙重檢測機制實現單例部分內容。

 

2.4. volatile的使用限制

 

由於volatile變量只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性。

運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
變量不需要與其他的狀態變量共同參與不變約束。

2.5. volatile的使用場景

2.5.1. 狀態標記量

使用volatile來修飾狀態標記量,使得狀態標記量對所有線程是實時可見的,從而保證所有線程都能實時獲取到最新的狀態標記量,進一步決定是否進行操作。例如常見的促銷活動“秒殺”,可以用volatile來修飾“是否售罄”字段,從而保證在並發下,能正確的處理商品是否售罄。

volatile boolean flag = false;
while(!flag){
    doSomething();
}
public void setFlag() {
    flag = true;
}

2.5.2. 雙重檢測機制實現單例

普通的雙重檢測機制在極端情況,由於指令重排序會出現問題,通過使用volatile來修飾instance,禁止指令重排序,從而可以正確的實現單例

public class Singleton {
    // 私有化構造函數
    private Singleton() {
    }
    // volatile修飾單例對象
    private static volatile Singleton instance = null;
 
    // 對外提供的工廠方法
    public static Singleton getInstance() {
        if (instance == null) { // 第一次檢測
            synchronized (Singleton.class) {    // 同步鎖
                if (instance == null) { // 第二次檢測
                    instance = new Singleton(); // 初始化
                }
            }
        }
        return instance;
    }
}

 

3. 總結:

  1. 每個線程有自己的工作內存,工作內存中的數據並不會實時刷新回主內存,因此在並發情況下,有可能線程A已經修改了成員變量k的值,但是線程B並不能讀取到線程A修改后的值,這是因為線程A的工作內存還沒有被刷新回主內存,導致線程B無法讀取到最新的值。
  2. 在工作內存中,每次使用volatile修飾的變量前都必須先從主內存刷新最新的值,這保證了當前線程能看見其他線程對volatile修飾的變量所做的修改后的值。
  3. 在工作內存中,每次修改volatile修飾的變量后都必須立刻同步回主內存中,這保證了其他線程可以看到自己對volatile修飾的變量所做的修改。
  4. volatile修飾的變量不會被指令重排序優化,保證代碼的執行順序與程序的順序相同。
  5. volatile保證可見性,不保證原子性,部分保證有序性(僅保證被volatile修飾的變量)。
  6. 指令重排序的目的是為了提高性能,指令重排序僅保證在單線程下不會改變最終的執行結果,但無法保證在多線程下的執行結果。
  7. 為了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止重排序。
  8. 在多線程中,volatile和synchronized都起到非常重要的作用,synchronized是通過加鎖來實現線程的安全性。而volatile的主要作用是在多處理器開發中保證共享變量對於多線程的可見性。 可見性的意思是,當一個線程修改一個共享變量時,另外一個線程能讀取到修改以后的值。接下來通過一個簡單的案例來演示可見性問題

 

 

參考:

1. Java並發:volatile詳解

2. Java並發:volatile關鍵字解析(個人感覺還是比較清晰的)

 


免責聲明!

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



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