volatile簡介及可見性、有序性的保證

volatile簡介

volatile是jvm提供的最輕量級的同步機制(相比於synchronized,其要輕量很多)

當一個變量定義爲volatile後,其具備兩種特性:

  • 此變量對所有線程的可見性
    • 可見性:當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
  • 禁止指令重排序優化
    • 指令重排序:JVM爲了進行優化,會對變量賦值等操作進行一系列的優化,其只保證了所有依賴賦值結果的地方都能獲取到正確的結果,但不能保證該變量賦值操作的順序與程序代碼中的執行順序一致。
    • 注意:重排序優化是機器級的優化操作,不是是Java源代碼層面進行的。

可見性

普遍變量

首先,爲什麼普通變量不能做到可見性呢?

這裏需要引入JMM(Java內存模型)。

  • 什麼是JMM?

    由於在不同平臺上內存模型的差異,可能同一個程序在一個平臺上併發情況下可以正常運行,而在另一個平臺上併發訪問就出錯,因此需要針對各種平臺來實現一個統一的規範。由此,JVM規範了自身的JAVA內存模型(即JMM)來屏蔽操作系統的內存訪問差異。

  • JMM簡介

    JMM的知識點較多,這裏只做簡單介紹。

    首先上圖
    在這裏插入圖片描述
    JMM規定了所有的變量都存儲在主內存,而每條線程有自己的工作內存;

    線程的工作內存保存了該線程所使用到的變量的拷貝(注意:線程的工作內存只拷貝了對象的引用、和正在訪問的對象中的某個字段,並不會完全拷貝此對象),線程都所有操作都是在自己的工作內存中進行的;

    (注意:JMM與JVM中內存區域的堆棧等區域不是一個層次的內存劃分,讀者不要混淆)

okay,引入完畢,回到剛纔的問題,爲什麼普通變量不能做到可見性呢?

由上圖及介紹可以知道,普通變量的值傳遞需要通過主內存來完成,例如:線程A修改一個變量的值,需要先向主內存回寫後,另一個線程B等到A回寫完成後再讀取,才能夠讀取到變量的新值。

volatile修飾的變量

volatile怎樣實現可見的呢?

有如下java代碼

public class Test16 {
    private volatile int a=0;
    public void update() {
        a = 1;
    }
    public static void main(String[] args) {
    }
}

通過hsdis+jitwatch工具查看其彙編碼(查看步驟見:here),如下:

......
  0x000000000295156d: lock addl %rdi,(%rdx)  
......

可以看到在volatile修飾的變量處,執行了lock addl....步驟,這個操作的lock作用把主內存的變量標示爲獨佔內存的變量,此時會使得本CPU的Cache寫入內存,同時令其他CPU或別的內核其cache失效,當其他CPU發現cache失效後,會從內存中重讀該變量數據,即可以獲取當前最新值。

通過以上的步驟,使用前面的volatile變量的修改對其他CPU立即可見。

  • 除了volatile之外,synchronizedfinal關鍵字也可以保證可見性

    synchroinzed:變量執行unlock操作(將處於鎖定狀態的變量釋放出來,釋放後其他線程纔可以使用此變量)之前,必須先把此變量同步到主內存中。

    final:保障構造函數中對象不溢出的情況下,其他線程拿到的是初始化後的final對象。

volatile保證有序性就安全了嗎

有如下例子:

package com.hpsyche;

/**
 * @author hpsyche
 * Create on 2019/12/24
 */
public class Test17 {
    public static volatile int race=0;

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads=new Thread[50];
        for(int i=0;i<50;i++){
             threads[i]=new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i1 = 0; i1 <1000; i1++){
                        race++;
                    }
                }
            });
            threads[i].start();
        }
        for(int i=0;i<50;i++){
            threads[i].join();
        }
        System.out.println(race);
    }
}

運行結果:發現race最終變量小於50000;

通過javap反編譯,查看字節碼:

         0: iconst_0
         1: istore_1
         2: iload_1
         3: bipush        50
         5: if_icmpge     31
         8: new           #2                  // class java/lang/Thread
        11: dup
        12: new           #3                  // class com/hpsyche/Test17$1
        15: dup
        16: invokespecial #4                  // Method com/hpsyche/Test17$1."<init>":()V
        19: invokespecial #5                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
        22: invokevirtual #6                  // Method java/lang/Thread.start:()V
        25: iinc          1, 1
        28: goto          2
        31: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
        34: getstatic     #8                  // Field race:I
        37: invokevirtual #9                  // Method java/io/PrintStream.println:(I)V
        40: return

可以看到,race++被不是一個原子操作,其有istore\iload\iinc等組成,執行這些指令是,其他線程有可能已經將race的值改變了,導致最終getstatic時同步到主內存的數據偏小。

此時我們可以將race++操作加上synchroinzed,或者使用jdk提供的AtmoicInteger類來確保race++的線程安全。

有序性

volatile怎麼實現有序性

上文已經提過:重排序優化是指過程不保證,結果保證的一系列優化過程。而volatile關鍵字禁止了指令優化,那麼其是怎樣實現的呢?

在上文舉例中提到彙編碼:lock addl...,其中的lock還有一個作用,其相當於一個內存屏障(重排序時不能將後面的指令排序到內存屏障之前),內存屏障其通過一系列的屏障策略來實現有序。

關於內存屏障的更多細節可見:here

  • 除了volatile外,synchroinzed也可實現線程間操作的有序性,因爲加了synchroinzed後,一個變量在同一個時刻只允許一個線程對其進行lock,也就保證了訪問的先後性。

volatile典型使用

DCL實現的單例模式(雙重檢查加鎖)

public class Singleton{
    private volatile static Singleton instance=null;
    private Singleton(){}
    public static Singleton getInstance(){
        //先檢查實例是否存在,如果不存在才進入下面的同步塊
        //避免synchroinzed資源的消耗
        if(instance==null){
            //同步塊,線程安全的創建實例
            synchronized(Singleton.class){
                //檢查實例是否存在,如果不存在才真正的創建實例
                if(instance==null){
                    instance=new Singleton();
                }
            }
        }
        return instance;
    }
}

上面的例子已經似乎可以保證單例了,那爲什麼還需要加volatile呢?

首先要理解new Singleton()做了什麼。new一個對象有幾個步驟。

1.看class對象是否加載,如果沒有就先加載class對象;
2.分配內存空間,初始化實例;
3.調用構造函數;
4.返回地址給引用。

而cpu爲了優化程序,可能會進行指令重排序,打亂這3,4這幾個步驟,導致實例內存還沒分配,就被使用了,當在併發的情況下,就可能出現線程B引用了線程A中還沒有被完全初始化的變量。

而加了volatile之後,就保證new 不會被指令重排序。

總結

關於volatile關鍵字還有很多深入的細節,由於才疏學淺,這裏我也只是簡單的聊了下其特性,如果不足之處歡迎評論指出。

參考文獻

《深入理解Java虛擬機》(第二版)——周志明

發佈了68 篇原創文章 · 獲贊 28 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章