從代碼實踐的角度解析volatile關鍵字

序言

由於最近項目上遇到了高併發問題,而自己對高併發,多線程這裏的知識點相對薄弱,尤其是基礎,所以想系統的學習一下,以後可能會出一系列的JUC文章及總結 ,同時也爲企業級的高併發項目做好準備。

在講此係列之前,我先大概的說一些自己對這些知識點的總結思路,其中大致分爲三部分:

  1. 理論(概念);
  2. 實踐(代碼證明);
  3. 總結(心得及適用場景)。

在這裏提前說也是爲了防止大家看着看着就迷路了。

volatile大綱

首先,下圖是本文的大綱,也就是說在看本文之前,你需要先了解本文到底是講什麼內容,有個整體大觀,然後逐個細分到內容層次去講解。

volatile大綱.png

volatile理論

volatile是什麼呢?

volatile關鍵字是Java提供的一種輕量級同步機制。它能夠保證可見性有序性(禁止指令重排),但是不能保證原子性

我們肯定聽過synchronized關鍵字,他是一個重量級的,基於jvm層次的同步機制(底層涉及到了monitor對象)。而相比於volatile,volatile更輕量級,更簡單並且開銷更低,因爲它不會引起線程上下文的切換和調度

在正式說volatile之前,我們有必要提一下JMM,因爲他涉及到了併發編程的3個概念。

JMM java內存模型

JMM(Java內存模型,簡稱JMM)本身是一種抽象的概念並不真實存在,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。

JMM關於同步的規定:
1.線程解鎖前,必須把共享變量的值刷新回主內存。
2.線程加鎖前,必須讀取主內存的最新值到自己的工作內存。
3.加鎖解鎖是同一把鎖。


可能這段文字大家可能不太懂,那我用白話給大家介紹一下:

首先,我們先知道一下3個概念,硬盤,內存,CPU

在io讀取速率上,硬盤是肯定不如內存的,cpu管運算。我們可以假想這麼一個場景:由於cpu的運算能力是遠超於內存的存儲效率,但是爲了保持數據的一致性,應該怎麼做呢?

答案是緩存。而這塊地,就是我們JMM的存在地,用圖的解釋如下:

1590808152(volatile.assets/006M2jFvly1gfaavg4tjaj30zw0pead7.jpg).jpg

JMM定義了線程和主內存之間的抽象關係,共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存保存了被該線程使用到的主內存的副本拷貝,線程對變量的所有操作都必須在工作內存中進行,而不能直接讀寫主內存中的變量。演變成圖的形式就是如下:

1590809329(volatile.assets/006M2jFvly1gfabfqa3ftj310m0qkgp6.jpg).jpg

因此不同的線程間無法訪問對方的工作內存,線程間的通信(傳值)必須通過主內存來完成。

而線程安全要想獲得保證,則必須符合以下三點:

  1. 可見性;
  2. 原子性;
  3. 有序性。

這3個具體是什麼意思後面我們結合volatile來講。

volatile實踐

上文我們具體介紹了volatile的特性,即:

  1. 保證可見性;
  2. 不保證原子性
  3. 保證有序性(即禁止指令重排)。

那我們用代碼來演示以下它的特點。

可見性演示

通過前面對JMM的介紹,我們知道
各個線程對主內存中共享變量的操作都是各個線程各自拷貝到自己的工作內存進行操作後再寫回主內存中的。

這就可能存在一個線程A修改了共享變量X的值但還未寫回主內存時,另一個線程B又對準內存中同一個共享變量X進行操作,但此時A線程工作內存中共享變量X對線程B來說並不是可見,這種工作內存與主內存同步存在延遲現象就造成了可見性問題。

代碼如下:

class VolatileVisibilityData{
    volatile int number; 
    // int number;

     void addNumberTo100(){
        this.number = 100;
    }
}

public class VolatileVisibility {
    public static void main(String[] args) {

        VolatileVisibilityData volatileVisibilityData = new VolatileVisibilityData();

        new Thread(() ->{
            System.err.println(Thread.currentThread().getName()+"線程 \t come in");
            //睡眠3秒的原因是爲了讓main線程拿到資源
            try{TimeUnit.SECONDS.sleep(3);}catch(Exception e){e.getStackTrace();};

            volatileVisibilityData.addNumberTo100();
            System.err.println(Thread.currentThread().getName()+"線程 \t update number value: "+volatileVisibilityData.number);
        },"AAA").start();

        while (volatileVisibilityData.number==0){
            //main線程持有共享數據的拷貝,一直爲0
        }
        System.err.println(Thread.currentThread().getName()+"\t mission is over. main get number value: "+volatileVisibilityData.number);
    }
}

說明:

VolatileVisibilityData類是資源類,一開始number變量沒有用volatile修飾,所以程序運行的結果是:

可見性測試
AAA	 come in
AAA	 update number value: 100

雖然一個線程把number修改成了100,但是main線程持有的仍然是最開始的0,所以一直循環,程序不會結束。

在沒有給number加入volite的時候,運行程序,程序不停止,加上volatile的時候,程序停止則證明volatile的可見性

原子性演示

我們先來看一下什麼是原子性?

按照我的理解:原子性就是不可分割,前後最終結果保持一致,線程不可打斷,其實可以聯想到數據庫事務ACID的原子性

我們都熟知的i++操作是線程不安全的,那麼爲什麼是線程不安全呢?

i++雖然代碼上只有一行代碼,但是在jvm底層,字節碼文件,他是分爲3步運行的:

getfield        //讀
iconst_1	//++常量1
iadd		//加操作
putfield	//寫操作

即:

  • 第一步:獲取初始值;

  • 第二步:作自增操作;

  • 第三步:返回引用。

而這三步中任意兩步同時執行的話,都會造成結果的差異。

CPU在獲取初始值時也可能同時讀到同一個值,這樣就會同一個值自增兩次,而實際上只自增了一次,從而導致寫覆蓋,所以i++不是原子操作

假設有2個線程,分別執行i++,都先從主內存中拿到最開始的值,number=0,然後2個線程分別進行操作。假設線程1自增完畢,number=1,在刷回主內存的時候被掛起,此時線程2也自增完畢,線程1刷回主內存的值,但是由於太快了,還沒來的及通知,線程2也開始將值刷回主內存從而導致寫覆蓋,線程1、2將number變成1。

代碼證明volatile不具有原子性如下:

class   VolatileAtomicData{

    volatile int number = 0;
//    int number = 0;

    void  increase(){
        number++;
    }

    //原子性的解決
    // 方法一:可以加synchronize,但是雖然保證了原子性,但是給整個方法加synchronized太重了,併發效率低
//    synchronized void  increase(){
//        number++;
//    }

    //方法二:採用atomic系類去解決
    AtomicInteger atomicInteger = new AtomicInteger();

    void addAtomic(){
        atomicInteger.getAndIncrement();
    }
}

public class VolatileAtomic {
    public static void main(String[] args) {
        VolatileAtomicData volatileAtomicData = new VolatileAtomicData();

        for (int i = 0; i < 20; i++) {
            new Thread(() ->{
                for (int j = 0; j < 1000; j++) {
                    volatileAtomicData.increase();

                    //解決原子性問題
                    volatileAtomicData.addAtomic();
                }
            },String.valueOf(i)).start();
        }
        //jvm底層存在兩個線程,一個main線程,一個是gc線程
        while (Thread.activeCount()>2){
            //如果main線程拿到了資源,暫時禮讓
            Thread.yield();
        }
        System.err.println(Thread.currentThread().getName()+"線程 \t int type finally number value: "+volatileAtomicData.number);
        System.err.println(Thread.currentThread().getName()+"線程 \t int type finally number value: "+volatileAtomicData.atomicInteger);

    }
}

有序性

計算機在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排,一般分一下3種:

源代碼->編譯器優化的重排->指令並行的重排->內存系統的重排
->最終執行的指令

這裏還得提一個概念,as-if-serial。不管怎麼重排序,單線程下的執行結果不能被改變。

編譯器、runtime和處理器都必須遵守as-if-serial語義。

處理器在進行重排序時必須考慮指令之間的數據依賴性。多線程環境中線程交替執行,由於編譯器優化重排的存在,兩個線程中使用的變量能否保證一致性是無法確定的,結果無法預測。


其實感覺上述內容說的還是有點晦澀難懂,那我們通過一個例子來說一下有序性把。

先來看一段代碼:

boolean contextReady = false;

//在線程A中執行:
context = loadContext();
contextReady = true;
//在線程B中執行:
while( ! contextReady ){ 
   sleep(200);
}
doAfterContextReady (context);

以上程序看似沒有問題。線程B循環等待上下文context的加載,一旦context加載完成,contextReady == true的時候,才執行doAfterContextReady 方法。

但是,如果線程A執行的代碼發生了指令重排,初始化和contextReady的賦值交換了順序:

boolean contextReady = false;

//在線程A中執行:
contextReady = true;
context = loadContext();

//在線程B中執行:
while( ! contextReady ){ 
   sleep(200);
}
doAfterContextReady (context);

這個時候,很可能context對象還沒有加載完成,變量contextReady 已經爲true,線程B直接跳出了循環等待,開始執行doAfterContextReady 方法,結果自然會出現錯誤。

需要注意的是,這裏java代碼的重排只是爲了簡單示意,真正的指令重排是在字節碼指令的層面。

那麼volatile是如何來解決這個問題呢?

volatile底層是用CPU的內存屏障(Memory Barrier)指令來實現的,有兩個作用:

  1. 保證特定操作的順序性;
  2. 保證變量的可見性。

在指令之間插入一條Memory Barrier指令,告訴編譯器和CPU,在Memory Barrier指令之間的指令不能被重排序。

但是在讀操作和寫操作插入內存屏障的位置又是不一樣的

操作

1590830606(volatile.assets/006M2jFvly1gfalos8tobj30zl0om41o.jpg).jpg

操作

1590830875(volatile.assets/006M2jFvly1gfaltdaa6kj30xd0oz41e.jpg).jpg

happens-before

如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。volatile就提供了這種關係。

volatile域規則:對一個volatile域的寫操作,happens-before於任意線程後續對這個volatile域的讀。

如果現在我的變了flag變成了false,那麼後面的那個操作,一定要知道我變了。

volatile總結

上面我們已理論和實踐的方式深入瞭解了以下volatile的機制及特性,那麼問題來了,哪些地方用到過volatile?

其實volatile的應用場景很多,比如cas底層,中間件的核心類都是大面積的採用volatile。

下面我們來說一個我們基本都夠得着的實際用例——>單例模式

單例模式的安全問題

單例的常見的5種寫法相信我們都已經瞭解了,我們來細揪一下雙端檢索機制模式下的單例(DCL模式)

上代碼:

public class SingletonDemo {
    private volatile static SingletonDemo SingletonDemo = null;

    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t我是構造方法");
    }
    ////DCL模式 Double Check Lock 雙端檢索機制:在加鎖前後都進行判斷
    private static SingletonDemo getInstance(){
        if(SingletonDemo == null){
            synchronized (SingletonDemo.class){
                if(SingletonDemo == null){
                    SingletonDemo = new SingletonDemo();
                }
            }
        }
        return SingletonDemo;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10000; i++) {
            new Thread(() ->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

有人可能會跟我一樣好奇,爲什麼都已經加鎖了,爲啥還需要volatile???而且他爲什麼要去判斷兩次??

這塊就涉及到了一個知識點,對象的創建過程。

我們在創建對象的過程,分爲三步走:

  1. memory = allocate(); //分配內存空間。
  2. instance(memory); //調用構造器,初始化實例。
  3. instance = memory; //返回地址給引用。

其中2、3沒有數據依賴關係,可能會發生指令重排。

如果發生,此時內存已經分配,那麼instance=memory不爲null。如果此時線程掛起,instance(memory)還未執行,對象還未初始化。由於instance!=null,所以兩次判斷都跳過,(其實這個對象是個半成品),那不就有空指針異常了。

而volatile卻恰好可以解決這個問題。

與synchronizd的區別

既然我們上文提到了synchronized,那我們也就來說一下這兩關鍵字的區別把;

  1. 首先,volatile它只能保證變量的可見性,不能保證原子性;而synchronized則可以保證變量的修改可見性和原子性。
  2. 當然針對上一點,volatile標記的變量不會被編譯器優化,也就是禁止指令重排;synchronized標記的變量可以被編譯器優化。
  3. volatile屬性的讀寫操作都是無鎖的,所以它不會導致阻塞。而synchronized會。
  4. volatile僅能使用在變量級別;synchronized則可以使用在變量、方法、和類級別的。

大致目前就總結到這裏了,如果後續發現新的知識,再來補充。

另:如有總結不對的地方,麻煩大家指出,希望我們共同給進步~~~

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