1. 程式人生 > 實用技巧 >理解volatile關鍵字

理解volatile關鍵字

前言

在多執行緒併發程式設計中volatile扮演者重要的角色,它是輕量級的synchronized,在多處理器中保證了共享變數的可見性,執行成本更低,因為它不會引起執行緒的上下文切換和呼叫,簡單來說就是多執行緒對共享變數的修改能讓其他執行緒立即知曉而不需要花費執行緒切換的相關成本,這一切都由一個叫做記憶體模型的東西來控制,接下來涉及到的內容可能較為羞澀難懂,或者理解不到位,加油!

重要概念

接下來介紹較為常見的一些概念。

快取一致性

計算機在執行程式時,每條指令都是在CPU中執行的,指令執行的過程中難免會涉及到資料的讀寫,而資料是存放在主記憶體中(RAM),主記憶體的特點是速度慢、容量大,這就造成了每次與主記憶體互動時都會導致速度變慢,降低執行效率,所以在CPU中加入了快取記憶體,該快取記憶體實際上是少量的、速度非常快的記憶體,目的就是為了加快對資料的訪問及操作。也就是說,執行程式時會將主記憶體的資料拷貝到快取記憶體中,CPU直接拿快取記憶體的資料進行計算並存儲計算後的結果,最後在將結果更新到主記憶體中,只是不知道何時會寫到主記憶體中。單核(單個CPU)中該形式並無問題,可到了多核(多CPU)時就會出現異常,也就是在多執行緒情況下,每條執行緒可能運行於不同的CPU中,因此每個執行緒在執行時有屬於自己的快取記憶體,這就有可能造成資料的不一致性,以下通過例子來更好的說明問題:

i = i + 1;

在多執行緒情況下,兩個執行緒分別將主記憶體中i變數的值拷貝到各自的CPU快取記憶體中,執行緒1執行程式後,將結果寫回到主記憶體中,而此時執行緒2所在的CPU快取記憶體的i變數仍然是初始值,執行程式後依然寫回主記憶體中,可見這是造成資料不一致的根本原因,即擁有多個快取記憶體!既然如此,何不多個CPU共享同一個快取記憶體呢,很明顯這會降低效率,所以為了能讓多個快取記憶體的內容保持一致,就定義了快取一致性協議 - MESI協議,也就是說所有的快取記憶體都必須遵守該規定。簡單描述下該協議的思想,與主記憶體進行傳輸是通過一條共享的匯流排,簡單來說,每個快取記憶體都連著一條共享的匯流排,也就是說快取記憶體可以窺探

總線上發生的資料交換,跟蹤其他快取記憶體在做什麼,所以當其中一個快取記憶體所屬的CPU去讀寫主記憶體時,其他CPU便會知道,它們以此來使自己的快取保持同步,只要某個CPU一寫記憶體,其他CPU馬上就知道它們自己的快取記憶體中對應的段已經失效了,下圖所示是CPU與主記憶體的關係。

Java記憶體模型

為了保證多執行緒下共享變數的正確性,記憶體模型定義了對共享變數讀寫操作的規範,通過多條規則來保證指令執行的正確性,而Java記憶體模型正是基於此規範,遮蔽了各種硬體和作業系統的差異性,確保Java程式在不同的平臺下始終提供一致的可見性保證。在Java中,所有例項域、靜態域和陣列元素都儲存在堆記憶體中,堆記憶體線上程之間共享。區域性變數、形參(方法入參)和異常處理器引數不會線上程之間共享,它們不會有可見性問題,所以並不會受記憶體模型的影響。Java執行緒之間的通訊由Java記憶體模型控制,其決定一個執行緒對共享變數的寫入何時對另外一個執行緒可見。

從抽象的角度來看,Java記憶體模型定義了執行緒和主記憶體之間的抽象關係:執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的本地記憶體,本地記憶體中儲存了共享變數的副本,本地記憶體是Java記憶體模型的一個抽象概念,並不真實存在。

從圖上可知,Java記憶體模型控制主記憶體與每個執行緒的本地記憶體之間的互動來提供可見性保證。

重排序

在執行程式時,為了提高效能,編譯器與處理器常常會對指令做重排序,分為以下幾種:

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

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

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

其中第一項屬於編譯器重排序,第二三項屬於處理器重排序。重排序可能會導致多執行緒程式出現可見性問題,所以Java記憶體模型規定了禁止特定型別的編譯器重排序,而對於處理器重排序,Java記憶體模型則是要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障指令來禁止特定型別的處理器重排序(所謂的記憶體屏障就是一組處理器指令),簡單來說凡是重排序會造成問題的一律被禁止。

happens-before

Java記憶體模型使用happens-before的概念來闡述操作之間的可見性,Java記憶體模型規定如果一個操作執行的結果需要對另外一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。這裡提到的兩個操作既可以是在一個執行緒內,也可以是在不同執行緒之間。以下是Java記憶體模型規定的happens-before原則:

  • 程式順序法則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作,即前者的執行結果對後者可見。其實在兩個互不依賴的操作下是允許重排序的,Java記憶體模型認為其合法,所以該條法則實際上並不嚴謹

  • 監視器法則:對一個鎖的解鎖,happens-before於後續對這個鎖的加鎖,即某一個執行緒解鎖後其他執行緒都能知道

  • volatile變數法則:對volatile變數的寫入happens-before於後續對這個volatile變數的讀,即對volatile變數是先寫後讀

  • 執行緒啟動法則:如果執行緒A執行ThreadB.start操作,那麼A執行緒的ThreadB.start操作happens-before於執行緒B中的任意操作(感覺說的不夠準確)

  • 執行緒終結法則:如果執行緒A執行ThreadB.join操作併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join操作成功返回,即執行緒B中執行結果在A執行緒中能夠看到

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

happens-before的前後兩個操作不會被重排序且後者對前者的執行結果是可見的,也就是說操作可以在不違反happens-before規則下進行重排序。當然了,仍然還是會出現可見性問題,比如說多執行緒下普通讀寫與volatile變數寫,針對這種情況可以禁止重排序,而對於多執行緒下多個普通變數讀寫沒辦法使用禁止重排序來解決問題,畢竟這是常見的程式碼操作,要是禁止了那豈不是撿了芝麻丟了西瓜,這也是我們在多執行緒下常出現的問題,這種情況下就應該為變數加上volatile關鍵字。

as-if-serial

不管怎麼重排序,單執行緒下程式的執行結果不能被改變,編譯器和處理器都必須遵守as-if-serial。該語義較為清晰明瞭就不做展開介紹了。

其他概念

  • 原子性:原子操作是不能被執行緒排程機制中斷的操作,也就是說一旦操作開始,那麼它一定可以在可能發生上下文切換(切換到其他執行緒)之前執行完畢,對基本資料型別的變數的讀取和直接賦值操作(直接賦值資料)就是原子性操作,而間接賦值操作(變數對變數之間的賦值)並不是原子性操作,還有J.U.C.Atomic包下的原子類同樣也屬於原子性操作。

    public class Test {

        private int i;

        public void f1() {
            i = 0;
        }

        public void f2() {
            i++;
        }
    }

    // 將上面的程式碼反編譯後:javap -c Test ---->

    /**
     *  public void f1();
     *    Code:
     *      0: aload_0       --> this引數,每個方法都會有這麼一個,虛擬機器在執行的過程中會隱式傳入該引數,這也是我們為什麼可以再非靜態方法中使用this關鍵字
     *      1: iconst_0      --> int型別常量0進棧
     *      2: putfield      #2                  // Field i:I   --> 給物件的欄位賦值,即 i = 0
     *      5: return
     *
     *  public void f2();
     *    Code:
     *      0: aload_0       --> this引數
     *      1: dup           --> 複製0序號中的引用並壓入棧中
     *      2: getfield      #2                  // Field i:I   --> 獲取物件的欄位,將其值壓入棧頂
     *      5: iconst_1      --> int型別常量1進棧
     *      6: iadd          --> 將棧中的前兩個值進行相加,即 getfield + iconst_1:0 + 1
     *      7: putfield      #2                  // Field i:I   --> 給物件的欄位賦值,即 i = 1
     *      10: return
     *
     *
     * 仔細觀察f1與f2反編譯後的結果可知,對於 i = 0 只用了一條指令(putfield)才操作,而對於 i++ 用了(getfield、putfield)指令,中間還參雜了其他的指令,這就有可能在執行這些指令時進行了上下文切換導致該值被修改
     * 從而造成錯誤性結果,所以我們可以知道對於賦值語句是原子操作,而遞增語句是非原子操作
     */

從上面的結果可知直接賦值語句是原子性操作,而遞增語句是非原子性操作。

  • 可見性:當一個執行緒修改一個共享變數時,另外一個執行緒能讀到這個修改的值。

volatile原理

說到原理,還是要明白volatile在記憶體中到底起了什麼作用,官方叫做記憶體語義。

volatile寫:當寫一個volatile變數時,Java記憶體模型會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體

voaltile讀:當讀一個volatile變數時,Java記憶體模型會把該執行緒對應的本地記憶體置為無效,接下來將從主記憶體中讀取共享變數

更為詳細地說,使用volatile修飾的變數在彙編層面上加了Lock字首的指令,該指令會引起快取記憶體中的資料回寫到主記憶體,在搭配上快取一致性機制來導致其他CPU的快取記憶體無效,當CPU對該變數進行操作時會重新從主記憶體中把資料讀到快取記憶體中。

volatile特性

  • 可見性:對一個volatile變數的讀,總是能看到任意執行緒對該變數的寫入

  • 原子性:對任意單個volatile變數的讀/寫具有原子性,但類似volatile++複合操作不具有原子性

  • 重排序:特定情況下禁止重排序,即對多個volatile變數的讀/寫操作不能進行重排序

以下是Java記憶體模型為了實現volatile記憶體語義而定義的重排序規則表:

是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫 NO
volatile讀 NO NO NO
volatile寫 NO NO

表格中NO自然是表示禁止重排序,而對於空白處的前提是要遵守Java記憶體模型定義的happens-before/as-if-serial規定後方可重排序,當然了,在重排序的情況仍然可能會出現可見性問題(最常見的就是普通變數之間的操作),這種時候可以使用volatile關鍵字。前文我們提到禁止重排序實際上就是在指令序列中插入記憶體屏障,對於任意處理器平臺來說會根據自身處理器的記憶體模型繼續優化,也就是說雖然Java記憶體模型針對volatile關鍵字定義了插入記憶體屏障的策略,但到了不同的處理器可能會由於其處理器的特性而被省略,比如Java記憶體模型幫你加上了3個記憶體屏障,實際上到了處理器層面就只需要2個。

結束語

以上大部分的知識點屬於理論型,參考了相關書籍與文章,筆者只是對其概念與介紹做了一個整理與總結,若有錯誤請見諒。關於volatile關鍵字的理解說不上很難,只是涉及到的點很多很雜,個人認為最重要的還是懂得其原理,其他概念性的理論有個印象即可。

參考資料

《Java程式設計思想》

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

https://www.cnblogs.com/dolphin0520/p/3920373.html