Java 併發編程之 JMM & volatile 詳解

本文從計算機模型開始,以及CPU與內存、IO總線之間的交互關係到CPU緩存一致性協議的邏輯進行了闡述,並對JMM的思想與做用進行了詳細的說明。針對volatile關鍵字從字節碼以及彙編指令層面解釋了它是如何保證可見性與有序性的,最後對volatile進行了拓展,從實戰的角度更瞭解關鍵字的運用。html

1、現代計算機理論模型與工做原理

1.1 馮諾依曼計算機模型

讓咱們來一塊兒回顧一下大學計算機基礎,現代計算機模型——馮諾依曼計算機模型,是一種將程序指令存儲器和數據存儲器合併在一塊兒的計算機設計概念結構。依據馮·諾伊曼結構設計出的計算機稱作馮.諾依曼計算機,又稱存儲程序計算機。java

計算機在運行指令時,會從存儲器中一條條指令取出,經過譯碼(控制器),從存儲器中取出數據,而後進行指定的運算和邏輯等操做,而後再按地址把運算結果返回內存中去。編程

接下來,再取出下一條指令,在控制器模塊中按照規定操做。依此進行下去。直至遇到中止指令。bootstrap

程序與數據同樣存貯,按程序編排的順序,一步一步地取出指令,自動地完成指令規定的操做是計算機最基本的工做模型。這一原理最初是由美籍匈牙利數學家馮.諾依曼於1945年提出來的,故稱爲馮.諾依曼計算機模型。segmentfault

  • 五大核心組成部分:
  1. 運算器:顧名思義,主要進行計算,算術運算、邏輯運算等都由它來完成。
  2. 存儲器:這裏存儲器只是內存,不包括內存,用於存儲數據、指令信息。實際就是咱們計算機中內存(RAM)
  3. 控制器:控制器是是全部設備的調度中心,系統的正常運行都是有它來調配。CPU包含控制器和運算器。
  4. 輸入設備:負責向計算機中輸入數據,如鼠標、鍵盤等。
  5. 輸出設備:負責輸出計算機指令執行後的數據,如顯示器、打印機等。
  • 現代計算機硬件結構:

圖中結構能夠關注兩個重點:數組

I/O總線:全部的輸入輸出設備都與I/O總線對接,保存咱們的內存條、USB、顯卡等等,就比如一條公路,全部的車都在上面行駛,可是畢竟容量有限,IO頻繁或者數據較大時就會引發「堵車」緩存

CPU:當CPU運行時最直接也最快的獲取存儲的是寄存器,而後會經過CPU緩存從L1->L2->L3尋找,若是緩存都沒有則經過I/O總線到內存中獲取,內存中獲取到以後會依次刷入L3->L2->L1->寄存器中。現代計算機上咱們CPU通常都是 1.xG、2.xG的赫茲,而咱們內存的速度只有每秒幾百M,因此爲了爲了避免讓內存拖後腿也爲了儘可能減小I/O總線的交互,纔有了CPU緩存的存在,CPU型號的不一樣有的是兩級緩存,有的是三級緩存,運行速度對比:寄存器 \> L1 > L2 > L3 > 內存條安全

1.2 CPU多級緩存和內存

CPU緩存即高速緩衝存儲器,是位於CPU與主內存之間容量很小但速度很高的存儲器。CPU直接從內存中存取數據後會保存到緩存中,當CPU再次使用時能夠直接從緩存中調取。若是有數據修改,也是先修改緩存中的數據,而後通過一段時間以後纔會從新寫回主內存中。多線程

CPU緩存最小單元是緩存行(cache line),目前主流計算機的緩存行大小爲64Byte,CPU緩存也會有LRU、Random等緩存淘汰策略。CPU的三級緩存爲多個CPU共享的。架構

  • CPU讀取數據時的流程:

(1)先讀取寄存器的值,若是存在則直接讀取

(2)再讀取L1,若是存在則先把cache行鎖住,把數據讀取出來,而後解鎖

(3)若是L1沒有則讀取L2,若是存在則先將L2中的cache行加鎖,而後將數據拷貝到L1,再執行讀L1的過程,最後解鎖

(4)若是L2沒有則讀取L3,同上先加鎖,再往上層依次拷貝、加鎖,讀取到以後依次解鎖

(5)若是L3也沒有數據則通知內存控制器佔用總線帶寬,通知內存加鎖,發起內存讀請求,等待迴應,迴應數據保存到L3(若是沒有就到L2),再從L3/2到L1,再從L1到CPU,以後解除總線鎖定。

  • 緩存一致性問題:

在多處理器系統中,每一個處理器都有本身的緩存,因而也引入了新的問題:緩存一致性。當多個處理器的運算任務都涉及同一塊主內存區域時,將可能致使各自的緩存數據不一致的狀況。爲了解決一致性的問題,須要各個處理器訪問緩存時都遵循一些協議,在讀寫時要根據協議來進行操做,這類協議有MSI、MESI、MOSI等等。

1.3 MESI緩存一致性協議

緩存一致性協議中應用最普遍的就是MESI協議。主要原理是 CPU 經過總線嗅探機制(監聽)能夠感知數據的變化從而將本身的緩存裏的數據失效,緩存行中具體的幾種狀態以下:

以上圖爲例,假設主內存中有一個變量x=1,CPU1和CPU2中都會讀寫,MESI的工做流程爲:

(1)假設CPU1須要讀取x的值,此時CPU1從主內存中讀取到緩存行後的狀態爲E,表明只有當前緩存中獨佔數據,並利用CPU嗅探機制監聽總線中是否有其餘緩存讀取x的操做。

(2)此時若是CPU2也須要讀取x的值到緩存行,則在CPU2中緩存行的狀態爲S,表示多個緩存中共享,同時CPU1因爲嗅探到CPU2也緩存了x因此狀態也變成了S。而且CPU1和CPU2會同時嗅探是否有另緩存失效獲取獨佔緩存的操做。

(3)當CPU1有寫入操做須要修改x的值時,CPU1中緩存行的狀態變成了M。

(4)CPU2因爲嗅探到了CPU1的修改操做,則會將CPU2中緩存的狀態變成 I 無效狀態。

(5)此時CPU1中緩存行的狀態從新變回獨佔E的狀態,CPU2要想讀取x的值的話須要從新從主內存中讀取。

2、JMM模型

2.1  Java 線程與系統內核的關係

Java線程在JDK1.2以前,是基於稱爲「綠色線程」(Green Threads)的用戶線程實現的,而在JDK1.2中,線程模型替換爲基於操做系統原生線程模型來實現。所以,在目前的JDK版本中,操做系統支持怎樣的線程模型,在很大程度上決定了Java虛擬機的線程是怎樣映射的,這點在不一樣的平臺上沒有辦法達成一致,虛擬機規範中也並未限定Java線程須要使用哪一種線程模型來實現。

用戶線程:指不須要內核支持而在用戶程序中實現的線程,其不依賴於操做系統核心,應用進程利用線程庫提供建立、同步、調度和管理線程的函數來控制用戶線程。另外,用戶線程是由應用進程利用線程庫建立和管理,不依賴於操做系統核心。不須要用戶態/核心態切換,速度快。操做系統內核不知道多線程的存在,所以一個線程阻塞將使得整個進程(包括它的全部線程)阻塞。因爲這裏的處理器時間片分配是以進程爲基本單位,因此每一個線程執行的時間相對減小。

內核線程: 線程的全部管理操做都是由操做系統內核完成的。內核保存線程的狀態和上下文信息,當一個線程執行了引發阻塞的系統調用時,內核能夠調度該進程的其餘線程執行。在多處理器系統上,內核能夠分派屬於同一進程的多個線程在多個處理器上運行,提升進程執行的並行度。因爲須要內核完成線程的建立、調度和管理,因此和用戶級線程相比這些操做要慢得多,可是仍然比進程的建立和管理操做要快。

基於線程的區別,咱們能夠引出java內存模型的結構。

2.2  什麼是 JMM 模型

Java內存模型(Java Memory Model簡稱JMM)是一種抽象的概念,並不真實存在,它描述的是一組規則或規範,經過這組規範定義了程序中各個變量(包括實例字段,靜態字段和構成數組對象的元素)的訪問方式。

爲了屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的併發效果,JMM規範了Java虛擬機與計算機內存是如何協同工做的:JVM運行程序的實體是線程,而每一個線程建立時JVM都會爲其建立一個工做內存(有些地方稱爲棧空間),用於存儲線程私有的數據,而Java內存模型中規定全部變量都存儲在主內存,主內存是共享內存區域,全部線程均可以訪問,但線程對變量的操做(讀取賦值等)必須在工做內存中進行,首先要將變量從主內存拷貝的本身的工做內存空間,而後對變量進行操做,操做完成後再將變量寫回主內存,不能直接操做主內存中的變量,工做內存中存儲着主內存中的變量副本拷貝。工做內存是每一個線程的私有數據區域,所以不一樣的線程間沒法訪問對方的工做內存,線程間的通訊(傳值)必須經過主內存來完成。

主內存

主要存儲的是Java實例對象,全部線程建立的實例對象都存放在主內存中,無論該實例對象是成員變量仍是方法中的本地變量(也稱局部變量),固然也包括了共享的類信息、常量、靜態變量。因爲是共享數據區域,從某個程度上講應該包括了JVM中的堆和方法區。多條線程對同一個變量進行訪問可能會發生線程安全問題。

工做內存

主要存儲當前方法的全部本地變量信息(工做內存中存儲着主內存中的變量副本拷貝),每一個線程只能訪問本身的工做內存,即線程中的本地變量對其它線程是不可見的,就算是兩個線程執行的是同一段代碼,它們也會各自在本身的工做內存中建立屬於當前線程的本地變量,固然也包括了字節碼行號指示器、相關Native方法的信息。因此則應該包括JVM中的程序計數器、虛擬機棧以及本地方法棧。注意因爲工做內存是每一個線程的私有數據,線程間沒法相互訪問工做內存,所以存儲在工做內存的數據不存在線程安全問題。

2.3 JMM 詳解

須要注意的是JMM只是一種抽象的概念,一組規範,並不實際存在。對於真正的計算機硬件來講,計算機內存只有寄存器、緩存內存、主內存的概念。無論是工做內存的數據仍是主內存的數據,對於計算機硬件來講都會存儲在計算機主內存中,固然也有可能存儲到CPU緩存或者寄存器中,所以整體上來講,Java內存模型和計算機硬件內存架構是一個相互交叉的關係,是一種抽象概念劃分與真實物理硬件的交叉。

工做內存同步到主內存之間的實現細節,JMM定義瞭如下八種操做:

若是要把一個變量從主內存中複製到工做內存中,就須要按順序地執行read和load操做,若是把變量從工做內存中同步到主內存中,就須要按順序地執行store和write操做。但Java內存模型只要求上述操做必須按順序執行,而沒有保證必須是連續執行。

  • 同步規則分析

(1)不容許一個線程無緣由地(沒有發生過任何assign操做)把數據從工做內存同步回主內存中。

(2)一個新的變量只能在主內存中誕生,不容許在工做內存中直接使用一個未被初始化(load或者assign)的變量。即就是對一個變量實施use和store操做以前,必須先自行assign和load操做。

(3)一個變量在同一時刻只容許一條線程對其進行lock操做,但lock操做能夠被同一線程重複執行屢次,屢次執行lock後,只有執行相同次數的unlock操做,變量纔會被解鎖。lock和unlock必須成對出現。

(4)若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量以前須要從新執行load或assign操做初始化變量的值。

(5)若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做;也不容許去unlock一個被其餘線程鎖定的變量。

(6)對一個變量執行unlock操做以前,必須先把此變量同步到主內存中(執行store和write操做)。

2.4 JMM 如何解決多線程併發引發的問題

多線程併發下存在:原子性、可見性、有序性三種問題。

  • 原子性:

問題:原子性指的是一個操做是不可中斷的,即便是在多線程環境下,一個操做一旦開始就不會被其餘線程影響。可是當線程運行的過程當中,因爲CPU上下文的切換,則線程內的多個操做並不能保證是保持原子執行。

解決:除了JVM自身提供的對基本數據類型讀寫操做的原子性外,能夠經過 synchronized和Lock實現原子性。由於synchronized和Lock可以保證任一時刻只有一個線程訪問該代碼塊。

  • 可見性

問題:以前咱們分析過,程序運行的過程當中是分工做內存和主內存,工做內存將主內存中的變量拷貝到副本中緩存,假如兩個線程同時拷貝一個變量,可是當其中一個線程修改該值,另外一個線程是不可見的,這種工做內存和主內存之間的數據同步延遲就會形成可見性問題。另外因爲指令重排也會形成可見性的問題。

解決:volatile關鍵字保證可見性。當一個共享變量被volatile修飾時,它會保證修改的值當即被其餘的線程看到,即修改的值當即更新到主存中,當其餘線程須要讀取時,它會去內存中讀取新值。synchronized和Lock也能夠保證可見性,由於它們能夠保證任一時刻只有一個線程能訪問共享資源,並在其釋放鎖以前將修改的變量刷新到內存中。

有序性

問題:在單線程下咱們認爲程序是順序執行的,可是多線程環境下程序被編譯成機器碼的後可能會出現指令重排的現象,重排後的指令與原指令未必一致,則可能會形成程序結果與預期的不一樣。

解決:在Java裏面,能夠經過volatile關鍵字來保證必定的有序性。另外能夠經過synchronized和Lock來保證有序性,很顯然,synchronized和Lock保證每一個時刻是有一個線程執行同步代碼,至關因而讓線程順序執行同步代碼,天然就保證了有序性。

3、volatile關鍵字

3.1 volatile 的做用

volatile是 Java 虛擬機提供的輕量級的同步機制。volatile關鍵字有以下兩個做用:

  • 保證被volatile修飾的共享變量對全部線程總數可見,也就是當一個線程修改了一個被volatile修飾共享變量的值,新值老是能夠被其餘線程當即得知
  • 禁止指令重排序優化

3.2 volatile 保證可見性

如下是一段多線程場景下存在可見性問題的程序。

public class VolatileTest extends Thread {
    private int index = 0;
    private boolean flag = false;
 
    @Override
    public void run() {
        while (!flag) {
            index++;
        }
    }
 
    public static void main(String[] args) throws Exception {
        VolatileTest volatileTest = new VolatileTest();
        volatileTest.start();
 
        Thread.sleep(1000);
 
        // 模擬屢次寫入,並觸發JIT
        for (int i = 0; i < 10000000; i++) {
            volatileTest.flag = true;
        }
        System.out.println(volatileTest.index);
    }
}

運行能夠發現,當 volatileTest.index 輸出打印以後程序仍然未中止,表示線程依然處於運行狀態,子線程讀取到的flag的值仍爲false。

private volatile boolean flag = false;

嘗試給flag增長volatile關鍵字後程序能夠正常結束, 則表示子線程讀取到的flag值爲更新後的true。

那麼爲何volatile能夠保證可見性呢?

能夠嘗試在JDK中下載hsdis-amd64.dll後使用參數-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 運行程序,能夠看到程序被翻譯後的彙編指令,發現增長volatile關鍵字後給flag賦值時彙編指令多了一段 "lock addl $0x0,(%rsp)"

說明volatile保證了可見性正是這段lock指令起到的做用,查閱IA-32手冊,能夠得知該指令的主要做用:

  • 鎖總線,其它CPU對內存的讀寫請求都會被阻塞,直到鎖釋放,不過實際後來的處理器都採用鎖緩存替代鎖總線,由於鎖總線的開銷比較大,鎖總線期間其餘CPU無法訪問內存。
  • lock後的寫操做會回寫已修改的數據,同時讓其它CPU相關緩存行失效,從而從新從主存中加載最新的數據。
  • 不是內存屏障卻能完成相似內存屏障的功能,阻止屏障兩遍的指令重排序。

3.3 volatile 禁止指令重排

Java 語言規範規定JVM線程內部維持順序化語義。即只要程序的最終結果與它順序化狀況的結果相等,那麼指令的執行順序能夠與代碼順序不一致,此過程叫指令的重排序。指令重排序的意義是什麼?

JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)適當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。

如下是源代碼到最終執行的指令集的示例圖:

as-if-serial原則:無論怎麼重排序,單線程程序下編譯器和處理器不能對存在數據依賴關係的操做作重排序。可是,若是操做之間不存在數據依賴關係,這些操做就可能被編譯器和處理器重排序。

下面是一段經典的發生指令重排致使結果預期不符的例子:

public class VolatileTest {
 
    int a, b, x, y;
 
    public boolean test() throws InterruptedException {
        a = b = 0;
        x = y = 0;
        Thread t1 = new Thread(() -> {
            a = 1;
            x = b;
        });
        Thread t2 = new Thread(() -> {
            b = 1;
            y = a;
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
 
        if (x == 0 && y == 0) {
            return true;
        } else {
            return false;
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            VolatileTest volatileTest = new VolatileTest();
            if (volatileTest.test()) {
                System.out.println(i);
                break;
            }
        }
    }
}

按照咱們正常的邏輯理解,在不出現指令重排的狀況下,x、y永遠只會有下面三種狀況,不會出現都爲0,即循環永遠不會退出。

  1. x = 一、y = 1
  2. x = 一、y = 0
  3. x = 0、y = 1

可是當咱們運行的時候會發現一段時間以後循環就會退出,即出現了x、y都爲0的狀況,則是由於出現了指令重排,時線程內的對象賦值順序發生了變化。

而這個問題給參數增長volatile關鍵字便可以解決,此處是由於JMM針對重排序問題限制了規則表。

爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。一個讀的操做爲load,寫的操做爲store。

對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能。爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略。

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

以上圖爲例,普通寫與volatile寫之間會插入一個StoreStore屏障,另外有一點須要注意的是,volatile寫後面可能有的volatile讀/寫操做重排序,由於編譯器經常沒法準確判斷是否須要插入StoreLoad屏障。

則JMM採用了比較保守的策略:在每一個volatile寫的後面插入一個StoreLoad屏障。

那麼存彙編指令的角度,CPU是怎麼識別到不一樣的內存屏障的呢:

(1)sfence:實現Store Barrior 會將store buffer中緩存的修改刷入L1 cache中,使得其餘cpu核能夠觀察到這些修改,並且以後的寫操做不會被調度到以前,即sfence以前的寫操做必定在sfence完成且全局可見。

(2)lfence:實現Load Barrior 會將invalidate queue失效,強制讀取入L1 cache中,並且lfence以後的讀操做不會被調度到以前,即lfence以前的讀操做必定在lfence完成(並未規定全局可見性)。

(3)mfence:實現Full Barrior 同時刷新store buffer和invalidate queue,保證了mfence先後的讀寫操做的順序,同時要求mfence以後寫操做結果全局可見以前,mfence以前寫操做結果全局可見。

(4)lock:用來修飾當前指令操做的內存只能由當前CPU使用,若指令不操做內存仍然由用,由於這個修飾會讓指令操做自己原子化,並且自帶Full Barrior效果。

因此能夠發現咱們上述分析到的"lock addl"指令也是能夠實現內存屏障效果的。

4、volatile 拓展

4.1 濫用 volatile 的危害

通過上述的總結咱們能夠知道volatile的實現是根據MESI緩存一致性協議實現的,而這裏會用到CPU的嗅探機制,須要不斷對總線進行內存嗅探,大量的交互會致使總線帶寬達到峯值。所以濫用volatile可能會引發總線風暴,除了volatile以外大量的CAS操做也可能會引起這個問題。因此咱們使用過程當中要視狀況而定,適當的場景下能夠加鎖來保證線程安全。

4.2 如何不用 volatile 不加鎖禁止指令重排?

指令重排的示例中咱們既然已經知道了插入內存屏障能夠解決重排問題,那麼用什麼方式能夠手動插入內存屏障呢?

JDK1.8以後能夠在Unsafe魔術類中發現新增了插入屏障的方法。

/**
 * Ensures lack of reordering of loads before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void loadFence();
 
/**
 * Ensures lack of reordering of stores before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void storeFence();
 
/**
 * Ensures lack of reordering of loads or stores before the fence
 * with loads or stores after the fence.
 * @since 1.8
 */
public native void fullFence();

(1)loadFence()表示該方法以前的全部load操做在內存屏障以前完成。

(2)storeFence()表示該方法以前的全部store操做在內存屏障以前完成。

(3)fullFence()表示該方法以前的全部load、store操做在內存屏障以前完成。

能夠看到這三個方法正式對應了CPU插入內存屏障的三個指令lfence、sfence、mfence。

所以咱們若是想手動添加內存屏障的話,能夠用Unsafe的這三個native方法完成,另外因爲Unsafe必須由bootstrap類加載器加載,因此咱們想使用的話須要用反射的方式拿到實例對象。

/**
 * 反射獲取到unsafe
 */
private Unsafe reflectGetUnsafe() throws NoSuchFieldException, IllegalAccessException {
    Field field = Unsafe.class.getDeclaredField("theUnsafe");
    field.setAccessible(true);
    return (Unsafe) field.get(null);
}
 
 
// 上述示例中手動插入內存屏障
Thread t1 = new Thread(() -> {
    a = 1;
    // 插入LoadStore()屏障
    reflectGetUnsafe().storeFence();
    x = b;
});
Thread t2 = new Thread(() -> {
    b = 1;
    // 插入LoadStore()屏障
    reflectGetUnsafe().storeFence();
    y = a;
});

4.3 單例模式的雙重檢查鎖爲何須要用 volatile

如下是單例模式雙重檢查鎖的初始化方式:

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

由於synchronized雖然加了鎖,可是代碼塊內的程序是沒法保證指令重排的,其中instance = new Singleton(); 方法實際上是拆分紅多個指令,咱們用javap -c 查看字節碼,能夠發現這段對象初始化操做是分紅了三步:

(1)new :建立對象實例,分配內存空間

(2)invokespecial :調用構造器方法,初始化對象

(3)aload_0 :存入局部方法變量表

以上三步若是順序執行的話是沒問題的,可是若是二、3步發生指令重排,則極端併發狀況下可能出現下面這種狀況:

因此,爲了保證單例對象順利的初始化完成,應該給對象加上volatile關鍵字禁止指令重排。

5、總結

隨着計算機和CPU的逐步升級,CPU緩存幫咱們大大提升了數據讀寫的性能,在高併發的場景下,CPU經過MESI緩存一致性協議針對緩存行的失效進行處理。基於JMM模型,將用戶態和內核態進行了劃分,經過java提供的關鍵字和方法能夠幫助咱們解決原子性、可見性、有序性的問題。其中volatile關鍵字的使用最爲普遍,經過添加內存屏障、lock彙編指令的方式保證了可見性和有序性,在咱們開發高併發系統的過程當中也要注意volatile關鍵字的使用,可是不能濫用,不然會致使總線風暴。

參考資料

  1. 書籍:《java併發編程實戰》
  2.  IA-32手冊
  3. 雙重檢查鎖爲何要使用volatile?
  4.  java內存模型總結
  5. Java 8 Unsafe: xxxFence() instructions
做者:push
相關文章
相關標籤/搜索