【多執行緒與高併發原理篇:3_java記憶體模型】

小豬爸爸發表於2022-04-23

1. 概述

Java 記憶體模型即 Java Memory Model,簡稱 JMM。從抽象的角度來看,JMM 定義了執行緒和主記憶體之間的抽象關係,執行緒之間的共享變數儲存在主記憶體中,每個執行緒都有一個私有的工作記憶體,工作記憶體中儲存了該執行緒以讀/寫共享變數的副本。工作記憶體是 JMM 的一個抽象概念,並不真實存在。它涵蓋了快取、寫緩衝區、暫存器以及其他的硬體和編譯器優化。

Java記憶體模型是跟cpu快取模型是類似的,基於cpu快取模型來建立的Java記憶體模型,只不過Java記憶體模型是標準化的,遮蔽掉底層不同的計算機的區別。

2. Java記憶體模型帶來的問題

Java記憶體模型規定了執行緒對主記憶體的操作具備原子性,包括以下8個操作:
lock:主記憶體,標識變數為執行緒獨佔;
unlock:主記憶體,解鎖執行緒獨佔變數;
read:主記憶體,讀取記憶體到執行緒快取(工作記憶體);
load:工作記憶體,read後的值放入執行緒本地變數副本;
use:工作記憶體,傳值給執行引擎;
assign:工作記憶體,執行引擎結果賦值給執行緒本地變數;
store:工作記憶體,存值到主記憶體給write備用;
write:主記憶體,寫變數值。

假設如下程式,兩個未加同步控制的執行緒去同時對i自增,會出現什麼結果呢?

public class Test {
    private int i = 0;
    public void increment() {
        i++;
        System.out.println("i=" + i);
    }

    public static void main(String[] args) {
        Test t = new Test();
        new Thread(() -> t.increment()).start();
        new Thread(() -> t.increment()).start();
    }
}

通過執行會出現下面三種情況

i=1
i=1

或者

i=1
i=2

或者

i=2
i=2

下面通過圖來解釋第一種情況

A、B兩個執行緒都有自己的工作記憶體,A自從執行read操作,從主記憶體讀取i=0,隨後load操作載入自己的工作記憶體,接著執行use操作,對i進行自增,然後從新賦值操作assign,此時執行緒A的工作記憶體i=1,隨後store操作進行儲存,最後寫回到主記憶體,最終i=1。

B執行緒也進行如此操作,read->load->use->assign->store->write,最終也得出i=1。

出現第二種,關鍵在於B執行緒read操作是從A執行緒重新整理到主記憶體後才去取值的。執行順序是:執行緒A自增->執行緒A列印i最終值->執行緒B自增->執行緒B列印i最終值,如下圖

出現第三種,是執行緒A自增後把i=1重新整理到主記憶體,在執行列印之前,執行緒B優先從主記憶體獲取i=1,進行read->load->use->assign->store->write,將i=1自增為i=2,隨後執行緒A執行列印操作,執行順序是:執行緒A自增->執行緒B自增->執行緒A列印i最終值->執行緒B列印i最終值,如下圖

3. 可見性、有序性、原子性

雖然java記憶體模型JMM提供為每個執行緒提供了每個工作記憶體,存放共享變數的變數副本,但是如果執行緒沒有作可見性的控制,從上述過程中可以看出,多執行緒下對共享變數的修改,其結果依然是不可預知的。

3.1 可見性

volatile關鍵詞,在程式級別,保證對一個共享變數的修改對另外執行緒立馬可見。上述程式對i加入volatile關鍵字,可以保證能始終得到第二種結果。

下面用程式來演示:

Class VolatileExample {
	int  a = 0;
	volatile boolean flg = false;
	
	public void writer() {
		a = 1;
		flg = true;
	}
	
	public void reader() {
		if (flg) {
			int i = a;
			......
		}
	}
}

圖解如下:

上述過程概括為兩句話:
當寫一個volatile修飾的變數時,JMM會把執行緒對應的本地記憶體中的共享變數值重新整理的主記憶體;
當讀一個volatile修飾的變數時,JMM會把該執行緒對應的本地記憶體置為無效,從主記憶體讀取最新的共享變數的值。
上述過程解釋了volatile的可見性問題。

3.2 有序性

對於一些程式碼,編譯器或者處理器,為了提高程式碼執行效率,會將指令重排序,就是說比如下面的程式碼:

flg = false;
//執行緒1:
parpare(); // 準備資源
flg = true;

//執行緒2:
while(!flg) {
	Thread.sleep(1000);
}
execute();// 基於準備好的資源執行操作

重排序之後,讓flag = true先執行了,會導致執行緒2直接跳過while等待,執行某段程式碼,結果prepare()方法還沒執行,資源還沒準備好呢,此時就會導致程式碼邏輯出現異常。

volatile通過記憶體屏障,保證volatile修飾的變數,與其前後定義的值,不發生指令重排。JMM定義瞭如下四種記憶體屏障StoreStore、StoreLoad、LoadLoad、LoadStore;

對於volatile寫,在前面插入StoreStore,禁止上面的普通讀與下面的volatile寫重排序;後面插入StoreLoad,禁止上面的volatile寫與下面的普通讀重排序,如下圖:

對於volatile讀,在後面插入LoadLoad,禁止上面的volatile讀與下面的普通讀重排序;下面再插入LoadStore,禁止上面的volatile讀與下面的普通寫重排序,如下圖:

happens-before原則

為了保證多執行緒之間在某些情況下一定不能發生指令重排,java記憶體模型規定了8條原則。

  1. 程式次序規則 :一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作;

  2. 管程鎖定規則:一個unLock操作先行發生於後面對同一個鎖的lock操作;

  3. volatile變數規則:對一個變數的寫操作先行發生於後面對這個變數的讀操作;

  4. 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作;

  5. 執行緒終結規則:執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行;

  6. 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生;

  7. 物件終結規則:一個物件的初始化完成先行發生於他的finalize()方法的開始;

  8. 傳遞性:如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A先行發生於操作C;

3.3 原子性

一般情況下,volatile修飾的變數是不能保證原子性的,例如i++是複合操作,先讀取,再修改變數的值,是不具備原子性的

4. volatile作用

通過上面的描述,可以得出volatile的作用主要有兩點:

  • 保證執行緒可見性
  • 禁止指令重排序

5. HotSpot層面實現

通過hsdis工具檢視java彙編檔案,首先下載hsdis-amd64.dll到 \jdk1.8\jre\bin ,然後設定VM引數,-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly

最終執行時會在volatile變數前加如下資訊

lock addl $0x0,(%rsp)  

如下圖:

6. 底層CPU硬體層面實現

上述過程中,JVM虛擬機器會向CPU傳送lock前置指令,將這個變數所在的快取行資料寫回主記憶體,如果其他CPU快取的值是舊值,就會有問題,在多CPU(這裡指多個核)下,每個CPU都會通過嗅探匯流排上傳播的資料是否與自己的快取一致,通過快取一致性協議,最終保證多個CPU內部快取資料的一致性,下面通過圖來說明。

虛擬機器的lock字首指令,在底層硬體是通過快取一致性協議來完成的,不同的CPU快取一致性協議不一樣, 有MSI、MESI、MOSI、Synapse、Firefly及Dragon,英特爾CPU的快取一致性協議是通過MESI來完成的。

為了實現MESI協議,需要解釋兩個專業術語:flush處理器快取refresh處理器快取

flush處理器快取,他的意思就是把自己更新的值重新整理到快取記憶體裡去(或者是主記憶體),因為必須要刷到快取記憶體(或者是主記憶體)裡,才有可能在後續通過一些特殊的機制讓其他的處理器從自己的快取記憶體(或者是主記憶體)裡讀取到更新的值。除了flush以外,他還會傳送一個訊息到匯流排(bus),通知其他處理器,某個變數的值被他給修改了。

refresh處理器快取,他的意思就是說,處理器中的執行緒在讀取一個變數的值的時候,如果發現其他處理器的執行緒更新了變數的值,必須從其他處理器的快取記憶體(或者是主記憶體)裡,讀取這個最新的值,更新到自己的快取記憶體中。所以說,為了保證可見性,在底層是通過MESI協議、flush處理器快取和refresh處理器快取,這一整套機制來保障的。

flush和refresh,這兩個操作,flush是強制重新整理資料到快取記憶體(主記憶體),不要僅僅停留在寫緩衝器裡面;refresh,是從匯流排嗅探發現某個變數被修改,必須強制從其他處理器的快取記憶體(或者主記憶體)載入變數的最新值到自己的快取記憶體裡去。

7. 總結

本篇主要講述了Java記憶體模型的作用,遮蔽了底層實現的細節,同時帶來了一系列問題,導致執行緒之間的三大問題,即有序性、可見性、原子性,volatile關鍵字修飾的變數在多執行緒之間的作用,以及初步分析了底層是如何實現的,如果要深入分析,這個得具體看MESI協議規範,以及不同硬體底層的實現邏輯,比如英特爾的操作手冊,後面有時間再接著深入。

相關文章