Java併發編程之深入理解volatile

個人博客請訪問 http://www.x0100.top       

1. 保證可見性

volatile保證了不同線程對volatile修飾變量進行操作時的可見性。

對一個volatile變量的讀,(任意線程)總是能看到對這個volatile變量最後的寫入。

  1. 一個線程修改volatile變量的值時,該變量的新值會立即刷新到主內存中,這個新值對其他線程來說是立即可見的。

  2. 一個線程讀取volatile變量的值時,該變量在本地內存中緩存無效,需要到主內存中讀取。

舉例:

中斷線程時常採用這種標記辦法。

boolean stop = false;// 是否中斷線程1標誌
//Tread1
new Thread() {
    public void run() {
        while(!stop) {
          doSomething();
        }
    };
}.start();
//Tread2
new Thread() {
    public void run() {
        stop = true;
    };
}.start();

目的: Tread2設置stop=true時,Tread1讀取到stop=true,Tread1中斷執行。

問題: 雖然大多數時候可以達到中斷線程1的目的,但是有可能發生Tread2設置stop=true後,Thread1未被中斷的情況,而且這種情況引發的都是比較嚴重的線上問題,排查難度很大。

問題分析: Tread2設置stop=true時,並未將stop=true刷到主內存,導致Tread1到主內存中讀取到的仍然是stop=false,Tread1就會繼續執行。也就是有內存可見性問題。

解決: stop變量用volatile修飾。
Tread2設置stop=true時,立即將volatile修飾的變量stop=true刷到主內存;
Tread1讀取stop的值時,會到主內存中讀取最新的stop值。

2. 保證有序性

volatile關鍵字能禁止指令重排序,保證了程序會嚴格按照代碼的先後順序執行,即保證了有序性。

volatile的禁止重排序規則:

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

舉例:

boolean inited = false;// 初始化完成標誌
//線程1:初始化完成,設置inited=true
new Thread() {
    public void run() {
        context = loadContext();   //語句1
        inited = true;             //語句2
    };
}.start();
//線程2:每隔1s檢查是否完成初始化,初始化完成之後執行doSomething方法
new Thread() {
    public void run() {
        while(!inited){
          Thread.sleep(1000);
        }
        doSomething(context);
    };
}.start();

目的: 線程1初始化配置,初始化完成,設置inited=true。線程2每隔1s檢查是否完成初始化,初始化完成之後執行doSomething方法。

問題: 線程1中,語句1和語句2之間不存在數據依賴關係,JMM允許這種重排序。如果在程序執行過程中發生重排序,先執行語句2後執行語句1,會發生什麼情況?

當線程1先執行語句2時,配置並未加載,而inited=true設置初始化完成了。線程2執行時,讀取到inited=true,直接執行doSomething方法,而此時配置未加載,程序執行就會有問題。

解決: volatile修飾inited變量。
volatile修飾inited,“當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。”,保證線程1中語句1與語句2不能重排序。

3. 不保證原子性

volatile是不能保證原子性的。

原子性是指一個操作是不可中斷的,要全部執行完成,要不就都不執行。

舉例:

public class VolatileTest {
    public volatile int a = 0;

    public void increase() {
        a++;
    }

    public static void main(String[] args) {
        final VolatileTest test = new VolatileTest();
        for (int i = 0; i < 10; i++) {
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        test.increase();
                };
            }.start();
        }

        while (Thread.activeCount() > 1) {
            // 保證前面的線程都執行完
            Thread.yield();
        }
        System.out.println(test.a);
    }
}

 

目的: 10個線程將inc加到10000。

結果: 每次運行,得到的結果都小於10000。

原因分析:

首先來看a++操作,其實包括三個操作:
  ①讀取a=0;
  ②計算0+1=1;
  ③將1賦值給a;
保證a++的原子性,就是保證這三個操作在一個線程沒有執行完之前,不能被其他線程執行。

一個可能的執行時序圖如下:

關鍵一步:線程2在讀取a的值時,線程1還沒有完成a=1的賦值操作,導致線程2讀取到當前a=0,所以線程2的計算結果也是a=1。

問題在於沒有保證a++操作的原子性。如果保證a++的原子性,線程1在執行完三個操作之前,線程2不能執行a++,那麼就可以保證在線程2執行a++時,讀取到a=1,從而得到正確的結果。

解決:

  1. synchronized保證原子性,用synchronized修飾increase()方法。

  2. CAS來實現原子性操作,AtomicInteger修飾變量a。

4. volatile實現原理

volatile保證有序性原理

前文介紹過,JMM通過插入內存屏障指令來禁止特定類型的重排序。

java編譯器在生成字節碼時,在volatile變量操作前後的指令序列中插入內存屏障來禁止特定類型的重排序。

volatile內存屏障插入策略:

在每個volatile寫操作的前面插入一個StoreStore屏障。
在每個volatile寫操作的後面插入一個StoreLoad屏障。
在每個volatile讀操作的後面插入一個LoadLoad屏障。
在每個volatile讀操作的後面插入一個LoadStore屏障。

內存屏障

Store:數據對其他處理器可見(即:刷新到內存中)
Load:讓緩存中的數據失效,重新從主內存加載數據

volatile保證可見性原理

volatile內存屏障插入策略中有一條,“在每個volatile寫操作的後面插入一個StoreLoad屏障”。

StoreLoad屏障會生成一個Lock前綴的指令,Lock前綴的指令在多核處理器下會引發了兩件事:

1. 將當前處理器緩存行的數據寫回到系統內存。
2. 這個寫回內存的操作會使在其他CPU裏緩存了該內存地址的數據無效。

volatile內存可見的寫-讀過程:

  1. volatile修飾的變量進行寫操作。

  2. 由於編譯期間JMM插入一個StoreLoad內存屏障,JVM就會向處理器發送一條Lock前綴的指令。

  3. Lock前綴的指令將該變量所在緩存行的數據寫回到主內存中,並使其他處理器中緩存了該變量內存地址的數據失效。

  4. 當其他線程讀取volatile修飾的變量時,本地內存中的緩存失效,就會到到主內存中讀取最新的數據。

總結

併發編程中,常用volatile修飾變量以保證變量的修改對其他線程可見。

volatile可以保證可見性和有序性,不能保證原子性。

volatile是通過插入內存屏障禁止重排序來保證可見性和有序性的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章