【多線程與高併發原理篇:3_java內存模型】

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協議規範,以及不同硬件底層的實現邏輯,比如英特爾的操作手冊,後面有時間再接着深入。

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