1. 程式人生 > 實用技巧 >Volatile關鍵字詳解

Volatile關鍵字詳解

參考:

https://www.cnblogs.com/zhengbin/p/5654805.html

https://juejin.im/post/6861885337568804871

https://zhuanlan.zhihu.com/p/139891314

Java中Volatile關鍵字詳解

一、基本概念


先補充一下概念:Java 記憶體模型中的可見性、原子性和有序性。

可見性:

  可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。通常,我們無法確保執行讀操作的執行緒能適時地看到其他執行緒寫入的值,有時甚至是根本不可能的事情。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。

  可見性,是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是一個執行緒修改的結果。另一個執行緒馬上就能看到。比如:用volatile修飾的變數,就會具有可見性。volatile修飾的變數不允許執行緒內部快取和重排序,所以對其他執行緒是可見的。但是這裡需要注意一個問題,volatile只能讓被他修飾內容具有可見性,但不能保證它具有原子性。比如 volatile int a = 0;之後有一個操作 a++;這個變數a具有可見性,但是a++ 依然是一個非原子操作,也就是這個操作同樣存線上程安全問題。

  在 Java 中 volatile、synchronized 和 final 實現可見性。

原子性:

  原子是世界上的最小單位,具有不可分割性。比如 a=0;(a非long和double型別) 這個操作是不可分割的,那麼我們說這個操作時原子操作。再比如:a++; 這個操作實際是a = a + 1;是可分割的,所以他不是一個原子操作。非原子操作都會存線上程安全問題,需要我們使用同步技術(sychronized)來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來了解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

  在 Java 中 synchronized 和在 lock、unlock 中操作保證原子性。

有序性:

  Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證執行緒之間操作的有序性,volatile 是因為其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個物件鎖的兩個同步塊只能序列執行。

下面內容摘錄自《Java Concurrency in Practice》:

  下面一段程式碼在多執行緒環境下,將存在問題。

+ View code
 1 /**
 2  * @author zhengbinMac
 3  */
 4 public class NoVisibility {
 5     private static boolean ready;
 6     private static int number;
 7     private static class ReaderThread extends Thread {
 8         @Override
 9         public void run() {
10             while(!ready) {
11                 Thread.yield();
12             }
13             System.out.println(number);
14         }
15     }
16     public static void main(String[] args) {
17         new ReaderThread().start();
18         number = 42;
19         ready = true;
20     }
21 }

  NoVisibility可能會持續迴圈下去,因為讀執行緒可能永遠都看不到ready的值。甚至NoVisibility可能會輸出0,因為讀執行緒可能看到了寫入ready的值,但卻沒有看到之後寫入number的值,這種現象被稱為“重排序”。只要在某個執行緒中無法檢測到重排序情況(即使在其他執行緒中可以明顯地看到該執行緒中的重排序),那麼就無法確保執行緒中的操作將按照程式中指定的順序來執行。當主執行緒首先寫入number,然後在沒有同步的情況下寫入ready,那麼讀執行緒看到的順序可能與寫入的順序完全相反。

  在沒有同步的情況下,編譯器、處理器以及執行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多執行緒程式中,要想對記憶體操作的執行春旭進行判斷,無法得到正確的結論。

  這個看上去像是一個失敗的設計,但卻能使JVM充分地利用現代多核處理器的強大效能。例如,在缺少同步的情況下,Java記憶體模型允許編譯器對操作順序進行重排序,並將數值快取在暫存器中。此外,它還允許CPU對操作順序進行重排序,並將數值快取在處理器特定的快取中。

二、Volatile原理


  Java語言提供了一種稍弱的同步機制,即volatile變數,用來確保將變數的更新操作通知到其他執行緒。當把變數宣告為volatile型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile型別的變數時總會返回最新寫入的值。

  在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。

  當對非 volatile 變數進行讀寫的時候,每個執行緒先從記憶體拷貝變數到CPU快取中。如果計算機有多個CPU,每個執行緒可能在不同的CPU上被處理,這意味著每個執行緒可以拷貝到不同的 CPU cache 中。

  而宣告變數是 volatile 的,JVM 保證了每次讀變數都從記憶體中讀,跳過 CPU cache 這一步。

當一個變數定義為 volatile 之後,將具備兩種特性:

  1.保證此變數對所有的執行緒的可見性,這裡的“可見性”,如本文開頭所述,當一個執行緒修改了這個變數的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變數做不到這點,普通變數的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。

  2.禁止指令重排序優化。有volatile修飾的變數,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),只有一個CPU訪問記憶體時,並不需要記憶體屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理)。

volatile 效能:

  volatile 的讀效能消耗與普通變數幾乎相同,但是寫操作稍慢,因為它需要在原生代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。

反制面試官 | 14張原理圖 | 再也不怕被問 volatile!

絮叨

這一篇也算是Java併發程式設計的開篇,看了很多資料,但是輪到自己去整理去總結的時候,發現還是要多看幾遍資料才能完全理解。還有一個很重要的點就是,畫圖是加深印象和檢驗自己是否理解的一個非常好的方法。

一、Volatile怎麼念?

看到這個單詞一直不知道怎麼發音

英 [ˈvɒlətaɪl]  美 [ˈvɑːlətl]

adj. [化學] 揮發性的;不穩定的;爆炸性的;反覆無常的
複製程式碼

那Java中volatile又是幹啥的呢?

二、Java中volatile用來幹啥?

  • Volatile是Java虛擬機器提供的輕量級的同步機制(三大特性)
    • 保證可見性
    • 不保證原子性
    • 禁止指令重排

要理解三大特性,就必須知道Java記憶體模型(JMM),那JMM又是什麼呢?

三、JMM又是啥?

這是一份精心總結的Java記憶體模型思維導圖,拿去不謝。

3.1 為什麼需要Java記憶體模型?

Why:遮蔽各種硬體和作業系統的記憶體訪問差異

JMM是Java記憶體模型,也就是Java Memory Model,簡稱JMM,本身是一種抽象的概念,實際上並不存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變數(包括例項欄位,靜態欄位和構成陣列物件的元素)的訪問方式。

3.2 到底什麼是Java記憶體模型?

  • 1.定義程式中各種變數的訪問規則
  • 2.把變數值儲存到記憶體的底層細節
  • 3.從記憶體中取出變數值的底層細節

3.3 Java記憶體模型的兩大記憶體是啥?

  • 主記憶體
    • Java堆中物件例項資料部分
    • 對應於物理硬體的記憶體
  • 工作記憶體
    • Java棧中的部分割槽域
    • 優先儲存於暫存器和快取記憶體

3.4 Java記憶體模型是怎麼做的?

Java記憶體模型的幾個規範:

  • 1.所有變數儲存在主記憶體

  • 2.主記憶體是虛擬機器記憶體的一部分

  • 3.每條執行緒有自己的工作記憶體

  • 4.執行緒的工作記憶體儲存變數的主記憶體副本

  • 5.執行緒對變數的操作必須在工作記憶體中進行

  • 6.不同執行緒之間無法直接訪問對方工作記憶體中的變數

  • 7.執行緒間變數值的傳遞均需要通過主記憶體來完成

由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),工作記憶體是每個執行緒的私有資料區域,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫會主記憶體,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中儲存著主記憶體中的變數副本拷貝,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程:

3.5 Java記憶體模型的三大特性

  • 可見性(當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改)
  • 原子性(一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗)
  • 有序性(變數賦值操作的順序與程式程式碼中的執行順序一致)

關於有序性:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的。前半句是指“執行緒內似表現為序列的語義”(Within-Thread As-If-Serial Semantics),後半句是指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。

四、能給個示例說下怎麼用volatile的嗎?

考慮一下這種場景:

有一個物件的欄位number初始化值=0,另外這個物件有一個公共方法setNumberTo100()可以設定number = 100,當主執行緒通過子執行緒來呼叫setNumberTo100()後,主執行緒是否知道number值變了呢?

答案:如果沒有使用volatile來定義number變數,則主執行緒不知道子執行緒更新了number的值。

(1)定義如上述所說的物件:ShareData

class ShareData {
    int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}
複製程式碼

(2)主執行緒中初始化一個子執行緒,名字叫做子執行緒

子執行緒先休眠3s,然後設定number=100。主執行緒不斷檢測的number值是否等於0,如果不等於0,則退出主執行緒。

public class volatileVisibility {
    public static void main(String[] args) {
        // 資源類
        ShareData shareData = new ShareData();

        // 子執行緒 實現了Runnable介面的,lambda表示式
        new Thread(() -> {

            System.out.println(Thread.currentThread().getName() + "\t come in");

            // 執行緒睡眠3秒,假設在進行運算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            myData.setNumberTo100();

            // 輸出修改後的值
            System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);

        }, "子執行緒").start();

        while(myData.number == 0) {
            // main執行緒就一直在這裡等待迴圈,直到number的值不等於零
        }

        // 按道理這個值是不可能打印出來的,因為主執行緒執行的時候,number的值為0,所以一直在迴圈
        // 如果能輸出這句話,說明子執行緒在睡眠3秒後,更新的number的值,重新寫入到主記憶體,並被main執行緒感知到了
        System.out.println(Thread.currentThread().getName() + "\t 主執行緒感知到了 number 不等於 0");

        /**
         * 最後輸出結果:
         * 子執行緒     come in
         * 子執行緒     update number value:100
         * 最後執行緒沒有停止,並行沒有輸出"主執行緒知道了 number 不等於0"這句話,說明沒有用volatile修飾的變數,變數的更新是不可見的
         */
    }
}
複製程式碼

(3)我們用volatile修飾變數number

class ShareData {
    //volatile 修飾的關鍵字,是為了增加多個執行緒之間的可見性,只要有一個執行緒修改了記憶體中的值,其它執行緒也能馬上感知
    volatile int number = 0;

    public void setNumberTo100() {
        this.number = 100;
    }
}
複製程式碼

輸出結果:

子執行緒  come in
子執行緒  update number value:100
main     主執行緒知道了 number 不等於 0

Process finished with exit code 0
複製程式碼

小結:說明用volatile修飾的變數,當某執行緒更新變數後,其他執行緒也能感知到。

五、那為什麼其他執行緒能感知到變數更新?

其實這裡就是用到了“窺探(snooping)”協議。在說“窺探(snooping)”協議之前,首先談談快取一致性的問題。

5.1 快取一致性

當多個CPU持有的快取都來自同一個主記憶體的拷貝,當有其他CPU偷偷改了這個主記憶體資料後,其他CPU並不知道,那拷貝的記憶體將會和主記憶體不一致,這就是快取不一致。那我們如何來保證快取一致呢?這裡就需要作業系統來共同制定一個同步規則來保證,而這個規則就有MESI協議。

如下圖所示,CPU2 偷偷將num修改為2,記憶體中num也被修改為2,但是CPU1和CPU3並不知道num值變了。

5.2 MESI

當CPU寫資料時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,系統會發出訊號通知其它CPU將該記憶體變數的快取行設定為無效。如下圖所示,CPU1和CPU3 中num=1已經失效了。

當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。

如下圖所示,CPU1和CPU3發現快取的num值失效了,就重新從記憶體讀取,num值更新為2。

5.3 匯流排嗅探

那其他CPU是怎麼知道要將快取更新為失效的呢?這裡是用到了匯流排嗅探技術。

每個CPU不斷嗅探總線上傳播的資料來檢查自己快取值是否過期了,如果處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作的時候,會重新從記憶體中把資料讀取到處理器快取中。

5.4 匯流排風暴

匯流排嗅探技術有哪些缺點?

由於MESI快取一致性協議,需要不斷對主線進行記憶體嗅探,大量的互動會導致匯流排頻寬達到峰值。因此不要濫用volatile,可以用鎖來替代,看場景啦~

六、能演示下volatile為什麼不保證原子性嗎?

原子性:一個操作或一系列操作是不可分割的,要麼同時成功,要麼同時失敗。

這個定義和volatile啥關係呀,完全不能理解呀?Show me the code!

考慮一下這種場景:

當20個執行緒同時給number自增1,執行1000次以後,number的值為多少呢?

在單執行緒的場景,答案是20000,如果是多執行緒的場景下呢?答案是可能是20000,但很多情況下都是小於20000。

示例程式碼:

package com.jackson0714.passjava.threads;

/**
 演示volatile 不保證原子性
 * @create: 2020-08-13 09:53
 */

public class VolatileAtomicity {
    public static volatile int number = 0;

    public static void increase() {
        number++;
    }

    public static void main(String[] args) {

        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            }, String.valueOf(i)).start();
        }

        // 當所有累加執行緒都結束
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(number);
    }
}
複製程式碼

執行結果:第一次19144,第二次20000,第三次19378。

我們來分析一下increase()方法,通過反編譯工具javap得到如下彙編程式碼:

  public static void increase();
    Code:
       0: getstatic     #2                  // Field number:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field number:I
       8: return
複製程式碼

number++其實執行了3條指令

getstatic:拿number的原始值 iadd:進行加1操作 putfield:把加1後的值寫回

執行了getstatic指令number的值取到操作棧頂時,volatile關鍵字保證了number的值在此時是正確的,但是在執行iconst_1、iadd這些指令的時候,其他執行緒可能已經把number的值改變了,而操作棧頂的值就變成了過期的資料,所以putstatic指令執行後就可能把較小的number值同步回主記憶體之中。

總結如下:

在執行number++這行程式碼時,即使使用volatile修飾number變數,在執行期間,還是有可能被其他執行緒修改,沒有保證原子性。

七、怎麼保證輸出結果是20000呢?

7.1 synchronized同步程式碼塊

我們可以通過使用synchronized同步程式碼塊來保證原子性。從而使結果等於20000

public synchronized static void increase() {
   number++;
}
複製程式碼

但是使用synchronized太重了,會造成阻塞,只有一個執行緒能進入到這個方法。我們可以使用Java併發包(JUC)中的AtomicInterger工具包。

7.2 AtomicInterger原子性操作

我們來看看AtomicInterger原子自增的方法getAndIncrement()

public static AtomicInteger atomicInteger = new AtomicInteger();

public static void main(String[] args) {

    for (int i = 0; i < 20; i++) {
        new Thread(() -> {
            for (int j = 0; j < 1000; j++) {
                atomicInteger.getAndIncrement();
            }
        }, String.valueOf(i)).start();
    }

    // 當所有累加執行緒都結束
    while(Thread.activeCount() > 2) {
        Thread.yield();
    }

    System.out.println(atomicInteger);
}
複製程式碼

多次執行的結果都是20000。

八、禁止指令重排又是啥?

說到指令重排就得知道為什麼要重排,有哪幾種重排。

如下圖所示,指令執行順序是按照1>2>3>4的順序,經過重排後,執行順序更新為指令3->4->2->1。

會不會感覺到重排把指令順序都打亂了,這樣好嗎?

可以回想下小學時候的數學題:2+3-5=?,如果把運算順序改為3-5+2=?,結果也是一樣的。所以指令重排是要保證單執行緒下程式結果不變的情況下做重排。

8.1 為什麼要重排

計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。

8.2 有哪幾種重排

  • 1.編譯器優化重排:編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

  • 2.指令級的並行重排:現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

  • 3.記憶體系統的重排:由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

注意:

  • 單執行緒環境裡面確保最終執行結果和程式碼順序的結果一致

  • 處理器在進行重排序時,必須要考慮指令之間的資料依賴性

  • 多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。

8.3 舉個例子來說說多執行緒中的指令重排?

設想一下這種場景:定義了變數num=0和變數flag=false,執行緒1呼叫初始化函式init()執行後,執行緒呼叫add()方法,當另外執行緒判斷flag=true後,執行num+100操作,那麼我們預期的結果是num會等於101,但因為有指令重排的可能,num=1和flag=true執行順序可能會顛倒,以至於num可能等於100

public class VolatileResort {
    static int num = 0;
    static boolean flag = false;
    public static void init() {
        num= 1;
        flag = true;
    }
    public static void add() {
        if (flag) {
            num = num + 5;
            System.out.println("num:" + num);
        }
    }
    public static void main(String[] args) {
        init();
        new Thread(() -> {
            add();
        },"子執行緒").start();
    }
}

複製程式碼

先看執行緒1中指令重排:

num= 1;flag = true; 的執行順序變為 flag=true;num = 1;,如下圖所示的時序圖

如果執行緒2 num=num+5 線上程1設定num=1之前執行,那麼執行緒2的num變數值為5。如下圖所示的時序圖。

8.4 volatile怎麼實現禁止指令重排?

我們使用volatile定義flag變數:

static volatile boolean flag = false;
複製程式碼

如何實現禁止指令重排:

原理:在volatile生成的指令序列前後插入記憶體屏障(Memory Barries)來禁止處理器重排序。

有如下四種記憶體屏障:

volatile寫的場景如何插入記憶體屏障:

  • 在每個volatile寫操作的前面插入一個StoreStore屏障(寫-寫 屏障)。

  • 在每個volatile寫操作的後面插入一個StoreLoad屏障(寫-讀 屏障)。

StoreStore屏障可以保證在volatile寫(flag賦值操作flag=true)之前,其前面的所有普通寫(num的賦值操作num=1) 操作已經對任意處理器可見了,保障所有普通寫在volatile寫之前重新整理到主記憶體。

volatile讀場景如何插入記憶體屏障:

  • 在每個volatile讀操作的後面插入一個LoadLoad屏障(讀-讀 屏障)。

  • 在每個volatile讀操作的後面插入一個LoadStore屏障(讀-寫 屏障)。

LoadStore屏障可以保證其後面的所有普通寫(num的賦值操作num=num+5) 操作必須在volatile讀(if(flag))之後執行。

十、volatile常見應用

這裡舉一個應用,雙重檢測鎖定的單例模式

package com.jackson0714.passjava.threads;
/**
 演示volatile 單例模式應用(雙邊檢測)
 * @author: 悟空聊架構
 * @create: 2020-08-17
 */

class VolatileSingleton {
    private static VolatileSingleton instance = null;
    private VolatileSingleton() {
        System.out.println(Thread.currentThread().getName() + "\t 我是構造方法SingletonDemo");
    }
    public static VolatileSingleton getInstance() {
        // 第一重檢測
        if(instance == null) {
            // 鎖定程式碼塊
            synchronized (VolatileSingleton.class) {
                // 第二重檢測
                if(instance == null) {
                    // 例項化物件
                    instance = new VolatileSingleton();
                }
            }
        }
        return instance;
    }
}
複製程式碼

程式碼看起來沒有問題,但是 instance = new VolatileSingleton();其實可以看作三條虛擬碼:

memory = allocate(); // 1、分配物件記憶體空間
instance(memory); // 2、初始化物件
instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null
複製程式碼

步驟2 和 步驟3之間不存在 資料依賴關係,而且無論重排前 還是重排後,程式的執行結果在單執行緒中並沒有改變,因此這種重排優化是允許的。

memory = allocate(); // 1、分配物件記憶體空間
instance = memory; // 3、設定instance指向剛剛分配的記憶體地址,此時instance != null,但是物件還沒有初始化完成
instance(memory); // 2、初始化物件
複製程式碼

如果另外一個執行緒執行:if(instance == null) 時,則返回剛剛分配的記憶體地址,但是物件還沒有初始化完成,拿到的instance是個假的。如下圖所示:

解決方案:定義instance為volatile變數

private static volatile VolatileSingleton instance = null;
複製程式碼

十一、volatile都不保證原子性,為啥我們還要用它?

奇怪的是,volatile都不保證原子性,為啥我們還要用它?

volatile是輕量級的同步機制,對效能的影響比synchronized小。

典型的用法:檢查某個狀態標記以判斷是否退出迴圈。

比如執行緒試圖通過類似於數綿羊的傳統方法進入休眠狀態,為了使這個示例能正確執行,asleep必須為volatile變數。否則,當asleep被另一個執行緒修改時,執行判斷的執行緒卻發現不了。

那為什麼我們不直接用synchorized,lock鎖?它們既可以保證可見性,又可以保證原子性為何不用呢?

因為synchorized和lock是排他鎖(悲觀鎖),如果有多個執行緒需要訪問這個變數,將會發生競爭,只有一個執行緒可以訪問這個變數,其他執行緒被阻塞了,會影響程式的效能。

注意:當且僅當滿足以下所有條件時,才應該用volatile變數

  • 對變數的寫入操作不依賴變數的當前值,或者你能確保只有單個執行緒更新變數的值。
  • 該變數不會與其他的狀態一起納入不變性條件中。
  • 在訪問變數時不需要加鎖。

十二、volatile和synchronzied的區別

  • volatile只能修飾例項變數和類變數,synchronized可以修飾方法和程式碼塊。
  • volatile不保證原子性,而synchronized保證原子性
  • volatile 不會造成阻塞,而synchronized可能會造成阻塞
  • volatile 輕量級鎖,synchronized重量級鎖
  • volatile 和synchronized都保證了可見性和有序性

十三、小結

  • volatile 保證了可見性:當一個執行緒修改了共享變數的值時,其他執行緒能夠立即得知這個修改。
  • volatile 保證了單執行緒下指令不重排:通過插入記憶體屏障保證指令執行順序。
  • volatitle不保證原子性,如a++這種自增操作是有併發風險的,比如扣減庫存、發放優惠券的場景。
  • volatile 型別的64位的long型和double型變數,對該變數的讀/寫具有原子性。
  • volatile 可以用在雙重檢鎖的單例模式種,比synchronized效能更好。
  • volatile 可以用在檢查某個狀態標記以判斷是否退出迴圈。

參考資料:

《深入理解Java虛擬機器》

《Java併發程式設計的藝術》

《Java併發程式設計實戰》


作者:悟空聊架構
連結:https://juejin.im/post/6861885337568804871
來源:掘金
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

一個volatile跟面試官扯了半個小時

前言

volatile 應該算是Java 後端面試的必考題,因為多執行緒程式設計基本繞不開它,很適合作為併發程式設計的入門題。

開場

面試官:你先自我介紹一下吧!

安琪拉: 我是安琪拉,草叢三婊之一,最強中單(鍾馗不服)!哦,不對,串場了,我是**,目前在–公司做–系統開發。

面試官: 看你簡歷上寫熟悉併發程式設計,volatile 用過的吧?

安琪拉: 用過的。(還是熟悉的味道)

面試官: 那你跟我講講什麼時候會用到 volatile ?

安琪拉: 如果需要保證多執行緒共享變數的可見性時,可以使用volatile 來修飾變數。

面試官: 什麼是共享變數的可見性?

安琪拉: 多執行緒併發程式設計中主要圍繞著三個特性實現。可見性是其中一種!

  • 可見性
    可見性是指當多個執行緒訪問同一個共享變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看到修改後的值。
  • 原子性
    原子性指的一個操作或一組操作要麼全部執行,要麼全部不執行。
  • 有序性
    有序性是指程式執行的順序按照程式碼的先後順序執行。

面試官: volatile 除了解決共享變數的可見性,還有別的作用嗎?

安琪拉: volatile 除了讓共享變數具有可見性,還具有有序性(禁止指令重排序)。

面試官: 你先跟我舉幾個實際volatile 實際專案中的例子?

安琪拉: 可以的。有個特別常見的例子:

  1. 狀態標誌
    比如我們工程中經常用一個變數標識程式是否啟動、初始化完成、是否停止等,如下:

volatile 很適合只有一個執行緒修改,其他執行緒讀取的情況。volatile 變數被修改之後,對其他執行緒立即可見。

面試官: 現在我們來看一下你的例子,如果不加volatile 修飾,會有什麼後果?

安琪拉: 比如這是一個帶前端互動的系統,有A、 B二個執行緒,使用者點了停止應用按鈕,A 執行緒呼叫shutdown()方法,讓變數shutdown 從false 變成 true,但是因為沒有使用volatile修飾, B 執行緒可能感知不到shutdown 的變化,而繼續執行 doWork 內的迴圈,這樣違背了程式的意願:當shutdown 變數為true 時,代表應用該停下了,doWork函式應該跳出迴圈,不再執行。

面試官: volatile還有別的應用場景嗎?

安琪拉: 懶漢式單例模式,我們常用的 double-check 的單例模式,如下所示:

使用volatile 修飾保證 singleton 的例項化能夠對所有執行緒立即可見。

面試官: 我們再來看你的單例模式的例子,我有三個問題:

  1. 為什麼使用volatile 修飾了singleton 引用還用synchronized 鎖?
  2. 第一次檢查singleton 為空後為什麼內部還需要進行第二次檢查?
  3. volatile 除了記憶體可見性,還有別的作用嗎?

安琪拉: 【心裡炸了,舉單例模式例子簡直給自己挖坑】這三個問題,我來一個個回答:

  1. 為什麼使用volatile 修飾了singleton 引用還用synchronized 鎖?
    volatile 只保證了共享變數 singleton 的可見性,但是singleton = new Singleton();這個操作不是原子的,可以分為三步:
    步驟1:在堆記憶體申請一塊記憶體空間;
    步驟2:初始化申請好的記憶體空間;
    步驟3:將記憶體空間的地址賦值給 singleton;
    所以singleton = new Singleton();是一個由三步操作組成的複合操作,多執行緒環境下A 執行緒執行了第一步、第二步之後發生執行緒切換,B 執行緒開始執行第一步、第二步、第三步(因為A 執行緒singleton 是還沒有賦值的),所以為了保障這三個步驟不可中斷,可以使用synchronized 在這段程式碼塊上加鎖。(synchronized 原理參考《安琪拉與面試官二三事》系列第二篇文章)
  2. 第一次檢查singleton 為空後為什麼內部還進行第二次檢查?
    A 執行緒進行判空檢查之後開始執行synchronized程式碼塊時發生執行緒切換(執行緒切換可能發生在任何時候),B 執行緒也進行判空檢查,B執行緒檢查 singleton == null 結果為true,也開始執行synchronized程式碼塊,雖然synchronized 會讓二個執行緒序列執行,如果synchronized程式碼塊內部不進行二次判空檢查,singleton 可能會初始化二次。
  3. volatile 除了記憶體可見性,還有別的作用嗎?
    volatile 修飾的變數除了可見性,還能防止指令重排序。
    指令重排序是編譯器和處理器為了優化程式執行的效能而對指令序列進行重排的一種手段。現象就是CPU 執行指令的順序可能和程式程式碼的順序不一致,例如a = 1; b = 2;可能 CPU 先執行b=2;後執行a=1;
    singleton = new Singleton();由三步操作組合而成,如果不使用volatile 修飾,可能發生指令重排序。步驟3 在步驟2 之前執行,singleton 引用的是還沒有被初始化的記憶體空間,別的執行緒呼叫單例的方法就會引發未被初始化的錯誤。
    指令重排序也遵循一定的規則:
  • 重排序不會對存在依賴關係的操作進行重排

    • 重排序目的是優化效能,不管怎樣重排,單執行緒下的程式執行結果不會變

因此volatile 還有禁止指令重排序的作用。

面試官: 那為什麼不加volatile ,A 執行緒對共享變數的修改,其他執行緒不可見呢?你知道volatile的底層原理嗎?

安琪拉: 果然該來的還是來了,我要放大招了,您坐穩咯!

面試官: 我靠在椅子上,穩的很,請開始你的表演!

安琪拉: 先說結論,我們知道volatile可以實現記憶體的可見性和防止指令重排序,但是volatile 不保證操作的原子性。那麼volatile是怎麼實現可見性和有序性的呢?其實volatile的這些記憶體語意是通過記憶體屏障技術實現的。

面試官: 那你跟我講講記憶體屏障。

安琪拉: 講記憶體屏障的話,這塊內容會比較深,我以下面的順序講,這個整個知識成體系,不散:

  1. 現代CPU 架構的形成
  2. Java 記憶體模型(JMM)
  3. Java 通過 Java 記憶體模型(JMM )實現 volatile 平臺無關

現代CPU 架構的形成

安琪拉: 一切要從盤古開天闢地說起,女媧補天! 咳咳,不好意思,扯遠了! 一切從馮洛伊曼計算機體系開始說起!

面試官: 扯的是不是有點遠!

安琪拉: 你就說要不要聽?要聽別打斷我!

面試官: 得嘞!您請講!

安琪拉: 下圖就是經典的 馮洛伊曼體系結構,基本把計算機的組成模組都定義好了,現在的計算機都是以這個體系弄的,其中最核心的就是由運算器和控制器組成的中央處理器,就是我們常說的CPU。

面試官: 這個跟 volatile 有什麼關係?

安琪拉: 不要著急嘛!理解技術不要死盯著技術的細枝末節,要思考這個技術產生的歷史背景和原因,思考發明這個技術的人當時是遇到了什麼問題? 而發明這個技術的。 這樣即理解深刻,也讓自己思考問題更巨集觀,更有深度!這叫從歷史的角度看問題,站在巨人的肩膀上!

面試官: 來來來,今天你教我做人!

安琪拉: 剛才說到馮洛伊曼體系中的CPU,你應該聽過摩爾定律吧! 就是英特爾創始人戈登·摩爾講的:

積體電路上可容納的電晶體數目,約每隔18個月便會增加一倍,效能也將提升一倍。

面試官: 聽過的,然後呢?

安琪拉:所以你看到我們電腦CPU 的效能越來越強勁,英特爾CPU 從Intel Core 一直到 Intel Core i7,前些年單核CPU 的電晶體數量確實符合摩爾定律,看下面這張圖。

橫軸為新CPU發明的年份,縱軸為可容納電晶體的對數。所有的點近似成一條直線,這意味著電晶體數目隨年份呈指數變化,大概每兩年翻一番。

面試官: 後來呢? 這和今天說的 volatile,以及記憶體屏障有什麼關係?

安琪拉:彆著急啊!後來摩爾定律越來越撐不住了,但是更新換代的程式對電腦效能的期望和要求還在不斷上漲,就出現了下面的劇情。

他為其Pentium 4新一代晶片取消上市而道歉, 近幾年來,英特爾不斷地在增加其處理器的執行速度。當前最快的一款,其速度已達3.4GHz,雖然強化處理器的執行速度,也增強了晶片運作效能,但速度提升卻使得晶片的能源消耗量增加,並衍生出冷卻晶片的問題。
因此,英特爾摒棄將心力集中在提升執行速度的做法,在未來幾年,將其晶片轉為以多模核心(multi-core)的方式設計等其他方式,來提升晶片的表現。多模核心的設計法是將多模核心置入單一晶片中。如此一來,這些核心晶片即能以較緩慢的速度運轉,除了可減少運轉消耗的能量,也能減少運轉生成的熱量。此外,集眾核心晶片之力,可提供較單一核心晶片更大的處理能力。 —《經濟學人》

安琪拉:當然上面貝瑞特當然只是在開玩笑,眼看摩爾定律撐不住了,後來怎麼處理的呢?一顆CPU 不行,我們多來幾顆嘛!這就是現在我們常見的多核CPU,四核8G 聽著熟悉不熟悉?當然完全依據馮洛伊曼體系設計的計算機也是有缺陷的!

面試官: 什麼缺陷? 說說看。

安琪拉: CPU 運算器的運算速度遠比記憶體讀寫速度快,所以CPU 大部分時間都在等資料從記憶體讀取,運算完資料寫回記憶體。

面試官: 那怎麼解決?

安琪拉: 因為CPU 執行速度實在太快,主存(就是記憶體)的資料讀取速度和CPU 運算速度差了有幾個數量級,因此現代計算機系統通過在CPU 和主存之前加了一層讀寫速度儘可能接近CPU 執行速度的快取記憶體來做資料緩衝,這樣快取提前從主存獲取資料,CPU 不再從主存取資料,而是從快取取資料。這樣就緩解由於主存速度太慢導致的CPU 飢餓的問題。同時CPU 內還有暫存器,一些計算的中間結果臨時放在暫存器內。

面試官: 既然你提到快取,那我問你一個問題,CPU 從快取讀取資料和從記憶體讀取資料除了讀取速度的差異?有什麼本質的區別嗎?不都是讀資料寫資料,而且加快取會讓整個體系結構變得更加複雜。

安琪拉:快取和主存不僅僅是讀取寫入資料速度上的差異,還有另外更大的區別:研究人員發現了程式80%的時間在執行20% 的程式碼,所以快取本質上只要把20%的常用資料和指令放進來就可以了(是不是和Redis 存放熱點資料很像),另外CPU 訪問主存資料時存在二個區域性性現象:

  1. 時間區域性性現象
    如果一個主存資料正在被訪問,那麼在近期它被再次訪問的概率非常大。想想你程式大部分時間是不是在執行主流程。
  2. 空間區域性性現象
    CPU使用到某塊記憶體區域資料,這塊記憶體區域後面臨近的資料很大概率立即會被使用到。這個很好解釋,我們程式經常用的陣列、集合(本質也是陣列)經常會順序訪問(記憶體地址連續或鄰近)。

因為這二個區域性性現象的存在使得快取的存在可以很大程度上緩解CPU 飢餓的問題。

面試官: 講的是那麼回事,那能給我畫一下現在CPU、快取、主存的關係圖嗎?

安琪拉:可以。我們來看下現在主流的多核CPU的硬體架構,如下圖所示。

安琪拉: 現代作業系統一般會有多級快取(Cache Line),一般有L1、L2,甚至有L3,看下安琪拉的電腦快取資訊,一共4核,三級快取,L1 快取(在CPU核心內)這裡沒有顯示出來,這裡L2 快取後面括號標識了是每個核都有L2 快取,而L3 快取沒有標識,是因為L3 快取是4個核共享的快取:

面試官: 那你能跟我簡單講講程式執行時,資料是怎麼在主存、快取、CPU暫存器之間流轉的嗎?

安琪拉: 可以。比如以i = i + 2;為例, 當執行緒執行到這條語句時,會先從主存中讀取i 的值,然後複製一份到快取中,CPU 讀取快取資料(取數指令),進行 i + 2 操作(中間資料放暫存器),然後把結果寫入快取,最後將快取中i最新的值重新整理到主存當中(寫回時間不確定)。

面試官: 這個資料操作邏輯在單執行緒環境和多執行緒環境下有什麼區別?

安琪拉: 比如i 如果是共享變數(例如物件的成員變數),單執行緒執行沒有任何問題,但是多執行緒中執行就有可能出問題。例如:有A、B二個執行緒,在不同的CPU 上執行,因為每個執行緒執行的CPU 都有自己的快取,A 執行緒從記憶體讀取i 的值存入快取,B 執行緒此時也讀取i 的值存入自己的快取,A 執行緒對i 進行+1操作,i變成了1,B執行緒快取中的變數 i 還是0,B執行緒也對i 進行+1操作,最後A、B執行緒先後將快取資料寫入記憶體,記憶體預期正確的結果應該是2,但是實際是1。這個就是非常著名的快取一致性問題。

說明:單核CPU 的多執行緒也會出現上面的執行緒不安全的問題,只是產生原因不是多核CPU快取不一致的問題導致,而是CPU排程執行緒切換,多執行緒區域性變數不同步引起的。

執行過程如下圖:

面試官: 那CPU 怎麼解決快取一致性問題呢?

安琪拉:早期的一些CPU 設計中,是通過鎖匯流排(匯流排訪問加Lock# 鎖)的方式解決的。看下CPU 體系結構圖,如下:

因為CPU 都是通過匯流排來讀取主存中的資料,因此對匯流排加Lock# 鎖的話,其他CPU 訪問主存就被阻塞了,這樣防止了對共享變數的競爭。但是鎖匯流排對CPU的效能損耗非常大,把多核CPU 並行的優勢直接給乾沒了!

後面研究人員就搞出了一套協議:快取一致性協議。協議的型別很多(MSI、MESI、MOSI、Synapse、Firefly),最常見的就是Intel 的MESI 協議。快取一致性協議主要規範了CPU 讀寫主存、管理快取資料的一系列規範,如下圖所示。

面試官: 那講講 **MESI **協議唄!

安琪拉: (MESI這部分內容可以只瞭解大概思想,不用深究,因為東西多到可以單獨成一篇文章了)

MESI 協議的核心思想:

  • 定義了快取中的資料狀態只有四種,MESI 是四種狀態的首字母。
  • 當CPU寫資料時,如果寫的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態;
  • 當CPU讀取共享變數時,發現自己快取的該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。
快取中資料都是以快取行(Cache Line)為單位儲存;MESI 各個狀態描述如下表所示:

面試官: 那我問你MESI 協議和volatile實現的記憶體可見性時什麼關係?

安琪拉: volatile 和MESI 中間差了好幾層抽象,中間會經歷java編譯器,java虛擬機器和JIT,作業系統,CPU核心。

volatile 是Java 中標識變數可見性的關鍵字,說直接點:使用volatile 修飾的變數是有記憶體可見性的,這是Java 語法定的,Java 不關心你底層作業系統、硬體CPU 是如何實現記憶體可見的,我的語法規定就是volatile 修飾的變數必須是具有可見性的。

CPU 有X86(複雜指令集)、ARM(精簡指令集)等體系架構,版本型別也有很多種,CPU 可能通過鎖匯流排、MESI 協議實現多核心快取的一致性。因為有硬體的差異以及編譯器和處理器的指令重排優化的存在,所以Java 需要一種協議來規避硬體平臺的差異,保障同一段代表在所有平臺執行效果一致,這個協議叫做Java 記憶體模型(Java Memory Model)。

Java 記憶體模型(JMM)

面試官: 你能詳細講講Java 記憶體模型嗎?

安琪拉: JMM 全稱Java Memory Model, 是 Java 中非常重要的一個概念,是Java 併發程式設計的核心和基礎。JMM 是Java 定義的一套協議,用來遮蔽各種硬體和作業系統的記憶體訪問差異,讓Java 程式在各種平臺都能有一致的執行效果。

協議這個詞都不會陌生,HTTP 協議、TCP 協議等。JMM 協議就是一套規範,具體的內容為:

所有的變數都儲存在主記憶體中,每個執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了該執行緒使用到的變數(主記憶體的拷貝),執行緒對變數的所有操作(讀取、賦值)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要在主記憶體來完成。

面試官: 你剛才提到每個執行緒都有自己的工作記憶體,問個深入一點的問題,執行緒的工作記憶體在主存還是快取中?

安琪拉: 這個問題非常棒!JMM 中定義的每個執行緒私有的工作記憶體是抽象的規範,實際上工作記憶體和真實的CPU 記憶體架構如下所示,Java 記憶體模型和真實硬體記憶體架構是不同的:

JMM 是記憶體模型,是抽象的協議。首先真實的記憶體架構是沒有區分堆和棧的,這個Java 的JVM 來做的劃分,另外執行緒私有的本地記憶體執行緒棧可能包括CPU 暫存器、快取和主存。堆亦是如此!

面試官: 能具體講講JMM 記憶體模型規範嗎?

安琪拉: 可以。前面已經講了執行緒本地記憶體和物理真實記憶體之間的關係,說的詳細些:

  • 初始變數首先儲存在主記憶體中;
  • 執行緒操作變數需要從主記憶體拷貝到執行緒本地記憶體中;
  • 執行緒的本地工作記憶體是一個抽象概念,包括了快取、store buffer(後面會講到)、暫存器等。

面試官: 那JMM 模型中多執行緒如何通過共享變數通訊呢?

安琪拉: 執行緒間通訊必須要經過主記憶體。

執行緒A與執行緒B之間要通訊的話,必須要經歷下面2個步驟:

1)執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。

2)執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。

關於主記憶體與工作記憶體之間的具體互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作(單一操作都是原子的)來完成:

  • lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態。
  • unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數解除鎖定,解除鎖定後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(有的指令是save/儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的write的操作。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

我們編譯一段Java code 看一下。

程式碼和位元組碼指令分別為:

Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

  • 如果要把一個變數從主記憶體中複製到工作記憶體,需要順序執行read 和load 操作, 如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行store 和write 操作。但Java記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行,也就是操作不是原子的,一組操作可以中斷。
  • 不允許read和load、store和write操作之一單獨出現,必須成對出現。
  • 不允許一個執行緒丟棄它的最近assign的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中。
  • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施use和store操作之前,必須先執行過了assign和load操作。
  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。lock和unlock必須成對出現
  • 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行load或assign操作初始化變數的值
  • 如果一個變數事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中(執行store和write操作)。

面試官: 聽下來 Java 記憶體模型真的內容很多,那Java 記憶體模型是如何保障你上面說的這些規則的呢?

安琪拉: 這就是接下來要說的底層實現原理了,上面叨逼叨說了一堆概念和規範,需要慢慢消化。

Java 通過 Java 記憶體模型(JMM )實現 volatile 平臺無關

安琪拉: 我們前面說 併發程式設計實際就是圍繞三個特性的實現展開的:

  • 可見性
  • 有序性
  • 原子性

面試官: 對的。前面已經說過了。我怎麼感覺我想是捧哏。

安琪拉: 前面我們已經說過共享變數不可見的問題,講完Java 記憶體模型,理解的應該更深刻了,如下圖所示:

1. 可見性問題:如果物件obj 沒有使用volatile 修飾,A 執行緒在將物件count讀取到本地記憶體,從1修改為2,B 執行緒也把obj 讀取到本地記憶體,因為A 執行緒的修改對B 執行緒不可見,這是從Java 記憶體模型層面看可見性問題(前面從實體記憶體結構分析的)。

2. 有序性問題:重排序發生的地方有很多,編譯器優化、CPU 因為指令流水批處理而重排序、記憶體因為快取以及store buffer 而顯得亂序執行。如下圖所示:

附一張帶store buffer (寫緩衝)的CPU 架構圖,希望詳細瞭解store buffer 可以看文章最後面的擴充套件閱讀。

每個處理器上的Store Buffer(寫緩衝區),僅僅對它所在的處理器可見。這會導致處理器執行記憶體操作的順序可能會與記憶體實際的操作執行順序不一致。由於現代的處理器都會使用寫緩衝區,因此現代的處理器都會允許對寫-讀操作進行重排序:

下圖是各種CPU 架構允許的指令重排序的情況。

3. 原子性問題:例如多執行緒併發執行 i = i +1。 i 是共享變數,看完Java 記憶體模型,知道這個操作不是原子的,可以分為+1 操作和賦值操作。因此多執行緒併發訪問時,可能發生執行緒切換,造成不是預期結果。

針對上面的三個問題,Java 中提供了一些關鍵字來解決。

  1. 可見性 & 有序性問題解決
    volatile 可以讓共享變數實現可見性,同時禁止共享變數的指令重排,保障可見性。從JSR-333 規範 和 實現原理講:
  • JSR-333 規範:JDK 5定義的記憶體模型規範,
    在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係。

    1. 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
    2. 兩個操作之間存在happens-before關係,並不意味著一定要按照happens-before原則制定的順序來執行。如果重排序之後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法。
  1. 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;
  2. 鎖定規則:一個unLock操作先行發生於後面對同一個鎖額lock操作;
  3. volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;
  4. 傳遞規則:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;
  5. 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;
  6. 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;
  7. 執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行;
  8. 物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始;
    • 實現原理:上面說的happens-before原則保障可見性,禁止指令重排保證有序性,如何實現的呢?
      Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序,保證共享變數操作的有序性。
      記憶體屏障指令:寫操作的會讓執行緒本地的共享記憶體變數寫完強制重新整理到主存。讀操作讓本地執行緒變數無效,強制從主記憶體讀取,保證了共享記憶體變數的可見性。

JVM中提供了四類記憶體屏障指令:

JSR-133 定義的相應的記憶體屏障,在第一步操作(列)和第二步操作(行)之間需要的記憶體屏障指令如下:

Java volatile 例子:

以下是區分各個CPU體系支援的記憶體屏障(也叫記憶體柵欄),由JVM 實現平臺無關(volatile所有平臺表現一致)

synchronized 也可以實現有序性和可見性,但是是通過鎖讓併發序列化實現有序,記憶體屏障實現可見。原理可以看《安琪拉與面試官二三事》系列的synchronized 篇。

    • 一個執行緒寫入變數a後,任何執行緒訪問該變數都會拿到最新值。
    • 在寫入變數a之前的寫入操作,其更新的資料對於其他執行緒也是可見的。因為Memory Barrier會刷出cache中的所有先前的寫入。
  1. 原子性問題解決
    原子性主要通過JUC Atomic***包實現,如下圖所示,內部使用CAS 指令實現原子性,各個CPU架構有些區別。

擴充套件閱讀

Java如何實現跨平臺

作為Java 程式設計師的我們只需要寫一堆 ***.java 檔案,編譯器把 .java 檔案編譯成 .class 位元組碼檔案,後面的事就都交給Java 虛擬機器(JVM)做了。如下圖所示, Java虛擬機器是區分平臺的,虛擬機器來進行 .class 位元組碼指令翻譯成平臺相關的機器碼。

所以 Java 是跨平臺的,Java 虛擬機器(JVM)不是跨平臺的,JVM 是平臺相關的。 大家可以看Hostpot1.8 原始碼資料夾,JVM 每個系統都有單獨的實現,如下圖所示:

As-if-serial

As-if-serial語義的意思是,所有的動作(Action)都可以為了優化而被重排序,但是必須保證它們重排序後的結果和程式程式碼本身的應有結果是一致的。Java編譯器、執行時和處理器都會保證單執行緒下的as-if-serial語義。

併發&並行

現代作業系統,現代作業系統都是按時間片排程執行的,最小的排程執行單元是執行緒,多工和並行處理能力是衡量一臺計算機處理器的非常重要的指標。這裡有個概念要說一下:

  • 併發:多個程式可能同時執行的現象,例如刷微博和聽歌同時進行,可能你電腦只有一顆CPU,但是通過時間片輪轉的方式讓你感覺在同時進行。
  • 並行:多核CPU,每個CPU 內執行自己的執行緒,是真正的同時進行的,叫並行。

記憶體屏障

JSR-133 對應規則需要的規則

另外 final 關鍵字需要 StoreStore 屏障

x.finalField = v; StoreStore; sharedRef = x;

MESI 協議運作模式

MESI 協議運作的具體流程,舉個例項

第一列是操作序列號,第二列是執行操作的CPU,第三列是具體執行哪一種操作,第四列描述了各個cpu local cache中的cacheline的狀態(用meory address/狀態表示),最後一列描述了記憶體在0地址和8地址的資料內容的狀態:V表示是最新的,和cache一致,I表示不是最新的內容,最新的內容儲存在cache中。

總結篇

Java記憶體模型

Java 記憶體模型(JSR-133)遮蔽了硬體、作業系統的差異,實現讓Java程式在各種平臺下都能達到一致的併發效果,規定了一個執行緒如何和何時可以看到由其他執行緒修改過後的共享變數的值,以及在必須時如何同步的訪問共享變數,JMM使用記憶體屏障提供了java程式執行時統一的記憶體模型。

volatile的實現原理

volatile可以實現記憶體的可見性和防止指令重排序。

通過記憶體屏障技術實現的。

為了實現volatile的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障指令,記憶體屏障效果有:

  • 禁止volatile 修飾變數指令的重排序
  • 寫入資料強制重新整理到主存
  • 讀取資料強制從主存讀取

volatile使用總結

  • volatile 是Java 提供的一種輕量級同步機制,可以保證共享變數的可見性和有序性(禁止指令重排),常用於
    狀態標誌、雙重檢查的單例等場景。使用原則:
    • 對變數的寫操作不依賴於當前值。例如 i++ 這種就不適用。
    • 該變數沒有包含在具有其他變數的不變式中。

volatile的使用場景不是很多,使用時需要仔細考慮下是否適用volatile,注意滿足上面的二個原則。

    • 單個的共享變數的讀/寫(比如a=1)具有原子性,但是像num++或者a=b+1;這種複合操作,volatile無法保證其原子性;