java多線程之volatile關(guān)鍵字

[TOC]

volatile

英 [?v?l?ta?l]美 [?vɑl?tl]
adj.易變的,不穩(wěn)定的;(液體或油)易揮發(fā)的;爆炸性的;快活的,輕快的

摘要:

volatile有2個特性:可見性、有序性,但是不具備原子性,所以用volatile修飾變量不能保證線程安全。

1 JMM模型

Java內(nèi)存模型是理解volatile的前提。

簡單來說,Java程序的執(zhí)行單元是線程,而每個線程創(chuàng)建時JVM都會為其創(chuàng)建一個工作內(nèi)存(也有人叫棧空間、線程棧),用于存儲線程私有的數(shù)據(jù)。當(dāng)線程操作數(shù)據(jù)時,實際是把內(nèi)存(通常叫“主內(nèi)存”用于區(qū)分工作內(nèi)存)中的數(shù)據(jù)拷貝一份副本到自己的內(nèi)存空間。線程在各自的私有工作內(nèi)存空間完成對副本的操作,最后分別寫入主內(nèi)存。不同線程的私有副本互相“看不到”對方,線程間的數(shù)據(jù)不具備可見性。可以用下面的程序來驗證,不同線程之間數(shù)據(jù)不具有可見性。
可以參考這篇博客:JMM和底層實現(xiàn)原理。

JMM.png

/*
*例1:不可見性導(dǎo)致死循環(huán)
*/
public class CantSeeTest {
    int value = 0;
    public static void main(String[] args) throws InterruptedException {
        CantSeeTest cst = new CantSeeTest();
        new Thread(()->{
            while(cst.value==0){

            }
        }).start();
        Thread.sleep(1000);//確保子線程已經(jīng)start
        cst.value = 1;
        System.out.println("cst.value已經(jīng)被主線程修改為1.");
    }
}

程序的執(zhí)行結(jié)果是:主線程已經(jīng)把value值改為1,但是匿名線程仍然死循環(huán),因為讀不到最新的value值。因為匿名線程看不到主線程修改的value值。

2 指令重排序

重排序是指編譯器處理器為了優(yōu)化程序性能而對指令序列進行重新排序的一種手段,所以重排序分為兩類:編譯期重排序和運行期重排序。

2.1 編譯期重排序

編譯期重排序的典型就是通過調(diào)整指令順序,在不改變程序語義的前提下,盡可能減少寄存器的讀取、存儲次數(shù),充分復(fù)用寄存器的存儲值。例如:
??第一條指令計算一個值賦給變量A并存放在寄存器中,第二條指令與A無關(guān)但需要占用寄存器(假設(shè)它將占用A所在的那個寄存器),第三條指令使用A的值且與第二條指令無關(guān)。那么如果按照順序一致性模型,A在第一條指令執(zhí)行過后被放入寄存器,在第二條指令執(zhí)行時A不再存在,第三條指令執(zhí)行時A重新被讀入寄存器,而這個過程中,A的值沒有發(fā)生變化。通常編譯器都會交換第二和第三條指令的位置,這樣第一條指令結(jié)束時A存在于寄存器中,接下來可以直接從寄存器中讀取A的值,降低了重復(fù)讀取的開銷。

2.2 運行期重排序

現(xiàn)代CPU幾乎都采用流水線機制加快指令的處理速度,一般來說,一條指令需要若干個CPU時鐘周期處理,而通過流水線并行執(zhí)行,可以在同等的時鐘周期內(nèi)執(zhí)行若干條指令,具體做法簡單地說就是把指令分為不同的執(zhí)行周期,例如讀取、尋址、解析、執(zhí)行等步驟,并放在不同的元件中處理,同時在執(zhí)行單元EU中,功能單元被分為不同的元件,例如加法元件、乘法元件、加載元件、存儲元件等,可以進一步實現(xiàn)不同的計算并行執(zhí)行。流水線架構(gòu)決定了指令應(yīng)該被并行執(zhí)行,而不是在順序化模型中所認為的那樣。重排序有利于充分使用流水線,進而達到超標(biāo)量的效果。

2.3 重排序原則

在單線程環(huán)境下,重排序后的指令執(zhí)行的最終效果應(yīng)當(dāng)與其在順序執(zhí)行下的效果一致as-if-serial,否則這種優(yōu)化便沒有意義。重排序必須遵守的規(guī)則,就是大名鼎鼎的Happens-Before原則,他規(guī)定了以下情況下指令不能進行重排序:

  • 程序順序原則:一個線程內(nèi),代碼執(zhí)行的過程必須保證語義的串行性( as-if-serial,看起來是串行的;另外如果程序內(nèi)數(shù)據(jù)存在依賴,也不允許進行重排序 );
  • 監(jiān)視器鎖規(guī)則:解鎖unlock必然發(fā)生在加鎖lock前。
  • 傳遞性規(guī)則:如果操作A先于操作B,操作B先于操作C,那么操作A必先于操作C。
  • volatile規(guī)則:一個共享變量的寫操作,必須先于讀操作,這是volatile可見性語義的要求。
  • 線程的start規(guī)則:線程的start操作先于線程內(nèi)其他任何操作。
  • 線程的join規(guī)則:如果線程ThreadA中執(zhí)行了ThreadB.join()方法,那么ThreadB的所有操作先于ThreadA中ThreadB.join()返回后的操作。

2.4 重排序帶來的問題

雖然Happens-Before對指令重排序做了限制,但是在多線程環(huán)境下,仍然會有很多不符合預(yù)期的情況出現(xiàn)。上代碼:

/*
 *例2:有bug的雙重檢查鎖(double-checked-locking)單例模式
 */
public class LazySingleton {
    private static LazySingleton instance;

    private LazySingleton() {
    }
    public LazySingleton getInstance() {
        if (null == instance) {//.................................(1)
            synchronized (LazySingleton.class) {
                if(null == instance){
                    instance = new LazySingleton();//............(2)
                }
            }
        }
        return instance;
    }
}

關(guān)于雙重檢查鎖單例模式這里不做解釋,感興趣可以參考設(shè)計模式:(一)單例模式。我們只關(guān)注instance = new LazySingleton();這句話,可以粗略認為這條語句分3步驟執(zhí)行:

  • 1.memory = allocate() 分配內(nèi)存空間
  • 2.ctorInstance(memory) 初始化對象
  • 3.instance = memory 將句柄指向分配的內(nèi)存空間
    重排序后可能發(fā)生1-3-2的順序執(zhí)行,假設(shè)A線程按照1-3-2順序執(zhí)行,且剛剛完成3,沒有到2;此時B線程運行到判斷語句(1),判斷為false,得到了一個沒有初始化的句柄,從而引起錯誤。

3 volatile

Java語言規(guī)范第三版中對volatile的定義如下:

java編程語言允許線程訪問共享變量,為了確保共享變量能被準(zhǔn)確和一致的更新,線程應(yīng)該確保通過排他鎖單獨獲得這個變量。Java語言提供了volatile,在某些情況下比鎖更加方便。如果一個字段被聲明成volatile,java線程內(nèi)存模型確保所有線程看到這個變量的值是一致的。

3.1 volatile的作用

從上面例1和例2可以看出JMM模型和指令重排序規(guī)則本身并不能確保程序的執(zhí)行會得到我們的期望的結(jié)果。只要稍加修改,在value和instance變量的類型前加上volatile關(guān)鍵,程序運行就能得到正確的結(jié)果。可見volatile關(guān)鍵字保證了被修飾的變量具有可見性禁止重排序。它的讀寫內(nèi)存語義是:

  • 當(dāng)寫一個volatile變量時,JMM會把該線程對應(yīng)的本地內(nèi)存中的共享變量值刷新到主內(nèi)存;
  • 當(dāng)讀一個volatile變量時,JMM會把該線程對應(yīng)的本地內(nèi)存置為無效,直接將從主內(nèi)存中讀取變量;

3.2 volatile語義的實現(xiàn)

為了實現(xiàn)volatile的內(nèi)存語義,JMM會分別限制編譯期重排序和執(zhí)行期兩種類型的重排序。

  1. 針對編譯期指定的volatile重排序規(guī)則表。


    volatile_compile_rule.png
  • 當(dāng)?shù)诙€操作為volatile寫操作時,不管第一個操作是什么,都不能進行重排序。這個規(guī)則確保volatile寫之前的所有操作都不會被重排序到volatile寫之后;
  • 當(dāng)?shù)谝粋€操作為volatile讀操作時,不管第二個操作是什么,都不能進行重排序。這個規(guī)則確保volatile讀之后的所有操作都不會被重排序到volatile讀之前;
  • 當(dāng)?shù)谝粋€操作是volatile寫操作時,第二個操作是volatile讀操作,不能進行重排序。
    2.通過內(nèi)存屏障禁止運行期的特定類型重排序
  • 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障(禁止前面的寫與volatile寫重排序)。
  • 在每個 volatile 寫操作的后面插入一個 StoreLoad 屏障(禁止volatile寫與后面可能有的讀和寫重排序)。
  • 在每個 volatile 讀操作的后面插入一個 LoadLoad 屏障(禁止volatile讀與后面的讀操作重排序)。
  • 在每個 volatile 讀操作的后面插入一個 LoadStore 屏障(禁止volatile讀與后面的寫操作重排序)。


    volatile_runtime_rule.png

3.3 volatile讀寫原子性

從volatile語義實現(xiàn)方式來看,單步驟的讀寫都是原子性的,但是不能保證讀寫、寫讀、自增、自減等復(fù)合操作的原子性。而一些組合操作看起來又極具迷惑性,例如i++,實際包含了先讀在寫,在多線程環(huán)境下volatile無法保證這個操作的原子性??紤]下面代碼:

/*
 *例3:volatile沒有原子性
 */
public class NonAtomicTest {
    volatile int value = 0;
    public static void main(String[] args) {
        NonAtomicTest nat = new NonAtomicTest();
        for(int i =0;i<10;i++){
            new Thread(()->{
                for(int j=0;j<1000;j++){
                    nat.value++;
                }
            },""+i).start();
        }
        while(Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println("當(dāng)前value值:"+nat.value);
    }
}

其輸出結(jié)果小于10000。

3.4 CAS

CAS(compare and swap)是cpu指令,依賴于硬件。CAS操作涉及三個操作數(shù):內(nèi)存值V,期望值E,要更新的值U。如果V=E(視為本線程讀取V以后,沒有其他線程修改過V的值),則將V設(shè)置為U,返回true,否則返回flase。如果V用volatile修飾,在每次修改以后其他線程可見,那么用do{E = get_V }while(CAS(V,E,U))的方式,每次從內(nèi)存讀取V值,在while中不斷嘗試CAS操作,一直到成功為止即可實現(xiàn)原子性。如果覺得這個偽代碼太敷衍,可以看下這個:

/*
*例4:sun.misc.Unsafe.class
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

以上代碼是sun.misc.Unsafe類中的方法。其中var1代表一個對象的引用,var2是這個對象中的一個屬性的偏移地址,通過var1和var2就能夠得到對象屬性的數(shù)值,即var5。每次調(diào)用compareAndSwapInt方法時,會通過var1和var2獲取最新的屬性值,如果和var5不相等,則返回false,發(fā)生自旋,再次循環(huán)一遍,一直到成功。juc中的AtomicXXX類都是通過調(diào)用Unsafe的方法實現(xiàn)的原子操作。

3.5 ABA問題

雖然CAS加自旋鎖能夠解決原子問題,但是也存在一些缺點,比如高并發(fā)下效率低(都在自旋等待);能夠保證讀寫或?qū)懽x的原子性,但是不能保證代碼塊的原子性;最重要的是有ABA問題:線程t1將共享變量的值從A變?yōu)锽,再從B變?yōu)锳。同時有線程t2要將值從A變?yōu)镃。當(dāng)t2做CAS檢查的時候會發(fā)現(xiàn)共享變量的值沒有改變,但是實質(zhì)上它已經(jīng)發(fā)生了改變,可能會造成數(shù)據(jù)的缺失。ABA問題導(dǎo)致的原因,是CAS過程中只簡單進行了“值”的校驗,有些情況下,“值”相同不會引入錯誤的業(yè)務(wù)邏輯,有些情況下,“值”雖然相同,卻已經(jīng)不是原來的數(shù)據(jù)了。例如:
有一個棧,初始值是A-B-C-A-D,有2個并發(fā)線程t1、t2同時讀取棧頂?shù)闹刀嫉玫紸,t2連續(xù)3次出棧后t1得到cpu時間片,進行CAS操作,發(fā)現(xiàn)期望值和當(dāng)前值都是A,CAS操作成功,將A改為X,但是棧已經(jīng)只剩下2個元素,不是期望的數(shù)據(jù)。

ABA.png

解決ABA問題:
只需要在每次CAS成功后增加一個版本號或者時間戳即可,參考AtomicStampedReference類。

4 volatile使用場景

  • 1.狀態(tài)標(biāo)識
    用于指示發(fā)生了一個重要的一次性事件,例如完成初始化或任務(wù)結(jié)束。狀態(tài)標(biāo)志并不依賴于程序內(nèi)任何其他狀態(tài),且通常只有一種狀態(tài)轉(zhuǎn)換。例如把例1中value改為bool類型。
  • 2.一次性安全發(fā)布(one-time safe publication)
    在缺乏同步的情況下,可能會導(dǎo)致某個線程獲得一個未完全初始化的實例。(這就是雙重檢查鎖定問題的根源,例2)
  • 3.低開銷的讀-寫鎖策略
    當(dāng)讀遠多于寫,結(jié)合使用內(nèi)部鎖和 volatile 變量來減少同步的開銷
    利用volatile保證讀取操作的可見性;利用synchronized保證復(fù)合操作的原子性
public class Counter {
    private volatile int value;
    //利用volatile保證讀取操作的可見性, 讀取時無需加鎖
    public int getValue() { return value; }
    // 使用 synchronized 加鎖
    public synchronized int increment() { 
        return value++;
    }
}
  • 4.獨立觀察(independent observation)
    使用 volatile 定期 “發(fā)布” 觀察結(jié)果供程序內(nèi)部使用。假設(shè)有一種環(huán)境傳感器能夠感覺環(huán)境溫度,一個后臺線程可能會每隔幾秒讀取一次該傳感器,并更新包含當(dāng)前溫度的 volatile 變量。然后,其他線程可以讀取這個變量,從而隨時能夠看到最新的溫度值。即一個線程寫,多個線程讀。

參考資料:
https://blog.csdn.net/qq_35362055/article/details/78981792
https://www.iteye.com/blog/zhaodengfeng1989-2419692
https://download.oracle.com/otndocs/jcp/memory_model-1.0-pfd-spec-oth-JSpec/
http://www.itdecent.cn/p/9e467de97216

volatile.png
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容