Java併發——volatile關鍵字的核心

前言

在Java併發的話題中,volatile關鍵字一定是繞不開的話題。Java程序員都知道,volatile關鍵字的使用方式,以及它的特性:保證變量在內存中的可見性,但不保證原子性。Java的J.U.C包中volatile關鍵字可謂是基石般的存在。接下來我們便來好好的深究這個volatile關鍵字的核心。

volatile關鍵字的作用

被volatile修飾了的變量,是可以保證其在內存中的可見性的。其實在單核的操作系統下,不論是單線程還是多線程,都不存在變量的可見性問題。因此用不用volatile關鍵字都一樣。但是在當今多核處理器和程序併發的場景下,必須採取一些特殊的機制來保證程序運行的結果正確。爲了達到這個目的,程序語言本身和操作系統各自都做了不同的努力。接下來我們便來看看語言本身和操作系統到底做了什麼樣的事情?
我們以雙重校驗的單例模式開始:

public class Singleton {
    private static Singleton INSTANCE;
    private Singleton() {
    }
    public Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (this) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

雙重校驗鎖乍看之下沒有問題,但是如果瞭解Java new對象的過程的話,則一眼便能看出問題所在。
Java中new對象的具體過程:1)給對象分配內存;2)初始化對象;3)將引用指向對象所在的內存地址。以上三個步驟,1、2是具有前後依賴關係的,因此不存在指令重排序的問題,但是3和1/2步驟不存在前後依賴關係,因此操作系統有可能會進行指令重排序。如果在併發的場景下,A線程在初始化對象,此時B線程很有可能獲取到一個還沒有初始化完成的對象。
如果採用靜態內部類的方式來實現單例模式,則不會出現這個問題,因爲在類的加載過程中,JVM會通過鎖機制保證只會有一個線程對類進行加載。
如果將INSTANCE使用volatile關鍵字修飾的話也不會存在這個問題。

接下來我們就來看看指令排序到底是個啥。

指令重排序

指令重排序的前提是遵循as-if-serial語義。如果從Java語言的角度來看指令重排序的話,它是這樣的:

指令重排序
這意味着,指令重排序會發生在JVM編譯階段或者是操作系統執行階段。

編譯器優化指令重排序

在編譯階段,編譯器會適當優化指令來提升執行效率,優化手段包括語法糖的解析:例如JDK8下,字符串拼接將會重新被定爲爲StringBuilder方式,遵循as-if-serial語義也可能會進行指令重排序。

指令集並行重排序

現代操作系統爲了提升執行效率,引入了指令重疊並行執行技術。這種技術,也會存在指令重排序的情況。

指令重疊技術是計算機體系結構中的內容,詳細介紹

內存系統重排序

隨着摩爾定律的失效,現代計算機採用多核處理器的方式進一步提升執行效率。每個處理器都擁有一級緩存、二級緩存甚至三級緩存,緩存中的最小單位是緩存行。它們共用主內存,並且爲了進一步提升執行效率,引入了Store Buffers和Invalid Queue。在併發的場景下,對於數據的讀寫指令可能會存在重排序。例如CPU1讀取V變量到緩存中,並進行一系列的計算,在寫回主內存時,CPU2讀取了V變量進行一系列的計算。並先於CPU1的寫指令,這將導致CPU1的運算結果不可見。這便是內存系統的重排序,更精準的來說應該是讀寫指令的重排序。

緩存行的介紹網絡上有很多精闢的總結,這裏不再重複造輪子。

指令重排序可以提升執行效率,但同時也帶來了併發問題。而然操作系統並不知道何時應該進行指令重排序何時不應該進行指令重排序。因此開放了一些功能,讓開發者自己決定到底該不該重排序。接下來,我們將站在操作系統層面和Java語言層面來看他到底是如何禁止指令重排序的。

緩存一致性協議

操作系統爲了保證緩存中數據的一致性,制定了緩存一致性(MESI)協議。當前大多數的緩存一致性協議的實現思想都是基於“嗅探”技術。它的基本思想如下:所有內存的傳輸都是在一條共享的總線上,而所有的處理器都能看到這條總線;緩存本身是獨立的,但內存共享資源。因此所有對於內存的訪問(即:讀/寫)都要經過仲裁(即同一個指令週期中,只有一個CPU緩存可以訪問內存)。
MESI協議實際上是緩存行的4種狀態的縮寫,這四種狀態如下:

狀態 解釋
Modify 當前緩存行有效,緩存行的數據已經被修改了,修改後的數據只存在於本Cache中,和內存中的數據不一致
Exclusive 當前緩存行有效,數據只存在於本Cache中,和內存中的數據一致
Share -當前緩存行有效,數據存在於很多Cache中,和內存中的數據一致
Invalid 當前緩存行無效

對於寫數據:CPU在寫數據之前,必須先擁有獨佔權,即將緩存行狀態變成E狀態,此時其他處理器嗅探到這個變化後,會將自己的緩存行置於I狀態。
對於讀數據:CPU在讀數據時之前,其他處理器會嗅探到這個請求,如果其他處理器的緩存行存在M或E狀態,則必須回到共享狀態,如果是M狀態,則需要先寫回主內存。
下圖示意了,當一個cache line調整狀態的時候,另外一個cache line 需要調整的狀態。

M E S I
M × × ×
E × × ×
S × ×
I

緩存一致性協議的失效場景

由於引入了MESI協議,嗅探並確認狀態的過程是需要阻塞的。如果阻塞總線的話,將會導致總線不可用,將大大浪費CPU的性能,得不償失,因此實際情況並不會阻塞數據總線,而是阻塞當前這個緩存行。然而阻塞緩存行依然會帶來性能上的損耗(沒有阻塞最好…),爲了解決這個問題,引入了Store Buffers和Invalid Queue。

Store Buffers

CPU運算完畢之後,不再是直接將數據寫到緩存中,而是直接寫到Store Buffers,然後繼續做其他事情去,寫回到緩存的任務交給了Store Buffers。數據最終會寫回到緩存中,但至於具體什麼時候真正寫回,就得不到保證了。

Invalid Queue

當寫回緩存時,其他緩存會嗅探到這個動作然後立馬ack,但不會立馬讓緩存行失效,而是放入到Invalid Queue中,這個緩存行最終會失效,但至於具體什麼時候真正時效,就得不到保證了。

在引入了Store Buffers和Invalid Queue的情況下,MESI協議的實際執行情況變得更加複雜並且帶來了新的問題:
1.如果Store Buffers中有值,則CPU將直接從Store Buffers中取值,但此時的值會沒有提交;
2.可以確保數據最終會處理,但什麼時候處理得不到保證!
以上這兩個問題,導致MESI協議“失效”了
我們來看這一段僞代碼:

value = 3;
isFinish = false;

void exeToCPUA(){
  value = 10;
  isFinish = true;
}
void exeToCPUB(){
  if(isFinish){
    //value一定等於10?!
    assert value == 10;
  }
}

一開始,CPUA完成了運算,isFinish的值已經從Store Buffers中寫回緩存,然而CPUA中的valuse值坑還沒有寫回到緩存,或者寫回到了緩存,但是CPUB中還沒有真正的將其置爲valid狀態。此時的對於CPUB而言,value值不是10而是依然爲3.
這個現象看起來好像是isFinish = true先執行,而value = 10後執行。這種在可識別的行爲中發生的變化稱爲重排序

然而操作系統並沒有因爲這個問題而放棄Store Buffers和Invalid Queue的優化方案。而是基於硬件層面實現的內存屏障,來保證MESI協議的有效性。接下來我們繼續深究內存屏障。

內存屏障

在操作系統中,內存屏障有3種類型和一種僞類型:

類型 說明
讀屏障(Load Barrier) 在執行任何一條加載數據操作到緩存之前,先將失效隊列中的所有失效指令全部執行完畢!
寫屏障(Store Barrier) 在執行寫屏障指令之後的任何指令之前,先將Store Buffer中的所有存儲指令全部執行完畢!
全能屏障 同時擁有讀屏障和寫屏障的能力!
LOCK指令 (彙編語言中的指令)不是內存屏障,但是能夠完成全能屏障的功能!

以上便是內存屏障的定義,我們基於內存屏障在分析從新定義上面的僞代碼:

value = 3;
isFinish = false;

void exeToCPUA(){
  value = 10;
  store_barrier();
  isFinish = true;
}
void exeToCPUB(){
  if(isFinish){
    load_barrier();
    assert value == 10;
  }
}

我們基於內存屏障來重新分析這段代碼:
一開始,CPUA完成了運算,那麼在回寫isFinish時,由於寫屏障,必須先清空Store Buffer,此時value的值將會回寫;於此同時,CPUB接收到value值的更新,會立即ack,並將本緩存中的value放入到Invalid Queue;
CPUB在獲取到isFinish = true的情況下(有可能獲取到的依然是false),在讀取value值之前,由於讀屏障,此時會將Invalid Queue中的失效指令全部執行完畢,再去讀取value,而這個時候,value值是失效狀態,將去主內存讀取值。

通過內存屏障,可以保證MESI協議的有效性,達到“禁止指令重排序”的目的。

再談Java中的valatile關鍵字

以上僞代碼,在Java中的其實是長這樣的:

private volatile int value = 3;
private boolean isFinish = false;

public void exeToCPUA() {
     value = 10;
     isFinish = true;
}

public void exeToCPUB() {
    if(isFinish) {
        assert value == 10;
    }
}

我們可以看到通過volatile關鍵字便可以禁止指令重排序,從而保證了變量在內存當中的可見性。
那麼volatile關鍵字的背後是內存屏障嗎?答案:不是
此時已然採用老套路,反編譯看一下:

public com.leon.util.Test();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_3
       6: putfield      #2                  // Field value:I
       9: aload_0
      10: iconst_0
      11: putfield      #3                  // Field isFinish:Z
      14: return
      ......

通過反編譯,看不出個啥。此時只能將class文件進一步反編譯成彙編指令來觀察。
由於彙編結果太多了,就不再貼出。我們只需知道,在操作value指令之前,都有一個lock指令前綴!
volatile關鍵字其實是通過彙編指令中的LOCK指令前綴來完成內存屏障的效果! 我們介紹到LOCK指令不是內存屏障,但是能夠完成全能屏障的功能。


到這裏,真想了然!

通過以上的介紹,我們知道了指令重排序的場景,MESI協議以及其有效性保證的實現機制,進而引出內存屏障的知識,最後我們終於知道了volatile關鍵字背後的核心是啥;爲什麼volatile關鍵字能夠保證變量的可見性,可以禁止系統層面的指令重排序(當然也能禁止Java編譯器層面的指令重排序)。

後記

寫到這裏,越來越覺得像Java這樣的高級語言,屏蔽了系統底層的知識和結構,讓開發人員專注於功能實現,實在是了不起。但同時又覺得,由於現在人心浮躁,大多數人們只想看到自己想看到的;只能看到自己能看到的,不願意知其所以然,未免有點悲哀~
我在寫這篇文章的時候,查閱了大量的文獻和網絡上十幾篇高讚的回答,最後綜合自己的見解,將這些知識儘可能的關聯起來,感謝這些巨人給我肩膀~

公衆號

【參考文獻】

  1. 《Java併發編程的藝術》
  2. ifive.com 併發系列
  3. 併發原理之MESI與內存屏障
  4. CPU緩存一致性協議MESI
  5. volatile與lock前綴指令
  6. 關於volatile、MESI、內存屏障、#Lock
  7. 彙編語言中的lock指令
  8. 操作系統與架構指令重疊與流水線技術
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章