【併發編程系列3】volatile內存屏障及實現原理分析(JMM和MESI)

初識volatile

Java語言規範第3版中對volatile的定義如下:Java編程語言允許線程訪問共享變量,爲了確保共享變量能被準確和一致地更新,線程應該確保通過排他鎖單獨獲得這個變量。
這個概念聽起來有些抽象,我們先看下面一個示例:

package com.zwx.concurrent;

public class VolatileDemo {
    public static boolean finishFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            int i = 0;
            while (!finishFlag){
                i++;
            }
        },"t1").start();
        Thread.sleep(1000);//確保t1先進入while循環後主線程才修改finishFlag
        finishFlag = true;
    }
}

這裏運行之後他t1線程中的while循環是停不下來的,因爲我們是在主線程修改了finishFlag的值,而此值對t1線程不可見,如果我們把變量finishFlag加上volatile修飾:

public static volatile boolean finishFlag = false;

這時候再去運行就會發現while循環很快就可以停下來了。
從這個例子中我們可以知道volatile可以解決線程間變量可見性問題。可見性的意思是當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。

volatile如何保證可見性

利用工具hsdis,打印出彙編指令,可以發現,加了volatile修飾之後打印出來的彙編指令多了下面一行:
在這裏插入圖片描述
lock是一種控制指令,在多處理器環境下,lock 彙編指令可以基於總線鎖或者緩存鎖的機制來達到可見性的一個效果。

可見性的本質

硬件層面

線程是CPU調度的最小單元,線程設計的目的最終仍然是更充分的利用計算機處理的效能,但是絕大部分的運算任務不能只依靠處理器“計算”就能完成,處理器還需要與內存交互,比如讀取運算數據、存儲運算結果,這個 I/O 操作是很難消除的。而由於計算機的存儲設備與處理器的運算速度差距非常大,所以現代計算機系統都會增加一層讀寫速度儘可能接近處理器運算速度的高速緩存來作爲內存和處理器之間的緩衝:將運算需要使用的數據複製到緩存中,讓運算能快速進行,當運算結束後再從緩存同步到內存之中。
查看我們個人電腦的配置可以看到,CPU有L1,L2,L3三級緩存,大致粗略的結構如下圖所示:
在這裏插入圖片描述
從上圖可以知道,L1和L2緩存爲各個CPU獨有,而有了高速緩存的存在以後,每個 CPU 的處理過程是,先將計算需要用到的數據緩存在 CPU 高速緩存中,在 CPU進行計算時,直接從高速緩存中讀取數據並且在計算完成之後寫入到緩存中。在整個運算過程完成後,再把緩存中的數據同步到主內存。
由於在多 CPU 種,每個線程可能會運行在不同的 CPU 內,並且每個線程擁有自己的高速緩存。同一份數據可能會被緩存到多個 CPU 中,如果在不同 CPU 中運行的不同線程看到同一份內存的緩存值不一樣就會存在緩存不一致的問題,那麼怎麼解決緩存一致性問題呢?CPU層面提供了兩種解決方法:總線鎖緩存鎖

總線鎖

總線鎖,簡單來說就是,在多CPU下,當其中一個處理器要對共享內存進行操作的時候,在總線上發出一個 LOCK#信號,這個信號使得其他處理器無法通過總線來訪問到共享內存中的數據,總線鎖定把 CPU 和內存之間的通信鎖住了(CPU和內存之間通過總線進行通訊),這使得鎖定期間,其他處理器不能操作其他內存地址的數據。然而這種做法的代價顯然太大,那麼如何優化呢?優化的辦法就是降低鎖的粒度,所以CPU就引入了緩存鎖。

緩存鎖

緩存鎖的核心機制是基於緩存一致性協議來實現的,一個處理器的緩存回寫到內存會導致其他處理器的緩存無效,IA-32處理器和Intel 64處理器使用MESI實現緩存一致性協議(注意,緩存一致性協議不僅僅是通過MESI實現的,不同處理器實現了不同的緩存一致性協議)

MESI(緩存一致性協議)

MESI是一種比較常用的緩存一致性協議,MESI表示緩存行的四種狀態,分別是:
1、M(Modify) 表示共享數據只緩存在當前 CPU 緩存中,並且是被修改狀態,也就是緩存的數據和主內存中的數據不一致
2、E(Exclusive) 表示緩存的獨佔狀態,數據只緩存在當前CPU緩存中,並且沒有被修改
3、S(Shared) 表示數據可能被多個 CPU 緩存,並且各個緩存中的數據和主內存數據一致
4、I(Invalid) 表示緩存已經失效
在 MESI 協議中,每個緩存的緩存控制器不僅知道自己的讀寫操作,而且也監聽(snoop)其它CPU的讀寫操作。
對於 MESI 協議,從 CPU 讀寫角度來說會遵循以下原則:
CPU讀請求:緩存處於 M、E、S 狀態都可以被讀取,I 狀態CPU 只能從主存中讀取數據
CPU寫請求:緩存處於 M、E 狀態纔可以被寫。對於S狀態的寫,需要將其他CPU中緩存行置爲無效纔行。

CPU工作流程

使用總線鎖和緩存鎖機制之後,CPU 對於內存的操作大概可以抽象成下面這樣的結構。從而達到緩存一致性效果:
在這裏插入圖片描述

MESI協議帶來的問題

MESI協議雖然可以實現緩存的一致性,但是也會存在一些問題:就是各個CPU緩存行的狀態是通過消息傳遞來進行的。如果CPU0要對一個在緩存中共享的變量進行寫入,首先需要發送一個失效的消息給到其他緩存了該數據的 CPU。並且要等到他們的確認回執。CPU0在這段時間內都會處於阻塞狀態。爲了避免阻塞帶來的資源浪費。CPU中又引入了store bufferes:
在這裏插入圖片描述
如上圖,CPU0 只需要在寫入共享數據時,直接把數據寫入到 store bufferes中,同時發送invalidate消息,然後繼續去處理其他指令(異步) 當收到其他所有 CPU 發送了invalidate acknowledge消息時,再將store bufferes中的數據數據存儲至緩存行中,最後再從緩存行同步到主內存。但是這種優化就會帶來了可見性問題,也可以認爲是CPU的亂序執行引起的或者說是指令重排序(指令重排序不僅僅在CPU層面存在,編譯器層面也存在指令重排序)。
我們通過下面一個簡單的示例來看一下指令重排序帶來的問題。

package com.zwx.concurrent;

public class ReSortDemo {
    int value;
    boolean isFinish;

    void cpu0(){
        value = 10;//S->I狀態,將value寫入store bufferes,通知其他CPU當前value的緩存失效
        isFinish=true;//E狀態
    }
    void cpu1(){
        if (isFinish){//true
            System.out.println(value == 10);//可能爲false
        }
    }
}

這時候理論上當isFinish爲true時,value也要等於10,然而由於當value修改爲10之後,發送消息通知其他CPU還沒有收到響應時,當前CPU0繼續執行了isFinish=true,所以就可能存在isFinsh爲true時,而value並不等於10的問題。
我們想一想,其實從硬件層面很難去知道軟件層面上的這種前後依賴關係,所以沒有辦法通過某種手段自動去解決,故而CPU層面就提供了內存屏障(Memory Barrier,Intel稱之爲 Memory Fence),使得軟件層面可以決定在適當的地方來插入內存屏障來禁止指令重排序。

CPU層面的內存屏障

CPU內存屏障主要分爲以下三類:
寫屏障(Store Memory Barrier):告訴處理器在寫屏障之前的所有已經存儲在存儲緩存(store bufferes)中的數據同步到主內存,簡單來說就是使得寫屏障之前的指令的結果對寫屏障之後的讀或者寫是可見的。
讀屏障(Load Memory Barrier):處理器在讀屏障之後的讀操作,都在讀屏障之後執行。配合寫屏障,使得寫屏障之前的內存更新對於讀屏障之後的讀操作是可見的。
全屏障(Full Memory Barrier):確保屏障前的內存讀寫操作的結果提交到內存之後,再執行屏障後的讀寫操作。
這些概念聽起來可能有點模糊,我們通過將上面的例子改寫一下來說明:

package com.zwx.concurrent;

public class ReSortDemo {
    int value;
    boolean isFinish;

    void cpu0(){
        value = 10;//S->I狀態,將value寫入store bufferes,通知其他CPU當前value的緩存失效
        storeMemoryBarrier();//僞代碼,插入一個寫屏障,使得value=10這個值強制寫入主內存
        isFinish=true;//E狀態
    }
    void cpu1(){
        if (isFinish){//true
            loadMemoryBarrier();//僞代碼,插入一個讀屏障,強制cpu1從主內存中獲取最新數據
            System.out.println(value == 10);//true
        }
    }

    void storeMemoryBarrier(){//寫屏障
    }
    void loadMemoryBarrier(){//讀屏障
    }
}

通過以上內存屏障,我們就可以防止了指令重排序,得到我們預期的結果。
總的來說,內存屏障的作用可以通過防止 CPU 對內存的亂序訪問來保證共享數據在多線程並行執行下的可見性,但是這個屏障怎麼來加呢?回到最開始我們講 volatile關鍵字的代碼,這個關鍵字會生成一個 lock 的彙編指令,這個就相當於實現了一種內存屏障。接下來我們進入volatile原理分析的正題

JVM層面

在JVM層面,定義了一種抽象的內存模型(JMM)來規範並控制重排序,從而解決可見性問題。

JMM(Java內存模型)

JMM全稱是Java Memory Model(Java內存模型),什麼是JMM呢?通過前面的分析發現,導致可見性問題的根本原因是緩存以及指令重排序。 而JMM 實際上就是提供了合理的禁用緩存以及禁止重排序的方法。所以JMM最核心的價值在於解決可見性和有序性
JMM屬於語言級別的抽象內存模型,可以簡單理解爲對硬件模型的抽象,它定義了共享內存中多線程程序讀寫操作的行爲規範,通過這些規則來規範對內存的讀寫操作從而保證指令的正確性,它解決了CPU 多級緩存、處理器優化、指令重排序導致的內存訪問問題,保證了併發場景下的可見性。
需要注意的是,JMM並沒有限制執行引擎使用處理器的寄存器或者高速緩存來提升指令執行速度,也沒有限制編譯器對指令進行重排序,也就是說在JMM中,也會存在緩存一致性問題和指令重排序問題。只是JMM把底層的問題抽象到JVM層面,再基於CPU層面提供的內存屏障指令,以及限制編譯器的重排序來解決併發問題。

JMM抽象模型結構

JMM 抽象模型分爲主內存、工作內存;主內存是所有線程共享的,一般是實例對象、靜態字段、數組對象等存儲在堆內存中的變量。工作內存是每個線程獨佔的,線程對變量的所有操作都必須在工作內存中進行,不能直接讀寫主內存中的變量,線程之間的共享變量值的傳遞都是基於主內存來完成,可以抽象爲下圖:
在這裏插入圖片描述

JMM如何解決可見性問題

從JMM的抽象模型結構圖來看,如果線程A與線程B之間要通信的話,必須要經歷下面2個步驟。
1)線程A把本地內存A中更新過的共享變量刷新到主內存中去。
2)線程B到主內存中去讀取線程A之前已更新過的共享變量。
下面通過示意圖來說明這兩個步驟:
在這裏插入圖片描述
結合上圖,假設初始時,這3個內存中的x值都爲0。線程A在執行時,把更新後的x值(假設值爲1)臨時存放在自己的本地內存 A中。當線程A和線程B需要通信時,線程A首先會把自己本地內存中修改後的x值刷新到主內 存中,此時主內存中的x值變爲了1。隨後,線程B到主內存中去讀取線程A更新後的x值,此時線程B的本地內存的x值也變爲了1。 從整體來看,這兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來爲Java程序員提供內存可見性保證。

編譯器的指令重排序

綜合上面從硬件層面和JVM層面的分析,我們知道在執行程序時,爲了提高性能,編譯器和處理器常常會對指令做重排序。重排序分3種類型:
1)編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
2)指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來將多條指令重疊執行。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
3)內存系統的重排序。由於處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操作看上去可能是在亂序執行。
從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序,如下圖:
在這裏插入圖片描述
其中2和3屬於處理器重排序(前面硬件層面已經分析過了)。而這些重排序都可能會導致可見性問題(編譯器和處理器在重排序時會遵守數據依賴性,編譯器和處理器不會改變存在數據依賴關係的兩個操作的執行順序,編譯器會遵守happens-before規則和as-if-serial語義)。
對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排 序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(Memory Barriers,Intel稱之爲Memory Fence)指令,通過內存屏障指令來禁止特定類型的處理器重排序。JMM屬於語言級的內存模型,它確保在不同的編譯器和不同的處理器平臺之上,通過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。正是因爲volatile的這個特性,所以單例模式中可以通過volatile關鍵字來解決雙重檢查鎖(DCL)寫法中所存在的問題

JMM層面的內存屏障

在JMM 中把內存屏障分爲四類:
在這裏插入圖片描述
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多數處理器大多支持該屏障(其他類型的屏障不一定被所有處理器支持)。執行該屏障開銷會很昂貴,因爲當前處理器通常要把寫緩衝區中的數據全部刷新到內存中(Buffer Fully Flush)。

happens-before規則

happens-before表示的是前一個操作的結果對於後續操作是可見的,它是一種表達多個線程之間對於內存的可見性。所以我們可以認爲在 JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作必須要存在happens-before關係。這兩個操作可以是同一個線程,也可以是不同的線程,如果想詳細瞭解happens-before規則,可以點擊這裏

總結

併發編程中有三大特性:原子性可見性有序性,volatile通過內存屏障禁止指令重排序,主要遵循以下三個規則:

  1. 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  2. 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  3. 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。爲此,JMM採取保守策略。下面是基於保守策略的JMM內存屏障插入策略:

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

最後需要特別提一下原子性,Java語言規範鼓勵但不強求JVM對64位的long型變量和double型變量的寫操作具有原子性。當JVM在這種處理器上運行時,可能會把一個64位long/double型變量的寫操作拆分爲兩個32位的寫操作來執行,這兩個32位的寫操作可能會被分配到不同的總線事務中執行,此時對這個64位變量的寫操作將不具有原子性。
鎖的語義決定了臨界區代碼的執行具有原子性。但是因爲一個volatile變量的讀,總是能看到(任意線程)對這個volatile變量最後的寫入,所以即使是64位的long型和double型變量,只要它是volatile變量,對該變量的讀/寫就具有原子性。但是多個volatile操作或類似於i++這種複合操作,這些操作整體上不具有原子性。針對於複合操作如i++這種,如果要保證原子性,需要通過synchronized關鍵字或者加其他鎖來處理。
注意:在JSR-133之前的舊內存模型中,一個64位long/double型變量的讀/寫操作可以被拆分爲兩個32位的讀/寫操作來執行。從JSR-133內存模型開始(即從JDK5開始),僅僅只允許把一個64位long/double型變量的寫操作拆分爲兩個32位的寫操作來執行,任意的讀操作在JSR-133中都必須具有原子性(即任意讀操作必須要在單個讀事務中執行)。

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