但願是volatile的最後一次理解

第一次理解:

剛學java時,對於volatile的記憶就是:html

  • volatile保證可見性
  • volatile防止指令重排序
  • volatile不保證原子性

沒過腦的背了一下,寫代碼的時候也沒用到過,覺得不重要,而後就不了了之。java

第二次理解

一段代碼引發好奇c++

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

上圖爲比較經典的dcl(dubbo check lock)單例模式,雙重if判斷是爲了防止多線程屢次建立,可是instance屬性爲何還要加個volatile關鍵字呢,有什麼做用麼?
其實它的做用主要體如今禁止指令重排序緩存

首先先理解下什麼叫指令重排序?
指令重排序能夠說是jvm對程序執行的一個優化,他能夠保證普通的變量在方法執行的過程當中全部依賴賦值結果的地方都能獲取到正確的結果,而不能保證變量賦值操做的順序與程序代碼中寫的順序保持一致。如多線程

x = 1;
y = 2;

這兩條賦值語句之間沒有依賴關係,因此在具體執行時可能會先賦值y在賦值x,發生了指令重排。
而上述DCL代碼中雖然表面只有這instance = new Singleton();一條語句,可是這個賦值操做編譯成字節碼文件後是分爲3個步驟來完成的:併發

  1. 爲對象開闢內存空間並賦默認值
  2. 調用構造函數爲對象賦初始值
  3. 將instance引用指向剛開闢的內存地址

而程序在執行這三步時,會有可能先執行3再執行2,若是發生這種狀況,線程一先將引用指向地址,還沒來得及執行構造方法,線程二進來判斷instance!=null 直接拿這半初始化的對象去使用,就出現了問題。
因此此處須要用volatile關鍵字來修飾變量,禁止指令重排序狀況的發生。那麼volatile是如何作到禁止重排序的呢?
《深刻理解java虛擬機》中這樣寫道:jvm

咱們對volatile修飾的變量進行編譯後發現,在賦值操做後多執行了一個「lock addl $0x0,(%esp)」,這個操做至關於一個內存屏障(Memory Barrier 或 Memory Fence,指重排序時不能把後面的指令重排序到內存屏障以前的位置)

也有別的博主這樣寫道:ide

JMM爲volatile加內存屏障有如下4種狀況:
在每一個volatile寫操做的前面插入一個StoreStore屏障,防止寫volatile與後面的寫操做重排序。
在每一個volatile寫操做的後面插入一個StoreLoad屏障,防止寫volatile與後面的讀操做重排序。
在每一個volatile讀操做的後面插入一個LoadLoad屏障,防止讀volatile與後面的讀操做重排序。
在每一個volatile讀操做的後面插入一個LoadStore屏障,防止讀volatile與後面的寫操做重排序。

第三次理解

那麼保證可見性又是指什麼東東?
要想理解這可見性,須要先了解java內存模型(jmm)。學過計算機的同窗都知道多核cpu中每一個cpu都有本身的高速緩存,如L1,L2,L3,且每一個cpu之間的緩存是隔離的,即數據不可見。而多個cpu又共享一個主內存,數據通常會從磁盤讀取到主內存當中,當cpu須要處理數據時,須要從主內存讀取數據到本身的緩存當中而後進行運算,運算結束後將最新數據同步回內存之中。固然這種模型也伴隨這緩存一致性問題的出現。
其實java內存模型和cpu模型很是的相似:
每一個線程擁有本身的工做內存,而後共享的變量會存放在主內存(jvm的內存)當中,線程之間工做內存互相隔離。如圖:
image.png函數

上圖來源於《深刻理解java虛擬機363頁》

咱們再來看個容易理解的圖:
image.png優化

再回到咱們的保證可見性的探討:
如上圖所示,若線程A和B都操做主內存的共享變量時,AB會將共享變量先拷貝會本身的工做內存,在A率先完成修改完以後再同步刷回到主內存當中,此時線程B本地內存的數據仍是最早拷貝的舊數據,沒有及時的獲取到已修改的最新數據,最後會形成數據不一致問題。
而volatile修飾變量時,它會保證修改的值會當即被更新到主存,並通知其餘線程當前緩存的變量已失效,須要從新到主內存中讀取。
底層也是經過內存屏障來保證的。
針對這個特性,常見的使用的場景爲狀態標記量

public class VolatileTest1 {
    volatile static boolean flag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("t1 start");
            while (!flag){
                System.out.println("doing something");
            }
        },"t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
    }
}

和咱們指望的同樣,1秒後,程序正常中止。
可是好奇的我開始思考,那是否是隻要不加volatile,程序就不會中止?當即更新的反義詞是什麼?正常狀況下,線程會不會,何時會把修改的值寫會主內存,別的線程又會何時會去從新讀取?
帶着好奇我把上訴代碼中的volatile去掉,運行結果如圖:
image.png
沒錯 程序竟然正常停掉了!
而後我又把while循環裏的system輸出去掉後,再次運行:
image.png
此次又沒有中止!!
難道就是由於一句輸出語句的問題麼?我又嘗試換成i++試試:
image.png
此次也沒有中止!!!
很神奇,搞得我也很懵逼!!!
我不知道是否是由於環境的緣由,我用的jdk11和8,idea2020.1.2,
我的初步猜想:不加volatile,即正常狀況下,本地線程更新值後,會很快的寫回主內存,而其餘線程何時從新從主內存中讀取是不肯定的。
上述while代碼裏面執行點稍微費時的操做(如輸出,sleep 1s),都是能夠中止的,若是循壞太快,它可能沒時間去從新讀取flag的值。
(但願有大佬看到小弟的這篇文章,並指點一二。)

第四次理解

那不保證原子性又是什麼鬼?
原子性:保證指令不會受到線程上下文切換的影響,即一個操做不會被cpu切換所打斷。
咱們舉一個最多見的案列來講明:
多個線程對同一個數字進行++操做:

public class VolatileDemo {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final VolatileDemo test = new VolatileDemo();
        System.out.println("start");

        for (int i = 0; i < 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10000; j++) {
                    test.increase();
                }
            }).start();
        }

        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(test.inc);
    }
}

咱們啓動了20個線程對inc進行++操做,每一個線程+10000,理想結果應該爲200000,可是實際運行結果卻小於這個值,並且結果每次都不同(能夠多運行幾回觀察):
不保證原子性.png
這是爲何呢,程序中inc已經加了volatile修飾,保證了線程的可見性,可是爲何結果仍是會比預想的小呢?
這是由於++操做並非簡單的一步操做,即他不是原子性的,查看編譯後的字節碼文件,++的實際操做爲:
(實事求是地說,使用字節碼來分析併發問題仍然是不嚴謹的,由於即便編譯出來只有一條字節碼指令,也並不意味執行這條指令就是一個原子操做。一條字節碼指令在解釋執行時,解釋器要運 行許多行代碼才能實現它的語義。若是是編譯執行,一條字節碼指令也可能轉化成若干條本地機器碼 指令。)

public void increase();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field inc:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field inc:I
      10: return

inc++操做分紅了2.獲取字段 5.準備常數1 6.進行加1操做 7.賦值 四步
不保證原子性,即沒法確保這四步操做不會被cpu切換打斷:
image.png

如圖cpu在線程1修改完以後還未寫入內存時,切換到線程2,執行完了++操做,此時cpu切換回線程1又把inc=1 寫回去,形成了inc的值被覆蓋。
咱們再看下普通的賦值操做的字節碼文件 如:x=1

public void fun1(){
        inc = 1;
    } 
// 編譯後
 public void fun1();
    Code:
       0: aload_0
       1: iconst_1
       2: putfield      #2                  // Field inc:I
       5: return

他沒有getfield和add的操做,直接賦值,因此賦值操做算是原子性的。

而synchronized是如何保證原子性的呢?
經過字節碼文件咱們能夠發現,用synchronized修飾真的代碼塊在先後會執行monitorenter和monitorexit指令,這minitor指令底層則是經過lock和unlock來知足原子性的,他只容許同時只有一個線程來操做資源。

推薦一篇很詳細很全面的文章,此篇部分文字也有參考以下文章:

https://www.cnblogs.com/bangi...
相關文章
相關標籤/搜索