1. 程式人生 > 其它 >JUC(3)Java記憶體模型JMM

JUC(3)Java記憶體模型JMM

因為CPU的快取導致CPU的速度比物理主記憶體的速度快很多,CPU的執行並不是直接操作記憶體,而是先把記憶體裡邊的資料讀到快取,而記憶體的讀和寫操作的時候就會造成不一致的問題。

Java虛擬機器規範中試圖定義一種Java記憶體模型(java Memory Model,簡稱JMM) 來遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。本身是一種抽象的概念,實際上並不存在,它僅僅描述的是一組規則或規範,通過這組規範定義了程式中各個變數的訪問方式。(這裡所說的變數指的是例項變數和類變數,不包含區域性變數,因為區域性變數是執行緒私有的,因此不存在競爭問題)

JMM關於同步的規定:

  • 執行緒解鎖前,必須把共享變數的值重新整理回主記憶體
  • 執行緒加鎖前,必須讀取主記憶體的最新值,到自己的工作記憶體
  • 加鎖和解鎖是同一把鎖

由於JVM執行程式的實體是執行緒,而每個執行緒建立時JVM都會為其建立一個工作記憶體(有些地方稱為棧空間),工作記憶體是每個執行緒的私有資料區域,而Java記憶體模型中規定所有變數都儲存在主記憶體,主記憶體是共享記憶體區域,所有執行緒都可以訪問,但執行緒對變數的操作(讀取賦值等)必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫會主記憶體,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中儲存著主記憶體中的變數副本拷貝,因此不同的執行緒間無法訪問對方的工作記憶體,執行緒間的通訊(傳值)必須通過主記憶體來完成,其簡要訪問過程:

上圖提到了兩個概念:主記憶體 和 工作記憶體

  • 主記憶體:就是計算機的實體記憶體。
  • 工作記憶體:存放在CPU LN快取,也就是CPU暫存器。如下圖我們例項化 new student,那麼 age = 25 也是儲存在主記憶體中
    • 當同時有三個執行緒同時訪問 student中的age變數時,那麼每個執行緒都會拷貝一份到各自的工作記憶體,從而實現了變數的拷貝

即:JMM記憶體模型的可見性,指的是當主記憶體區域中的值被某個執行緒寫入更改後,其它執行緒會馬上知曉更改後的值,並重新得到更改後的值。如果沒有可見性就會導致髒讀的現象:

JMM資料同步的八大原子操作

一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步到主記憶體之間的實現細節,Java記憶體模型定義了以下八種操作來完成

  • lock(鎖定):作用於主記憶體的變數,把一個變數標記為一條執行緒獨佔狀態

  • unlock(解鎖):作用於主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後 的變數才可以被其他執行緒鎖定

  • read(讀取):作用於主記憶體的變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體 中,以便隨後的load動作使用

  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工 作記憶體的變數副本中

  • use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎

  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作內 存的變數

  • store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體 中,以便隨後的write的操作

  • write(寫入):作用於工作記憶體的變數,它把store操作從工作記憶體中的一個變數的值 傳送到主記憶體的變數中

    如果要把一個變數從主記憶體中複製到工作記憶體中,就需要按順序地執行read和load操作,如果把變數從工作記憶體中同步到主記憶體中,就需要按順序地執行store和write操作。但Java記憶體模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行

public class CodeVisibility {
    private static boolean initFlag = false;
    private volatile static int counter = 0;

    public static void refresh(){
        log.info("refresh data.......");
        initFlag = true;
        log.info("refresh data success.......");
    }

    public static void main(String[] args){
        Thread threadA = new Thread(()->{
            while (!initFlag){
                //System.out.println("runing");
                counter++;
            }
            log.info("執行緒:" + Thread.currentThread().getName() + "當前執行緒嗅探到initFlag的狀態的改變");
        },"threadA");
        threadA.start();

        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread threadB = new Thread(()->{
            refresh();
        },"threadB");
        threadB.start();
    }
}

小總結

  • 我們定義的所有共享變數都儲存在物理主記憶體中
  • 每個執行緒都有自己獨立的工作記憶體,裡面儲存該執行緒使用到的變數的副本(主記憶體中該變數的一份拷貝)
  • 執行緒對共享變數所有的操作都必須先線上程自己的工作記憶體中進行後寫回主記憶體,不能直接從主記憶體中讀寫(不能越級)
  • 不同執行緒之間也無法直接訪問其他執行緒的工作記憶體中的變數,執行緒間變數值的傳遞需要通過主記憶體來進行(同級不能相互訪問)

快取一致性

為什麼這裡主執行緒中某個值被更改後,其它執行緒能馬上知曉呢?其實這裡是用到了匯流排嗅探技術

在說嗅探技術之前,首先談談快取一致性的問題,就是當多個處理器運算任務都涉及到同一塊主記憶體區域的時候,將可能導致各自的快取資料不一。

為了解決快取一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議進行操作,這類協議主要有MSI、MESI等等。

MESI

當CPU寫資料時,如果發現操作的變數是共享變數,即在其它CPU中也存在該變數的副本,會發出訊號通知其它CPU將該記憶體變數的快取行設定為無效,因此當其它CPU讀取這個變數的時,發現自己快取該變數的快取行是無效的,那麼它就會從記憶體中重新讀取。

匯流排嗅探

那麼是如何發現數據是否失效呢?

這裡是用到了匯流排嗅探技術,就是每個處理器通過嗅探在總線上傳播的資料來檢查自己快取值是否過期了,當處理器發現自己的快取行對應的記憶體地址被修改,就會將當前處理器的快取行設定為無效狀態,當處理器對這個資料進行修改操作的時候,會重新從記憶體中把資料讀取到處理器快取中。

匯流排風暴

匯流排嗅探技術有哪些缺點?

由於Volatile的MESI快取一致性協議,需要不斷的從主記憶體嗅探和CAS迴圈,無效的互動會導致匯流排頻寬達到峰值。因此不要大量使用volatile關鍵字,至於什麼時候使用volatile、什麼時候用鎖以及Syschonized都是需要根據實際場景的。

JMM的特性

  • 可見性

    當一個執行緒修改了某一個共享變數的值,其他執行緒是否能夠立即知道該變更 ,JMM規定了所有的變數都儲存在主記憶體中。

  • 原子性

    一個操作是不可中斷的,即多執行緒環境下,操作不能被其他執行緒干擾

  • 有序性

    對於一個執行緒的執行程式碼而言,我們總是習慣性認為程式碼的執行總是從上到下,有序執行。但為了提供效能,編譯器和處理器通常會對指令序列進行重新排序。指令重排可以保證序列語義一致,但沒有義務保證多執行緒間的語義也一致,即可能產生髒讀,簡單說,兩行以上不相干的程式碼在執行的時候有可能先執行的不是第一條,不見得是從上到下順序執行,執行順序會被優化。
    ![image-20210728213838507](/Users/zorantaylor/Library/Application Support/typora-user-images/image-20210728213838507.png)

指令重排序

單執行緒環境裡面確保程式最終執行結果和程式碼順序執行的結果一致。

處理器在進行重排序時必須要考慮指令之間的*資料依賴性*

多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測

指令重排 - example 1

public void mySort() {
	int x = 11;//1
	int y = 12;//2
	x = x + 5;//3
	y = x * x;//4
}

按照正常單執行緒環境,執行順序是 1 2 3 4

但是在多執行緒環境下,可能出現以下的順序:

  • 2 1 3 4
  • 1 3 2 4

上述的過程就可以當做是指令的重排,即內部執行順序,和我們的程式碼順序不一樣

但是指令重排也是有限制的,即不會出現下面的順序

  • 4 3 2 1

因為處理器在進行重排時候,必須考慮到指令之間的資料依賴性

例子

int a,b,x,y = 0

執行緒1 執行緒2
x = a; y = b;
b = 1; a = 2;
x = 0; y = 0

因為上面的程式碼,不存在資料的依賴性,因此編譯器可能對資料進行重排

執行緒1 執行緒2
b = 1; a = 2;
x = a; y = b;
x = 2; y = 1

這樣造成的結果,和最開始的就不一致了,這就是導致重排後,結果和最開始的不一樣,因此為了防止這種結果出現,volatile就規定禁止指令重排,為了保證資料的一致性

指令重排 - example 2

比如下面這段程式碼

/**
 * ResortSeqDemo
 */
public class ResortSeqDemo {
    int a= 0;
    boolean flag = false;

    public void method01() {
        a = 1;//1
        flag = true;//2
    }

    public void method02() {
        if(flag) {
            a = a + 5;
            System.out.println("reValue:" + a);
        }
    }
}

我們按照正常的順序,分別呼叫method01()和 method02(()那麼,最終輸出就是 a = 6

但是如果在多執行緒環境下,因為方法1 和 方法2,他們之間不能存在資料依賴的問題,因此原先的順序可能是

a = 1;
flag = true;

a = a + 5;
System.out.println("reValue:" + a);
        

但是在經過編譯器,指令,或者記憶體的重排後,可能會出現這樣的情況

flag = true;//執行緒1

a = a + 5;//執行緒2
System.out.println("reValue:" + a);//執行緒2

a = 1;//執行緒1

也就是先執行 flag = true後,另外一個執行緒馬上呼叫方法2,滿足 flag的判斷,最終讓a + 5,結果為5,這樣同樣出現了資料不一致的問題

為什麼會出現這個結果:多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測。

happens-before

在JMM中, 如果一個操作執行的結果需要對另一個操作可見性 或者 指令重排序,那麼這兩個操作之間必須存在happens-before關係。

x = 5 執行緒A執行
y = x 執行緒B執行
上述稱之為:寫後讀

y是否等於5呢?如果執行緒A的操作(x= 5)happens-before(先行發生)執行緒B的操作(y = x),那麼可以確定執行緒B執行後y = 5 一定成立;

如果他們不存在happens-before原則,那麼y = 5 不一定成立。這就是happens-before原則的威力。包含可見性和有序性的約束

如果Java記憶體模型中所有的有序性都僅靠volatile和synchronized來完成,那麼有很多操作都將會變得非常囉嗦,但是我們在編寫Java併發程式碼的時候並沒有察覺到這一點。我們沒有時時、處處、次次,新增volatile和synchronized來完成程式,這是因為Java語言中JMM原則下有一個“先行發生”(Happens-Before)的原則限制和規矩。這個原則非常重要: 它是判斷資料是否存在競爭,執行緒是否安全的非常有用的手段。依賴這個原則,我們可以通過幾條簡單規則解決併發環境下兩個操作之間是否可能存在衝突的所有問題,而不需要陷入Java記憶體模型苦澀難懂的底層編譯原理之中。

總原則

  • 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前(可見性,有序性)
  • 兩個操作之間存在happens-before關係,並不意外著一定要按照happens-before原則制定的順序來執行。如果重排序之後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法(可以指令重排)(1+2+3=3+2+1)

8條規則

  1. 次序規則
    一個執行緒內,按照程式碼順序,寫在前面的操作先行發生於寫在後面的操作(強調的是一個執行緒),前一個操作的結果可以被後續的操作獲取。說白了就是前面一個操作把變數X賦值為1,那後面一個操作肯定能知道X已經變成了1

  2. 鎖定規則
    一個unlock操作先行發生於後面(這裡的"後面"是指時間上的先後)對同一個鎖的lock操作(上一個執行緒unlock了,下一個執行緒才能獲取到鎖,進行lock)

  3. volatile變數規則
    對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,前面的寫對後面的讀是可見的,這裡的"後面"同樣是指時間是的先後

  4. 傳遞規則
    如果操作A先行發生於操作B,而操作B又先行發生於操作C,則可以得出A先行發生於操作C

  5. 執行緒啟動規則(Thread Start Rule)
    Thread物件的start( )方法先行發生於執行緒的每一個動作

  6. 執行緒中斷規則(Thread Interruption Rule)

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

    可以通過Thread.interrupted( )檢測到是否發生中斷

  7. 執行緒終止規則(Thread Termination Rule)
    執行緒中的所有操作都先行發生於對此執行緒的終止檢測

  8. 物件終結規則(Finalizer Rule)
    物件沒有完成初始化之前,是不能呼叫finalized( )方法的

	private int value=0;
	public void setValue(){
	    this.value=value;
	}
	public int getValue(){
	    return value;
	}

假設存線上程A和B,執行緒A先(時間上的先後)呼叫了setValue(1),然後執行緒B呼叫了同一個物件的getValue(),那麼執行緒B收到的返回值是什麼?我們就這段簡單的程式碼分析happens-before的規則

  • 1 由於兩個方法是由不同的執行緒呼叫,不在同一個執行緒中,所以肯定不滿足程式次序規則;
  • 2 兩個方法都沒有使用鎖,所以不滿足鎖定規則;
  • 3 變數不是用volatile修飾的,所以volatile變數規則不滿足;
  • 4 傳遞規則肯定不滿足;

所以我們無法通過happens-before原則推匯出執行緒A happens-before執行緒B,雖然可以確認在時間上執行緒A優先於執行緒B指定,但就是無法確認執行緒B獲得的結果是什麼,所以這段程式碼不是執行緒安全的。那麼怎麼修復這段程式碼呢?

  • 把getter/setter方法都定義為synchronized方法
  • 把value定義為volatile變數,由於setter方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景