volatile 關鍵字,你真的理解嗎?

最近,在一篇文章中瞭解到了 volatile 關鍵字,在強烈的求知慾趨使下,我查閱了一些相關資料進行了學習,並將學習筆記記錄如下,希望能給小夥伴們帶來一些幫助。

這裏先給大家分享一個我在 B 站發現的講解 volitle 關鍵字的視頻,有興趣的同學可以認真看一下,挺不錯的,我就是通過它進行的學習。

視頻地址:https://www.bilibili.com/video/BV1BJ411j7qb?from=search&seid=7212869160158812321

volatile 的作用

大家都應該知道 volatile 的主要作用有兩點:

  • 保證變量的內存可見性
  • 禁止指令重排序

那麼,什麼是內存可見性,什麼是指令重排序,以及它們涉及了那些機制呢?下面就讓我們來看看吧。

在這裏提醒一下,各位小夥伴要有個心理準備,就一個 volatile 關鍵字所涉及的知識點超乎你的想象喲。

可見性問題

在理解 volatile 的內存可見性前,我們先來看看這個比較常見的多線程訪問共享變量的例子。

/**
 * 變量的內存可見性例子
 *
 * @author star
 */
public class VolatileExample {

    /**
     * main 方法作爲一個主線程
     */
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        // 開啓線程
        myThread.start();

        // 主線程執行
        for (; ; ) {
            if (myThread.isFlag()) {
                System.out.println("主線程訪問到 flag 變量");
            }
        }
    }

}

/**
 * 子線程類
 */
class MyThread extends Thread {

    private boolean flag = false;

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 修改變量值
        flag = true;
        System.out.println("flag = " + flag);
    }

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

執行上面的程序,你會發現,控制檯永遠都不會輸出 “主線程訪問到 flag 變量” 這句話。我們可以看到,子線程執行時已經將 flag 設置成 true,但主線程執行時沒有讀到 flag 的最新值,導致控制檯沒有輸出上面的句子。

那麼,我們思考一下爲什麼會出現這種情況呢?這裏我們就要了解一下 Java 內存模型(簡稱 JMM)。

Java 內存模型

JMM(Java Memory Model):Java 內存模型,是 Java 虛擬機規範中所定義的一種內存模型,Java 內存模型是標準化的,屏蔽掉了底層不同計算機的區別。也就是說,JMM 是 JVM 中定義的一種併發編程的底層模型機制。

JMM 定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程以讀/寫共享變量的副本。

JMM 的規定:

  • 所有的共享變量都存儲於主內存。這裏所說的變量指的是實例變量和類變量,不包含局部變量,因爲局部變量是線程私有的,因此不存在競爭問題。

  • 每一個線程還存在自己的工作內存,線程的工作內存,保留了被線程使用的變量的工作副本。

  • 線程對變量的所有的操作(讀,寫)都必須在工作內存中完成,而不能直接讀寫主內存中的變量。

  • 不同線程之間也不能直接訪問對方工作內存中的變量,線程間變量的值的傳遞需要通過主內存中轉來完成。

JMM 的抽象示意圖:JMM
然而,JMM 這樣的規定可能會導致線程對共享變量的修改沒有即時更新到主內存,或者線程沒能夠即時將共享變量的最新值同步到工作內存中,從而使得線程在使用共享變量的值時,該值並不是最新的。

正因爲 JMM 這樣的機制,就出現了可見性問題。也就是我們上面那個例子出現的問題

那我們要如何解決可見性問題呢?接下來我們就聊聊內存可見性以及可見性問題的解決方案。

內存可見性

內存可見性是指當一個線程修改了某個變量的值,其它線程總是能知道這個變量變化。也就是說,如果線程 A 修改了共享變量 V 的值,那麼線程 B 在使用 V 的值時,能立即讀到 V 的最新值。

可見性問題的解決方案

我們如何保證多線程下共享變量的可見性呢?也就是當一個線程修改了某個值後,對其他線程是可見的。

這裏有兩種方案:加鎖使用 volatile 關鍵字

下面我們使用這兩個方案對上面的例子進行改造。

加鎖

使用 synchronizer 進行加鎖。

 /**
  * main 方法作爲一個主線程
  */
  public static void main(String[] args) {
      MyThread myThread = new MyThread();
      // 開啓線程
      myThread.start();

      // 主線程執行
      for (; ; ) {
          synchronized (myThread) {
              if (myThread.isFlag()) {
                  System.out.println("主線程訪問到 flag 變量");
                }
          }
      }
  }

這裏大家應該有個疑問是,爲什麼加鎖後就保證了變量的內存可見性了? 因爲當一個線程進入 synchronizer 代碼塊後,線程獲取到鎖,會清空本地內存,然後從主內存中拷貝共享變量的最新值到本地內存作爲副本,執行代碼,又將修改後的副本值刷新到主內存中,最後線程釋放鎖。

這裏除了 synchronizer 外,其它鎖也能保證變量的內存可見性。

使用 volatile 關鍵字

使用 volatile 關鍵字修飾共享變量。

/**
 * 子線程類
 */
class MyThread extends Thread {

    private volatile boolean flag = false;

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 修改變量值
        flag = true;
        System.out.println("flag = " + flag);
    }

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

使用 volatile 修飾共享變量後,每個線程要操作變量時會從主內存中將變量拷貝到本地內存作爲副本,當線程操作變量副本並寫回主內存後,會通過 CPU 總線嗅探機制告知其他線程該變量副本已經失效,需要重新從主內存中讀取。

volatile 保證了不同線程對共享變量操作的可見性,也就是說一個線程修改了 volatile 修飾的變量,當修改後的變量寫回主內存時,其他線程能立即看到最新值。

接下來我們就聊聊一個比較底層的知識點:總線嗅探機制

總線嗅探機制

在現代計算機中,CPU 的速度是極高的,如果 CPU 需要存取數據時都直接與內存打交道,在存取過程中,CPU 將一直空閒,這是一種極大的浪費,所以,爲了提高處理速度,CPU 不直接和內存進行通信,而是在 CPU 與內存之間加入很多寄存器,多級緩存,它們比內存的存取速度高得多,這樣就解決了 CPU 運算速度和內存讀取速度不一致問題。

由於 CPU 與內存之間加入了緩存,在進行數據操作時,先將數據從內存拷貝到緩存中,CPU 直接操作的是緩存中的數據。但在多處理器下,將可能導致各自的緩存數據不一致(這也是可見性問題的由來),爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,而嗅探是實現緩存一致性的常見機制

處理器內存模型

注意:緩存的一致性問題,不是多處理器導致,而是多緩存導致的。

嗅探機制工作原理:每個處理器通過監聽在總線上傳播的數據來檢查自己的緩存值是不是過期了,如果處理器發現自己緩存行對應的內存地址修改,就會將當前處理器的緩存行設置無效狀態,當處理器對這個數據進行修改操作的時候,會重新從主內存中把數據讀到處理器緩存中。

注意:基於 CPU 緩存一致性協議,JVM 實現了 volatile 的可見性,但由於總線嗅探機制,會不斷的監聽總線,如果大量使用 volatile 會引起總線風暴。所以,volatile 的使用要適合具體場景。

可見性問題小結

上面的例子中,我們看到,使用 volatile 和 synchronized 鎖都可以保證共享變量的可見性。相比 synchronized 而言,volatile 可以看作是一個輕量級鎖,所以使用 volatile 的成本更低,因爲它不會引起線程上下文的切換和調度。但 volatile 無法像 synchronized 一樣保證操作的原子性。

下面我們來聊聊 volatile 的原子性問題。

volatile 的原子性問題

所謂的原子性是指在一次操作或者多次操作中,要麼所有的操作全部都得到了執行並且不會受到任何因素的干擾而中斷,要麼所有的操作都不執行。

在多線程環境下,volatile 關鍵字可以保證共享數據的可見性,但是並不能保證對數據操作的原子性。也就是說,多線程環境下,使用 volatile 修飾的變量是線程不安全的

要解決這個問題,我們可以使用鎖機制,或者使用原子類(如 AtomicInteger)。

這裏特別說一下,對任意單個使用 volatile 修飾的變量的讀 / 寫是具有原子性,但類似於 flag = !flag 這種複合操作不具有原子性。簡單地說就是,單純的賦值操作是原子性的

禁止指令重排序

什麼是重排序?

爲了提高性能,在遵守 as-if-serial 語義(即不管怎麼重排序,單線程下程序的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守。)的情況下,編譯器和處理器常常會對指令做重排序。

一般重排序可以分爲如下三種類型:

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

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

  • 內存系統重排序。由於處理器使用緩存和讀 / 寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。

數據依賴性:如果兩個操作訪問同一個變量,且這兩個操作中有一個爲寫操作,此時這兩個操作之間就存在數據依賴性。這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的數據依賴性不被編譯器和處理器考慮。

從 Java 源代碼到最終執行的指令序列,會分別經歷下面三種重排序:
重排序順序
爲了更好地理解重排序,請看下面的部分示例代碼:

int a = 0;
// 線程 A
a = 1;           // 1
flag = true;     // 2

// 線程 B
if (flag) { // 3
  int i = a; // 4
}

單看上面的程序好像沒有問題,最後 i 的值是 1。但是爲了提高性能,編譯器和處理器常常會在不改變數據依賴的情況下對指令做重排序。假設線程 A 在執行時被重排序成先執行代碼 2,再執行代碼 1;而線程 B 在線程 A 執行完代碼 2 後,讀取了 flag 變量。由於條件判斷爲真,線程 B 將讀取變量 a。此時,變量 a 還根本沒有被線程 A 寫入,那麼 i 最後的值是 0,導致執行結果不正確。那麼如何程序執行結果正確呢?這裏仍然可以使用 volatile 關鍵字。

這個例子中, 使用 volatile 不僅保證了變量的內存可見性,還禁止了指令的重排序,即保證了 volatile 修飾的變量編譯後的順序與程序的執行順序一樣。那麼使用 volatile 修飾 flag 變量後,在線程 A 中,保證了代碼 1 的執行順序一定在代碼 2 之前。

那麼,讓我們繼續往下探索, volatile 是如何禁止指令重排序的呢?這裏我們將引出一個概念:內存屏障指令

內存屏障指令

爲了實現 volatile 內存語義(即內存可見性),JMM 會限制特定類型的編譯器和處理器重排序。爲此,JMM 針對編譯器制定了 volatile 重排序規則表,如下所示:
volatile 重排序規則

使用 volatile 修飾變量時,根據 volatile 重排序規則表,Java 編譯器在生成字節碼時,會在指令序列中插入內存屏障指令來禁止特定類型的處理器重排序。

內存屏障是一組處理器指令,它的作用是禁止指令重排序和解決內存可見性的問題。

JMM 把內存屏障指令分爲下列四類:

屏障類型 指令示例 說明
LoadLoad 屏障 Load1; LoadLoad; Load2 確保 Load1 數據的讀取操作,在 Load2 及所有後續的讀取操作之前。
StoreStore 屏障 Store1; StoreStore; Store2 確保 Store1 數據的寫入操作對其他處理器可見(刷新到內存),在 Store2 及所有後續數據的寫入操作之前。
LoadStore 屏障 Load1; LoadStore; Store2 確保 Load1 數據的讀取操作,在 Store2 及所有後續的數據刷新到內存之前。
StoreLoad 屏障 Store1; StoreLoad; Load2 確保 Store1 數據的寫入操作對其他處理器變得可見(指刷新到內存),在 Load2 及所有後續數據的寫入操作之前。StoreLoad 屏障會使該屏障之前的所有內存訪問指令(讀取和寫入指令)完成之後,才執行該屏障之後的內存訪問指令。

StoreLoad 屏障是一個全能型的屏障,它同時具有其他三個屏障的效果。所以執行該屏障開銷會很大,因爲它使處理器要把緩存中的數據全部刷新到內存中。

下面我們來看看 volatile 讀 / 寫時是如何插入內存屏障的,見下圖:

volatile內存屏障

從上圖,我們可以知道 volatile 讀 / 寫插入內存屏障規則:

  • 在每個 volatile 讀操作的後面插入 LoadLoad 屏障和 LoadStore 屏障。
  • 在每個 volatile 寫操作的前後分別插入一個 StoreStore 屏障和一個 StoreLoad 屏障。

也就是說,編譯器不會對 volatile 讀與 volatile 讀後面的任意內存操作重排序;編譯器不會對 volatile 寫與 volatile 寫前面的任意內存操作重排序。

happens-before 概述

上面我們講述了重排序原則,爲了提高處理速度, JVM 會對代碼進行編譯優化,也就是指令重排序優化,但是併發編程下指令重排序也會帶來一些安全隱患:如指令重排序導致的多個線程操作之間的不可見性。爲了理解 JMM 提供的內存可見性保證,讓程序員再去學習複雜的重排序規則以及這些規則的具體實現,那麼程序員的負擔就太重了,嚴重影響了併發編程的效率。

所以從 JDK5 開始,提出了 happens-before 的概念,通過這個概念來闡述操作之間的內存可見性。如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關係。這裏提到的兩個操作既可以是在一個線程之內,也可以是在不同線程之間。

happens-before 規則如下:

  • 程序順序規則:一個線程中的每個操作,happens-before 於該線程中的任意後續操作。

  • 監視器鎖規則:對一個監視器鎖的解鎖,happens-before 於隨後對這個監視器鎖的加鎖。

  • volatile 變量規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。

  • 傳遞性:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。

  • start() 規則:Thread.start() 的調用會 happens-before 於啓動線程裏面的動作。

  • join() 規則:Thread 中的所有動作都 happens-before 於其他線程從 Thread.join() 中成功返回。

這裏特別說明一下,happens-before 規則不是描述實際操作的先後順序,它是用來描述可見性的一種規則。

從 happens-before 的 volatile 變量規則可知,如果線程 A 寫入了 volatile 修飾的變量 V,接着線程 B 讀取了變量 V,那麼,線程 A 寫入變量 V 及之前的寫操作都對線程 B 可見。

volatile 在單例模式中的應用

單例模式有 8 種,而懶漢式單例雙重檢測模式中就使用到了 volatile 關鍵字。

代碼如下:

public class Singleton {
    // volatile 保證可見性和禁止指令重排序
    private static volatile Singleton singleton;

    public static Singleton getInstance() {
        // 第一次檢查
        if (singleton == null) {
          // 同步代碼塊
          synchronized(this.getClass()) {
              // 第二次檢查
              if (singleton == null) {
                    // 對象的實例化是一個非原子性操作
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

上面代碼中, new Singleton() 是一個非原子性操作,對象實例化分爲三步操作:(1)分配內存空間,(2)初始化實例,(3)返回內存地址給引用。所以,在使用構造器創建對象時,編譯器可能會進行指令重排序。假設線程 A 在執行創建對象時,(2)和(3)進行了重排序,如果線程 B 在線程 A 執行(3)時拿到了引用地址,並在第一個檢查中判斷 singleton != null了,但此時線程 B 拿到的不是一個完整的對象,在使用對象進行操作時就會出現問題。

所以,這裏使用 volatile 修飾 singleton 變量,就是爲了禁止在實例化對象時進行指令重排序。

總結

  • volatile 修飾符適用於以下場景:某個屬性被多個線程共享,其中有一個線程修改了此屬性,其他線程可以立即得到修改後的值;或者作爲狀態變量,如 flag = ture,實現輕量級同步。

  • volatile 屬性的讀寫操作都是無鎖的,它不能替代 synchronized,因爲它沒有提供原子性和互斥性。因爲無鎖,不需要花費時間在獲取鎖和釋放鎖上,所以說它是低成本的。

  • volatile 只能作用於屬性,我們用 volatile 修飾屬性,這樣編譯器就不會對這個屬性做指令重排序。

  • volatile 提供了可見性,任何一個線程對其的修改將立馬對其他線程可見。volatile 屬性不會被線程緩存,始終從主存中讀取。

  • volatile 提供了 happens-before 保證,對 volatile 變量 V 的寫入 happens-before 所有其他線程後續對 V 的讀操作。

  • volatile 可以使純賦值操作是原子的,如 boolean flag = true; falg = false

  • volatile 可以在單例雙重檢查模式中實現可見性和禁止指令重排序,從而保證安全性。

參考

happens-before 俗解:http://ifeve.com/easy-happens-before/

JMM Cookbook(一)指令重排:http://ifeve.com/jmm-cookbook-reorderings/

JMM Cookbook(二)內存屏障:http://ifeve.com/jmm-cookbook-mb/

深入理解 Java 內存模型(二)——重排序:https://www.infoq.cn/article/java-memory-model-2/

深入理解 Java 內存模型(四)——volatile:https://www.infoq.cn/article/java-memory-model-4

窺探真相:volatile 可見性實現原理:https://segmentfault.com/a/1190000020909627

因爲是個人學習筆記,難免存在一些錯誤或紕漏,也請小夥伴們指正。

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