【轉載】Java併發面試系列:徹底掌握 volatile 關鍵字原理

【轉載】Java併發面試系列:徹底掌握 volatile 關鍵字原理

什麼是 volatile

volatile 是 Java 中的一種輕量級同步機制的關鍵字,當一個變量被 volatile 修飾後,有兩層含義:

  • 保證了該變量的修改對所有線程可見
  • 禁止指令重排序優化

另外,volatile 不保證原子性。

volatile 標準使用方法如下:

//定義共享變量爲 volatile
private volatile boolean flag = false;

//其他程序根據 flag 的值作業務操作
while (flag){
    //執行業務邏輯
}

volatile 是 Java 中的一個關鍵,用於修飾共享變量,它是 Java 語言提供的一種稍弱的同步機制。它能確保將變量的更新操作通知到其它線程。

其典型的用法如上所示,即用來檢查某個狀態標識的值,這個狀態標識必須聲明爲 volatile 的,否則該變量被其它線程修改時,執行判斷的線程卻發現不了該修改(可見性問題)。

之所以說“輕量級同步”或“稍弱的同步”這是相對於 synchronized 關鍵字加鎖而言的,它的使用不會引起上線下文切換,因此在一些特定的場景,相比於 synchronized 關鍵字,使用 volatile 可以獲得更好的性能。

img

什麼是線程可見性問題

volatile 可以保證線程可見性,那麼什麼是線程可見性呢?

線程可見性是指一個線程對共享變量值的修改,其它線程能否及時的看到被更改後的值。

線程可見性的問題可先看下面這個例子,標識位 flag 是多個線程共享的,開啓一個線 t1,不斷輪詢該標識位,這時另一個線程 t2(代碼中主線程)修改了這個標識位:

public class VolatileDemo {

    public static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            //不斷循環標識,標識位爲 true 則 return
            while (flag) {
            }
            System.out.println("線程 t1 退出");
        });
        t1.start();

        //主線程等 5s
        Thread.sleep(3000);
        flag = false;
        System.out.println("flag 已更改");
    }
}

你認爲 3s 後的輸出結果是什麼,會打印“線程 t1 退出”進而線程結束嗎? 真正的答案是線程會一直輪循死循環,並不會結束!

這裏第一眼看上去絕對是有違常理的,明明值 flag 的值被修改了,爲什麼循環沒有結束。其實這也是內存可見性問題,也就是說線程 t2(主線程)對 flag 的修改對線程 t1“不可見”,線程 t1 拿到的 flag 值仍然是 true,因此發生了下面這種情況:

img

想要探究可見性產生的原因,就會牽扯到其它很多的問題,從最上層來說,Java 內存模型的定義會導致可見性問題,但這不是根本原因,可見性是更底層的 CPU 架構緩存原因導致,只不過 Java 內存模型允許可見性的存在,Java 內存模型只是抽象出來的規範,它的規範中並不保證一個線程對普通變量的更改後另一個線程立即可見,但它的規範中 volatile 是可以實現這種功能的。因此必須先要必須把 CPU 緩存體系、緩存一致性及 Java 內存模型等了解清楚。

img

CPU 緩存體系

在計算機中,CPU 通過總線(bus)從主內存中讀取數據,通常用內部時鐘速度來描述 CPU 可以多快的執行操作,這個可以看作爲 CPU 內執行的速度:即處理器的處理能力,但當 CPU 與計算機其他組件通信就比較慢了,這稱之爲外部時鐘速度,當內部時鐘速度大於外部時鐘速度時,意味着 CPU 要等待,而從主內存中讀取數據就是這樣,當然,要使其它組件運行能跟得上 CPU 速度,代價非常昂貴。

爲了彌補 CPU 與主內存處理能力之間的差距,大多數現代 CPU 採用在主內存和處理器之間引入了高速緩存(cache memory),並且高速緩存是分級的,一級緩存 L1 和二級緩存 L2 屬於每個核,三級緩存 L3 爲核共享的,當然,越靠近 CPU 其製作成本越高,因此 L3、L2、L1 的容量逐次遞減,但訪問速度卻是遞增的。CPU 緩存體系如下圖:

img

對於上面的多核結構,對於操作系統而言,就將相當於一個個獨立的邏輯 CPU,因此就緩存這塊而言,4 個核也可以理解爲 4 個獨立的 CPU,爲了簡單起見,之後簡化爲“CPU 下的高速緩存”。

下面是電腦的一個真實配置信息,可以看到 L2、L3 緩存的大小,由於 L1 集成在 CPU 中,因此並沒有顯示。

img

有了高速緩存後,在執行內存讀寫操作時並不直接與主內存交互,而是通過高速緩存進行,當讀寫數據時,都會去緩存中找一下,若緩存中的數據可用,則直接操作緩存,若找不到或緩存中的數據不可用,會將所需要的數據從主內存中加載並放入響應的緩存中,也就是高速緩存中會保存主內存部分數據的副本,這顯著的緩解了處理器瓶頸的問題。

下面是處理器訪問各部件的速度:

img

在寫數據的時候一般採用 Write-back 的策略,是說寫 cache 後,爲了提高性能,並不立即寫主內存而是等待一段時間將更新的值批量同步到主內存(還有一種方式是每次修改完都將數據同步到主內存,稱之爲 Write-Through,很少使用)。

img

緩存一致性協議

通常一個部件的引入可以解決一些問題,但同樣會帶來新的問題,在引入高速緩存後,並且處理器的高速緩存都各自保留主內存中數據的副本,若某一個處理器將數據更新後,其它處理器必須要感知到,並且將更新的新值加載到緩存中,否則就會出現數據不一致的情況。緩存一致性協議就是爲了解決這個問題。

最常見的緩存一致性協議是 MESI 協議,這種協議通過狀態的流轉來維護緩存間的一致性,並且它針對讀取同一個地址的變量操作是併發,但更新一個地址的寫操作是獨佔的,因此同一個變量的寫操作在任意時刻只能由一個處理器執行,它把緩存中的緩存行(高速緩存與主內存交互的最小單元,類似於 MySQL,加載一條數據會把一塊的數據都加載出來)分爲 4 個狀態(M、E、S、I)來保障數據一致性。

img

MESI 通過定義一組消息用於協調各個處理器的讀寫內存操作,同時根據消息的內容會在上述 4 種狀態間流轉,CPU 在執行內存讀寫操作時通過總線發送特定的消息,同時其它處理器還會嗅探總線中由其它處理器發送的請求消息並會進行相應的回覆,具體如下:

img

爲了更好地理解 MESI,這裏簡述下一個處理器讀取和寫入數據的實現流程。假設有 processor1、processor2 兩個處理器。

processor1 讀取某個數據 V 會存在兩種情況:

  • 如果 processor1 的緩存中存在數據 V 的緩存行,且狀態爲 E/S/M 的一種,則直接讀取緩存數據,不會向總線發消息,因爲處於這三種狀態時意味着都是最新數據。
  • 另外一種情況是存在緩存但狀態是 I,這種和緩存不存在是一回事,處理邏輯也相同,即向總線發送 Read 消息。

processor1 發送了 Read 消息後,該消息的回覆可能來自 processor2,也可能來自主內存,這取決於 processor2 中是否存在數據 V 的緩存行,因此,當 processor2 嗅探到總線的 Read 消息後,這裏又會有兩種情況:

  • 如果 processor2 中存在 V 的緩存行但狀態爲 I 或不存在 V 的緩存行,此時響應消息將來自主內存,也就是從主內存中加載數據 V 到 processor1 的緩存中。
  • 如果 processor2 存在 V 的緩存行且狀態爲不爲 I,說明 processor2 存在數據 V 的副本,此時 processor2 會構造消息進行回覆,這裏有種特殊情況,即 processor2 中 V 的緩存狀態是 M,說明數據修改過,此時可能會將數據同步回主內存再回復 processor1 的 Read 請求,最後將狀態置爲 S(因爲已有其它處理器共享了)。

總之 processor1 能拿到正確的值,因此修改過的數據採取前文所說的 Write-back 的策略異步的回寫主內存,也不會存在不一致的情況。在 processor1 收到消息後將根據消息的來源將狀態置爲 E 或 S。

processor1 要寫數據 V 會有三種情況:

  • 如果數據 V 在 processor1 的緩存中且狀態爲 E 或者 M,說明現在是獨佔狀態,則會直接將數據寫入緩存中,狀態更新爲 M,不會發送消息。
  • 如果數據 V 在 processor1 的緩存中且狀態爲 S,說明 processor2 中緩存了該數據,因此 processor1 往總線發 Invalidate 消息,processor2 將數據所在的緩存置爲無效 I 狀態後回覆 Invalidate Acknowledge 消息,processor1 收到後將狀態設爲獨佔 E,接着再更新緩存的值後將狀態置爲 M
  • 如果數據 V 在 processor1 的緩存中且狀態爲 I 或不在緩存中,則需要發起 Read Invalidate 消息,在收到 Read Response 和 Invalidate Acknowledge 消息將狀態設置爲獨佔 E,再更新緩存後設置爲 M。

注:由於消息都是經過總線,因此當有多個 Invalidate 消息時,衝突可以由總線來解決,即保證一個數據在同一時刻只能由一個處理器更新。

至此 CPU 緩存體系和緩存一致性已經清楚了,只要將數據寫入高速緩存就會得到緩存一致性保證,如果是這樣來看的話,MESI 完全可以保證一個線程對共享變量的修改對其它處理器可見。

img

寫緩衝器和無效化隊列

引入高速緩存解決了“處理器高速處理能力不能充分發揮”的問題,但卻帶來了多個緩存需要同步的問題,而緩存一致性協議解決這個問題,但卻帶來了新的問題。

在處理器寫數據的時候,需要等待其它處理器將數據的緩存行設置爲無效然後回覆 Invalidate Acknowledge 消息後才能進行安全操作,這個過程可能會耗費較多時間,爲了優化這一過程,又引入了寫緩衝器 Store Buffer 和無效化隊列 Invalidate Queue 這兩個硬件緩衝區。

引入 Store Buffer 後,寫數據時一方面發送 Invalidate 消息給其它處理器,同時將要寫的數據放入 Store Buffer 中,此時 CPU 可以執行其它指令,等到所有處理回覆 Invalidate Acknowledge 消息後,再將數據寫入高速緩存。由此可見引入寫緩衝器後將不再等待 Invalidate Acknowledge 消息,這樣寫可以看作是異步行爲,這也減少了寫操作的延遲。

img

Store Buffer 通常容量很小,多個寫操作的結果到來後 Store Buffer 很容被填滿,如果其它處理器不能儘快的回覆 Invalidate Acknowledge 消息的話,就會有性能問題,因此引入 Invalidate Queue,當處理器接收到其它處理器發來的 Invalidate 消息後,並不立即無效化本地副本,而把消息放入 Invalidate Queue 後立即返回 Invalidate Acknowledge 消息,等處理器在合適的時間在處理 Invalidate Queue,這也相當於異步操作,同樣減少了寫操作的延遲。

當引入 Store Buffer 後,讀數據也要經過 Store Buffer,因爲考慮這樣一個場景,一個處理器剛寫了某個數據到 Store Buffer 還未刷到主內存,這時又要使用這個數據,那一定不能去緩存中拿,因爲現在緩存的是舊值,最新的值在 Store Buffer 中,只有取到最新的值程序運行纔不會出錯,如下面的程序:

    a = 10;
    if(a==10) {
        //執行業務邏輯
    }

即先寫入一個值馬上使用,因此讀取也要經過 Store Buffer,這種直接處理器直接從 Store Buffer 中獲取數據被稱之爲存儲轉發(Store Forwarding)。

因此緩存的佈局及操作可以抽象爲下面這樣:

img

img

可見性問題產生的根本原因

從上面引入的兩個硬件緩衝區中可以看到,將發送消息 --> 等待回覆 --> 寫數據這個步驟都變成異步的了。這樣雖然在性能上有很大優勢,但無疑增加了複雜性。而 Store Buffer 正是導致可見性的硬件根源。之所以說是根源,是因爲無效化隊列、存儲轉發可以說都是因爲引入 Store Buffer 而產生的。說 Store Buffer 是根源,具體來說:

首先,一個處理器寫數據的時候,先會寫到 Store Buffer 中,此時還沒有寫到高速緩存,自然不能利用緩存一致性使其它處理器周知,因此其它處理器持有的仍然是舊值,這就產生了可見性問題。

其次,當發送 Invalidate 消息到其它處理器時,由於消息無效化隊列的存在,使得一個處理器會將消息暫放入無效化隊列而立即回覆 Invalidate Acknowledge 消息,此時處理器持有的仍然是舊值,只有將無效化隊列中的消息處理完後纔會將舊值置爲 Invalidate。

最後,是存儲轉發,如果從在寫入數據到 Store Buffer 後尚未同步到高速緩存,這時其它處理器修改了該數據,那麼此時的處理器從 Store Buffer 中獲取的仍然是舊值,這就也會產生可見性問題。

如果沒有 Store Buffer,按照之前寫數據直接操作高速緩存,緩存一致性協議保證一致性,所有操作都是按部就班同步處理,但引入 Store Buffer 後的這種異步處理方式,可見問題複雜了很多。

img

什麼是重排序及重排序出現的原因

重排序是指程序運行中,編譯器或處理器對指令或內存操作順序做了調整,這是編譯器或處理器爲了提高性能而做的優化。重排序直觀的理解是代碼的書寫順序與實際執行的順序不同,而實際上這裏的重排序要更寬泛一些,比如一個處理器按順序進行了一系列操作,但由於可見性的一些問題,另一個處理觀察到的執行順序可能和目標代碼指定的順序不一致,這種也稱之爲重排序。事實上,上面說的 Store Buffer 的延遲寫入也是重排序的一種,稱之爲內存重排序(Memory Ordering),因爲它沒有按照既有的執行順序,將數據同步回高速緩存進而同步到主內存。包括內存重排序在內,共有三種類型重排序:

  • 編譯器重排序:編譯器在不改變單線程程序語義的前提下,對於沒有先後依賴的語句,編譯器可能會直接調整語句的執行順序。
  • CPU 指令重排序:具體到指令級別,現代的處理器對沒有依賴的關係的指令可以並行執行,這也稱之爲指令並行技術。
  • 內存重排序:如上介紹的指令執行的順序和寫入內存的順序不完全一致,這也是可見性問題的主要根源, 也是最難理解的。

編譯器重排序

編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。下面圖中展示了一個 Java 代碼從編譯到運行的過程:

img

編譯器(包括靜態和動態)可能對代碼順序進行調整,比如在可見性一節舉的例子中:

    //不斷循環標識,標識位爲 true 則 return
    while (flag) {
    }

這裏 JIT 編譯器會認爲變量 flag 只有一個線程訪問,爲了避免重複讀取 flag 變量而把其優化成這樣下面:

    //不斷循環標識,標識位爲 true 則 return
    if(flag){
        while (true) {
        }
    }

這種優化導致了死循環,這也算是一種重排序,同時也導致了可見性問題。因此重排序的第一種,編譯器重排也是導致可見性的一個原因。

還有一種編譯器重排的典型例子是對象的創建過程:

class Test{
    int a = 1;
}
Test t= new Test()

Test t= new Test() 在實際的創建對象過程中會有以三個步驟:

  1. 分配一塊內存
  2. 在內存上初始化成員變量
  3. 把 t 引用指向內存

在這三個操作中,操作 2 和操作 3 在 JIT 編譯器動態生成彙編碼時,可能會被重排序,即先把 t 指向內存,再初始化成員變量等操作,因爲二者並沒有先後的依賴關係。此時,另外一個線程可能拿到一個未完全初始化好的對象,也就是說這個對象被部分構造了,這時使用該對象時就可能出錯。

CPU 指令重排序

以下是一個處理器亂序執行的直觀例子,指令 1 是一個讀取內存數據的耗時指令操作,如果此時指令 2 要做的事情不依賴指令 1,那它完全可以優先來執行:

img

以上兩種都是對指令的順序進行了重排優化,而接下來的內存重排序卻不是這樣。

內存重拍序

內存重排序並不像指令重排序那樣,是真的將指令順序進行了調整,它是因爲引入了寫緩衝器,無效化隊列這些硬件緩衝區,而導致內存操作的結果與按照指令的執行順序看來不一致,它是一種現象而非動作,即一個線程按照指令執行,另一個線程感知到內存中一些值就像其讀寫指令被調整過一樣,通過以下一個實際而又簡單的例子來解釋:

//線程 t1:
result = showDown();  //(1)
hasShutdown = true;   //(2)
//線程 t2
if (hasShutdown) {     //(3)
    result.relase();   //(4)
}

這兩段代碼相信大家在日常開發中看到或用到過,即對一個操作的結果設置標識位,另一個線程根據標識位繼續處理,上面的例子中是線程 t1 先調用 showDown() 關閉得到結果 result,然後將關閉標識位置爲 true,線程 t2 根據標識位的結果再對 result 做操作。將上面的代碼再簡化一個下:

//線程 t1:
result = 1;           //(1)
hasShutdown = true;   //(2)
//線程 t2
if (hasShutdown) {                 //(3)
    System.out.println(result);;   //(4)
}

給人的第一直覺是程序這樣沒問題,如果 hasShutdown=true,那麼打印出來的 result 一定是 1。然而事實上是可能不是 1,即線程 t1 在指令沒有發生任何重排的情況下,線程 t2 得到了與 t1 指令不一致的的結果,這就是內存重排序導致的。result 和 hasShutdown 賦值指令雖然沒有發生變化,這兩個賦值結果先被依次寫入到寫緩衝器 Store Buffer 中,但有些處理器是不保證將 Store Buffer 結果先入先出的寫入高速緩存,也就是說 hasShutdown 可能先被寫到高速緩存,這樣其它線程就先感知到了 hasShutdown 的值,即 result 被重排到了 hasShutdown 之後,在其它線程看來就是重排序了,這樣其它線程的處理邏輯就會和預期的有出入。

如果把引入寫緩衝器和無效化隊列這些組件所導致的問題稱之爲內存重排序的話,那也可以這樣說:內存重排序是導致可見性的主要原因,再加上指令優化導致可見性問題,因此“重排序導致可見性”這種說法,也是正確的,如果完全禁止了重排序,自然不存在可見性的問題了。

綜上程序執行過程中可能經過的重排序如下:

img

img

as-if-serial 和 happens-before

在瞭解了可見性、重排序這些出現的原因及可能導致出現問題後,似乎打破了以往編程中的一些思維,即感覺實際上我們在日常開發中的一些簡單的場景時也沒有出現過問題。當然,如果隨便地進行重排序那程序的執行一定會出問題,因此重排序也是有限制,這裏的限制主要分成兩部分。

單線程 as-if-serial 語義

首選規定重排序要滿足 as-if-serial 語義,即“貌似串行”語義,這主要針對單線程環境執行來說的,是說不論如何重排序單線程程序的執行結果不能改變,因此代碼看起來是串行執行。

拋開虛擬機規範來說,這條語義其實適用於任何的語言,如果一種語言在單線程內因發生重排導致每次執行的結果不一致,那這門語言在很多場景都是不可用的,因爲我們的代碼是有邏輯的,有邏輯就一定要有順序性。因此由於 as-if-serial 語義的存在,從編譯器到處理器都會遵循一些規則(如有依賴關係的不能重排)來保證 as-if-serial,即在單線程中,可以重排,但不管怎麼重排,最終運行結果不能影響程序的正確性。當然,這會給人一種假象-單線程程序都是按代碼順序執行,這也是貌似串行語義中“貌似”的含義。這個不需要開發者來操心,開發者完成感知不到,從編譯器到處理器都會實現 as-if-serial 語義,因此我們在單線程中不用考慮重排序對程序的影響,但多線程就要複雜些。

多線程環境的 happens-before

對於多線程相互讀取複雜的情況,就需要更上層來規定,因爲線程之間的數據依賴太複雜,編譯器和處理器無法正確的做出合理優化,也就是不同語言要定義自己的內存模型和規則,由上層來告訴編譯器和處理器在什麼場景下可以重排、什麼場景下不可以重排,因此不同的語言在單線程的執行結果一般都相同,但在多線程下的表現結果可能是完全不同的。

Java 的內存模型中(JMM 稍後介紹),通過 happens-before 原則(先行發生原則)來規定哪些場景不能重排,也就是用來闡述多線程操作間的可見性。在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那這兩個操作之間必須要存在 happens-before 關係,也就說兩個操作間具有 happens-before 關係,則保證前一個操作的執行結果對後一個操作可見。JMM 作了這個規範後,當兩個操作存在 happens-before 關係時,並不會要求在編譯器和處理器角度對指令按照 happens-before 關係指定的順序執行,如果重排序的後的執行結果和按照 happens-before 關係執行後的結果一致,那重排也是允許的。因此 happens-before 被看作是對開發者的承諾,即程序員對於這兩個操作是否真的被重排序並不關心,程序員關心的是程序的執行結果要有確定性,無論怎麼重排,最後結果和所承諾的必須一致,從這一點上來看,happens-before 本質和 as-if-serial 語義是一樣的,只是前者對多線程環境在某些情況下執行結果不能因重排序而改變。

那 happens-before 到底做了哪些規定?

首先 happens-before 的基本原則,如果操作 A happens-before 操作 B,即 A 先行發生於操作 B,那 A 的執行結果必須對 B 可見。基於這個原則, JMM 定義了很多先行發生關係,這裏只分析其中的比較重要的幾條:

  • 可傳遞性,若操作 A happens-before 操作 B,同時 B happens-before C,則 A 的執行結果也一定對 C 可見。
  • 一個線程中的每個操作 happens-before 於該線程中的任意後續操作,但多個線程沒有這種關係。
  • ……

根據 happens-before 基本原則再結合上述規定,可得出結論單線程中前面的代碼執行結果對後面的可見。

int x;
int y;
以下操作在線程 1 執行
x=10; //A
y=20; //B
z=x*y;//C

如上示例,根據第一個條規定,線程 1 的前一個操作 happens-before 於後續操作,即:

  • A happens-before 於 B
  • B happens-before 於 C
  • A happens-before 於 C(根據傳遞性)

因爲 A happens-before B,所以 A 的結果對 B 操作可見,但 A B 仍然可以重排,因爲雖然對 B 可見,但 B 並不依賴於 A,A happens-before C,所以 A 的結果對 C 操作一定可見,也就是 C 中的 x 值一定是 10,如果此時發生重排導致 A 重排到 C 後,那值就不是 10,也就不滿足 A 的結果對 C 操作一定可見,因此這兩個不能重排。

再看下面的多線程的場景下,若操作 D 在線程 2 中,且出現在操作 B 和操作 C 之間,那麼 z 的值是不確定的,即 1 和 200 都有可能,因爲操作 D 與 C 並沒有先行發生關係,所以線程 2 對 z 的影響可能不會被線程 1 觀察到。

int x;
int y;
以下操作在線程 1 執行
x=10; //A
y=20; //B
z=x*y;//C

以下操作在線程 2 執行線程
z = 1;//D

另外一條規定是針對 volatile 變量的規則:volatile 變量的寫入 happens-before 對應後續該變量的讀取。

從這裏我們可以看出 JMM 規定 volatile 的變量可以打破內存可見性的問題,也就是 volatile 變量不能重排序。當然,JMM 本身只是規範,要做到這些規範都要要靠具體的虛擬機去實現。

img

JMM - Java 內存模型怎麼理解

JMM,即 Java 內存模型,是控制 Java 線程之間通信的,它描述的是一組規定或者說是規範,因此它是一個抽象模型概念,用來屏蔽掉 Java 程序在各種不同的硬件和操作系統對內存的訪問的差異。所屏蔽的正是高速緩存、寫緩衝器這些。因此,如果直接理解 Java 內存模型其實是非常簡單的,它把底層的信息都屏蔽掉了,給開發者呈現的是一個簡單的內存模型。

JMM 定義了線程和主內存的抽象關係如下,首先所有的變量都存儲在主內存中,這是所有線程共享的內存區域,其次每條線程有自己的工作內存用於存儲線程私有數據,線程對變量的所有操作(讀取、賦值等)都必須在工作內存中進行,而不能直接讀寫主內存中的變量,所以要將變量從主內存拷貝的自己的工作內存空間,也就是生成變量的副本,然後再對變量進行操作,操作完成後再在合適的時機將變量寫回主內存,如下圖所示:

img

如果抽象成這樣,那理解起來比具有高速緩存、緩存一致性、寫緩衝器的真是內存模型要簡單的多。比如針對可見性問題,就會非常好理解,假如兩個線程同時將主內存中的變量加載到自己的工作內存中,一個線程 t1 將變量更改,通常不會立即同步到主內存,而是在某個合適的時機將更改後的變量同步到主內存,正是因爲這個更改還沒有 flush 到主內存中,因此線程 t2 持有的始終是舊值,自然產生了可見性的問題。JMM 也對主內存和工作內存之間具體的交互協議是有詳細嚴謹定義的,首先是定義了 8 個內存間的交互原子操作:

  • read(讀取):從主內存讀取數據,經過總線將數據取出來
  • load(載入):將主內存讀取到的數據寫入工作內存
  • use(使用):從工作內存讀取數據來計算
  • assign(賦值):將計算好的值重新賦值到工作內存中
  • store(存儲):將工作內存寫入主內存
  • write(寫入):將 store 過去的變量賦值給主內存中的變量
  • lock(鎖定):將主內存變量加鎖,標識位線程獨佔狀態
  • unlock(解鎖):將主內存變量解鎖,解鎖後其他線程可以鎖定該變量

這個 8 個操作對應圖是這樣:

img

通過上面的圖可以看出想要使用一個變量,會經過 read->load->use。

Java 內存模型對上述 8 種操作還會有一系列的約束,有了這些約束就能準確的描述出 Java 程序中哪些操作內存方法在併發下才是安全的。比如,其中一條規定是變量在工作內存中改變後必須把該變化同步回主內存。但同步的時機沒有具體規定,可能在 CPU 空閒時、可能在緩存不夠用時等等,這也是從 Java 內存模型的角度來看,導致多線程會存在內存可見性的原因。爲了解決這個問題,必須要對同步時機進行干預,這也是 volatile 產生的原因。JMM 針對 volatile 修飾的比變量,專門定義了一些特殊訪問規則,即一個被聲明爲 volatile 的變量被修改後就立即同步到主內存,當讀取時也會從主內存中讀取,這樣就也解決了可見性問題,當然,具體的是 java 虛擬機爲我們做了這個實現。

img

可見性、重排序有什麼影響

之前的內容中介紹可見性及重排序時順帶的提出了一些示例,這裏彙總一下可見性和重排序對開發的具體影響。

1. 多線程根據共享變量狀態來處理邏輯

如下面這個場景,線程 t1 調用關閉方法 showDown() 後,將關閉標識位設置爲 true,線程 t2 根據標識位拿到關閉結果做釋放清理工作,如果 (1) 和 (2) 進行重排序,那麼線程 t2 就會出錯了。出現這種問題可以說是因爲重排序導致,也可以說是可見性導致。

//線程 t1:
result = showDown();  //(1)
hasShutdown = true;   //(2)
//線程 t2
while (hasShutdown) {  //(3)
    result.relase();   //(4)
}

所以我們要對 hasShutdown 做 volatile 修飾:

private volatile hasShutdown

2. 單例模式 DCL

懶漢式 + 雙重檢查加鎖是單例的一種實現方式,簡稱 DCL,單例模式就是一個類只有一個實例,懶漢式就是第一次獲取這個實例時把單例對象初始化並返回,對應的餓漢式就是一開始就把單例對象初始化(如通過靜態常量或靜態代碼塊),等需要使用時直接提供已初始化好的對象,下面的懶漢模式加鎖就是防止多個線程進行初始化操作。

public class Singleton {

    private static Singleton instance;

    public static Singleton getInstance() {
        if (null == instance) {
            //防止其他線程鎖住
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上述的代碼實在 singleton = new Singleton() 這塊是有問題的,根據前面提及的創建對象時底層會分爲三個操作,即在 new Singleton() 時,可能會發生重排序,即先把 singleton 指向內存,再初始化成員變量等操作,因爲二者並沒有先後的依賴關係。此時,另外一個線程可能拿到一個未完全初始化好的對象使用而出錯。解決辦法也是爲 singleton 變量加上 volatile 修飾。

餓漢模式一開始就把實例初始化了,通常在類裝載的時候,其缺點如果從始至終從未使用過這個實例,則會造成內存的浪費。懶漢模式雙重檢查並加鎖較麻煩,當然還有更好的方式是採用靜態內部類的方式。

3. 64 位寫入的原子性

在 Java 中,對 long 或者 double 類型並不要求寫入是原子的,但除了這兩個類型外,其它任何類型的寫操作都是原子的。因此在某些虛擬機上,對 64 位的 long 或 double 變量進行多線程讀寫時,可能會讀取到一半的值,這是因爲一個 64 位變量的寫入可能被拆分成兩個 32 位寫操作來執行。如下面的例子,當線程 1 寫入數據的時候,線程 2 可能會獲取到一個初始化一半的值:

public class HalfWriteDemo {

    private long result = 0;

    //線程 1 調用
    public void setResult(long result) {
        this.result = result;
    }

    //線程 2 獲取
    public long getResult() {
        return result;
    }
}

解決辦法同樣是用 volatile 來修飾,因爲 Java 語言中規定對於 volatile 修飾的 long/double 類型的變量的寫操作具有原子性。

img

volatile 是如何解決可見性和重排序的

以上是介紹了可見性和重排序出現的原因,從 JMM 角度看可見性問題,和從底層處理器角度看可見性和有序性問題是不同的。Java 語言上層也規定了如果用 volatile 修飾的話,這些問題都可以解決。JMM 只是做了這個規定,具體是如何實現的呢?

對於重排序中的指令重排在編譯器這一塊是好實現,只要識別出擁 volatile 修飾就不優化、不重排即可。

從最底層 CPU 的角度來看,要實現有序性和可見性,它會提供處理器的原語支持,也就是一些指令,而能實現這些功能的這些指令被稱之爲內存屏障(Memory Barrier)。Java 內存模型是抽象出來用於屏蔽硬件和操作系統內存讀取差異的,因此它提供 volatile 從語言層面給出的保證,而具體的實現是由虛擬機替我們和這些指令打交道。

volatile 語義的實現,Java 虛擬機在底層是藉助了內存屏障。內存屏障被插入兩個指令之間使用的,它像一堵牆一樣使兩側的指令無法重排,且內存屏障還會執行沖刷處理器緩存操作來保證可見性。

img

首先來分析下產生可見性和有序性的原因,之前的分析中導致可見性和有序性問題的主要根源是 Store Buffer 和 Invalidate Queue,那麼就需要從這兩個組件上着手。

由於 Store Buffer 中的內容可能不會立即寫入到高速緩存,並且後寫入的可能會先刷到高速緩存上,這樣造成了其它線程的可見性和有序性問題,因此一定要將寫緩衝器 Store Buffer 中的內容及時的、按順序的寫到高速緩存(沖刷處理器緩存),而編譯器及底層系統正可以藉助內存屏障的指令來實現這個功能。具體的是加入內存屏障後,會將 Store Buffer 中的數據進行標記,之後的寫入操作寫入 Store Buffer 中,再及時的同步到高速緩存中,在同步的過程中,要將標記的數據先同步,這樣也保證了有序性。

其次,能及時的按順序處理 Store Buffer 後只是解決了一半的問題。雖然數據到達高速緩存後可以利用緩存一致性,但因爲無效化隊列 Invalidate Queue 的存在,其它處理器讀到的值仍可能是舊值,因此還要及時的將 Invalidate Queue 中的內容進行處理,將失效的緩存條目置爲 Invalidate,而內存屏障同樣可以實現該功能。

因此內存屏障要進行“搭配”使用,寫數據要將更新及時的從寫緩衝器沖刷到高速緩存,而讀數據要將無效化隊列中的內容應用到高速緩存上。

內存屏障在 CPU 底層是提供了原語支持的,如下面的內存屏障指令:

  • sfence:將 Store Buffer 刷新到高速緩存,同時規定禁止 store 指令與 sfence 後面的指令重排序,即禁止了寫的重排序。
  • lfence:將 Invalidate Queue 中的內容應用到高速緩存,並規定 load 指令不能 lsfence 後面的指令重排序,即禁止了讀的重排序。
  • mfence:既將 Store Buffer 刷新到高速緩存又將將 Invalidate Queue 中的內容應用到高速緩存,並禁止 store/load 與 mfence 後面的指令重排,即禁止所有重排序。

從所有重排序類型的角度來看,重拍序可以有 4 種,即 load 和 store 的全排列:loadload、loadstore、storestore、storeload。而我們常用的 x86 CPU 本身是不支持前三種重排序的,因此在 x86 CPU 只需要處理 storeload 這種重排序就可。當然,Java 是一種跨平臺的語言,它需要考慮所有重排序的場景,因此在 JSR 規範中是定義了 4 種內存屏障,針對不同的處理器可以有不同的實現。這 4 中內存屏障如下:

  • LoadLoad 屏障:對於這樣的語句 Load1;LoadLoad;Load2; 在 Load2 及後續讀取操作要讀取數據被訪問前,保障 Load1 被要讀取完畢。這裏可以通過清空無效化隊列實現,即將無效化隊列中的失效的緩存應用到高速緩存。
  • StoreStore 屏障:對於這樣的語句 Store1;StoreStore;Store2,在 Store2 及後續寫入操作執行前,保證 Store1 的寫入操作對其它處理器可見。這裏可以通過將寫緩衝器中的條目進行標記來實現,將標記的條目先提交進而禁止 StoreStore 重排序。
  • LoadStore 屏障:對於這樣的語句 Load1;LoadStore;Store2,在 Store2 及後續寫入操作執行前,保證 Load1 要讀取的數據被讀取完畢。
  • StoreLoad 屏障:對於這樣的語句 Store1;StoreLoad;Load2,在 Load2 及後續所有讀取操作執行前,保證 Store1 的寫入對所有處理器可見。這種內存屏障通常作爲基本內存屏障,它即會清空無效化隊列,又會將寫緩衝器沖刷到高速緩存中,因此它的開銷也是最大的。

在 Java 中對於 volatile 修飾的變量,編譯器生成字節碼時,會在 volatile 的讀寫前後插入對應的內存屏障,要插入何種內存屏障才能滿足 volatile 的語義如下圖所示:

img

由表格中可以看出,內存屏障的選擇與變量的前一個操作是否是 volatile 變量有關係,該圖出自 Doug Lea 的文章:

http://gee.cs.oswego.edu/dl/jmm/cookbook.html

下面是實現 volatile 語義的一種常規做法:

img

這裏要着重說明一下,關於 volatile 內存屏障的插入方式,不同的平臺是不同的,因此關於具體插入哪種內存屏障也出現了很多不同說法,但從 volatile 語義來說,若要支持所有平臺,就要採用悲觀策略,即該有的屏障都要插入。但很多處理器沒有 Invalidate Queue,因此在 volatile 的讀前面可以不插入屏障,因爲寫後已經插入 storeload 屏障保障數據同步到高速緩存,進而其它處理器就能獲取到最新值,另外對於寫之前按理說應加入 LoadStore 屏障,但 volatile 寫與前面普通變量的讀即使重排序也不會影響程序正確性,因此寫之前的 LoadStore 屏障也可以被省略了。

JMM 本身對於 volatile 變量在編譯器級別的重排序也制定了相關的規則,即提示編譯器不要做一些優化而導致可見性問題,如之前的 while 死循環的例子,就可以用 volatile 修飾而避免出現問題。由此可見從編譯器到虛擬機,再到處理器都是支持內存屏障的。

這是從底層來解釋了 volatile 語義的實現,如果從 Java 內存角度模型來看,其實沒這麼麻煩,因爲 Java 內存模型並不涉及緩存一致性協議、硬件緩衝器等等,因此可以簡單理解爲,寫一個 volatile 修飾的變量會從工作內存同步到主內存,同時其它處理器工作內存中存儲的該變量副本會失效,因此讀取該變量時會重新從主內存中加載。

再說回到內存屏障的實現,JMM 層面的內存屏障就是對 CPU 層面的內存屏障指令做的包裝,不同硬件實現內存屏障的方式不同。之前提到過,在 x86 平臺上,由於只支持 StoreLoad 的重排序,因此只需要在 volatile 的寫操作後加入 StoreLoad 屏障即可,而 Hotspot 虛擬機在 x86 平臺實現 volatile 依賴的是一條 CPU 指令:lock addl $0x0,(%rsp),lock 前綴指令理論上說不是一種內存屏障指令,但它可以完成類似內存屏障的功能,也就是說起到了 StoreLoad 屏障的作用。而在其處理器上,會根據對重排序的支持情況在 volatile 的前後插入相應的內存屏障指令。

由於需要保證可見性和禁止指令重排,x86 CPU 規定 load、store 不能與 lock 指令重排序,這就達到了禁止指令重排的要求。對於可見性的問題,lock 指令會觸發沖刷處理器 Store Buffer 到高速緩存,這樣保 lock 指令前面內容的可見性。由於 x86 處理器並沒有使用 Invalidate Queue,因此只需要在寫 volatile 變量後插入 StoreLoad 類型的屏障,即一條 lock 指令就可以了。lock 指令同時會鎖住要操作的內存地址,直到讀完/寫完,因此也保證 64 位變量寫的原子性。

img

volatile 的原子性問題

原子性是指對共享變量的訪問是一個不可分割的整體,volatile 可以使 long/double 類型的寫具有原子性,但它並不保證其它操作的原子性。

另外 volatile 能保證可見性,意味着一個線程操作了 volatile 修飾的變量,其它線程立即可以感知到新的值,但這不足以保證原子性,考慮下面的場景:多線程競爭執行 volatile 修飾變量 a++ 這種操作時, 其底層是分爲 4 步執行

  1. move a 寄存器
  2. add 寄存器 1
  3. move 寄存器 a
  4. add StoreLoad Barrier

首先將 a 的值賦給寄存器,然後寄存器加 1,再將寄存器的值賦給 a,最後一步是內存屏障,代表最新值會對其它線程其可見,這裏的第三步是可以保證原子的,但發生第 2 步的時候,其它線程如果也自增了 a 值後並同步回主內存,此時執行第三步,即使當前線程嗅探到 a 值的變換,並重新從主內存中加載,但第三步執行又將自增的結果賦給 a,之後同步回主內存,這樣就出現了 a 本應該自增了兩次,真實結果確只加了 1,這樣就導致數據不一致了,要保證原子性仍然要加鎖。

可以通過下面的代碼來驗證 volatile 的原子性問題:

public class VolatileTest {

    private static volatile int a = 0;

    public static void main(String[] args) throws InterruptedException {

        //自增 5000
        Runnable runnable = () -> {
            for (int i = 0; i < 5000; i++) {
                a++;
            }
            System.out.println("線程執行結束");
        };

        //開啓兩個線程執行
        new Thread(runnable).start();
        new Thread(runnable).start();
        Thread.sleep(5000);
        System.out.println(a);
    }
}

在程序中用兩個線程來實現 volatile 變量的自增操作,每個線程自增 5000,如果是原子的話,輸出結果應該是 10000,而實際的輸出結果如下:

線程執行結束
線程執行結束
7242

img

volatile 用在哪個地方或哪些場景

1. DCL 單例模式,通常要將其成員變量設置爲 volatile,禁止指令重排,這樣不會拿到一個初始化一半的對象。

public class Singleton {

    private static volatile Singleton instance;

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

2. 使用 volatile 變量作爲狀態標識。如果一個共享變量被某個線程設置,而另一個線程要根據該變量的狀態做業務邏輯時,要設置爲 volatile,這相當於一個同步機制,即一個線程能夠通知另一個線程某些事件的發生。如可以看下 RocketMQ 的源碼中,有很多用到 volatile 的例子:

 private volatile boolean hasShutdown = false;

 private volatile boolean cancelled = false;

 private volatile boolean stopped = false;

這些用 volatile 修飾的變量有很多都是 boolean 值,這也很好理解,就是可能有多個線程對這些 boolean 值操作時,某個一個線程成功了,其它線程立即就感知到。

如多個線程進行 showdown,某個線程執行成功將 hasShutdown 置爲 true,這樣其它線程再進行 showdown 時,檢測到 hasShutdown 標識位已經是 ture,則不會再進行 shutdown。如果未聲明未爲 volatile,則一個線程修改,另一個線程檢測仍然是 false,繼續進行關閉操作可能會出錯。或者某個線不斷循環 showdown 標識位,當檢測到關閉時需要及時做一些清理工作,同樣需要將標識位聲明爲使用這種方式的場景 。僞代碼如下:

//定義狀態量標記
volatile boolean hasShutdown = false;


//關閉方法
void showdown(){
  if(showdown){
    returen
  }
  start = true;
  //……
}

//清理工作
void clean(){
  while(hasShutdown){
      //
    }
}

3. 多線程讀寫 long/double 類型的變量時,需要用 volatile 修飾,如下面代碼所示:

public class HalfWriteDemo {

    private volatile long result = 0;

    //線程 1 調用
    public void setResult(long result) {
        this.result = result;
    }

    //線程 2 獲取
    public long getResult() {
        return result;
    }
}

img

轉載地址

Java併發面試系列:徹底掌握 volatile 關鍵字原理

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