Java 並發編程之 JMM & volatile 詳解


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

一、現代計算機理論模型與工作原理

1.1 馮諾依曼計算機模型

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

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

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

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

 

  • 五大核心組成部分:

 

  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的值的話需要重新從主內存中讀取。

二、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保證每個時刻是有一個線程執行同步代碼,相當於是讓線程順序執行同步代碼,自然就保證了有序性。

三、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 = 1、y = 1

  2. x = 1、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"指令也是可以實現內存屏障效果的。

四、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 :存入局部方法變量表

以上三步如果順序執行的話是沒問題的,但是如果2、3步發生指令重排,則極端並發情況下可能出現下面這種情況:

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

五、總結

隨着計算機和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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM