從零開始理解 Java 內存模型——可見性與有序性詳解
一、詳解指令重排序問題
1.什么是重排序問題
代碼在執行過程從,不同層級的運行為了提高最終指令執行效率,都會對執行響應重排序,以Java程序為例,從編譯到執行會經歷:
- 生成指令階段:編譯器重排,該階段JMM通過禁止特定類型的編譯器重排序達到要求。
- 處理器階段:指令并行重排序和內存系統加載重排序,這種處理器級別的重排序問題,則是要求編譯器在生成指令階段通過插入內存屏障即memory barriers指令禁止特定方式重排序。
2.編譯器重排序
編譯器(包括 JVM、JIT 編譯器等)重排序即不影響單線程執行結果的情況下,會針對性的重排代碼的效率以提高單線程情況下代碼執行效率。當然這種重排序可能也會存在一些問題,假設我們現在有這樣一段代碼,雙方先對各自的localNum初始化,然后用變量x、y讀取變量localNum的值,假設發生指令重排序就會導致x、y拿到默認的零值而輸出0:
對于這種情況,JMM會針對性發生這種重排序的編譯器進行禁止來解決這種問題。
3.指令重排序
現代的處理器會對某些指令進行重疊執行(采用指令級并行技術(Instruction-Level Parallelism,ILP),亦或者在不影響執行結果的情況下會將Java字節碼對應的機器碼指令進行順序調換以提高單線程下代碼的執行效率,這種問題的表象和上述情況類似,這里也就不再演示了。
4.內存系統重排序
該方式排序并不是真正意義上的重排序,在JMM上常常表現為主存和本地內存的數據不一致。
5.如何避免指令重排序
這一點其實在上述各種重排序都已經簡單的說明了,對于編譯器,會禁止特定類型的編譯器重排序來避免編譯器重排序在多線程情況下帶來的問題。對于指令重排序即處理器重排序,JVM生成程序指令序列時,會根據情況插入特定的內存屏障(Memory Barrier)來相關指令來告知處理器避免特定類型的指令重排序。
二、詳解Java內存模型JMM
1.什么是JMM模型
為了屏蔽不同操作系統之間操作系統內存模型的差異,Java定義了屬于自己的內存模型規范解決這個問題。 JMM也可以理解為針對Java并發編程的一組規范,抽象了線程和主內存之間的關系,以類似于volatile、synchronized等關鍵字以解決并發場景下重排序帶來的問題。
JMM規定所有示例對象都必須放置在主存中,所以每個線程需要操作這些數據時就需要將數據拷貝一份到本地內存中在進行相應的操作。
而每個Java將主存中拷貝的變量在完成操作后寫回主存中會經歷以下過程:
- lock:首先將變量鎖住,將這個共享變量設置為線程獨占變量。
- read:將主存的共享變量讀取到本地內存中。
- load:將變量load拷貝一份到本地內存中生成共享變量的副本。
- use:將共享變量副本放到執行引擎中。
- assign:將共享變量副本賦值給本地內存的變量。
- store:將變量放到主內存中
- write:寫入主內存對應變量中
- unlock:解鎖,該共享變量此時就可以被其他線程操作了。
同時,JMM模型還規定這些操作還得符合以下規范:
- 線程沒有發任何assign操作的變量不可以寫回主內存中。
- 新的變量只能在主內存中誕生。這就意味的線程中的變量必須是通過load從主存加載后再通過assign得到的。
- 一個線程通過lock鎖定主內存變量共享變量時,這個線程可以對其上無數次鎖(即線程可重入),其他線程就不能在對其上鎖了。
- 一個線程沒有lock一個共享變量,就不能對其進行unlock。
- 在執行use操作前,必須清空本地內存,通過load或者assign初始化變量值才可操作本地變量。
2.JVM和JMM有何區別(重點)
JVM規定了運行時的區域劃分,例如實例對象必須放置在堆區等。 而JMM則決定了線程和和主內存之間的關系,例如共享變量必須存放在主內存中。通過定義一系列規范和原則簡化用戶實現并發編程的種種操作且確保Java代碼從編譯到轉為CPU機器碼執行結果都是準確無誤的,也就是說JMM是一種內存模型語義的抽象并非實際的內存模型。
3.什么是happens-before原則?常見的happens-before原則有哪些?
happens-before也是一種JMM內存模型用來闡述內存可見性的一種規約,對應的happens-before原則共有8條,而常見的有以下5條:
- 程序順序規則 :寫前面的變量happens-before于后面的代碼。
- 傳遞規則: A happens-before B,B happens-before C,那么A happens-before C。
- volatile 變量規則: volatile的變量的寫操作, happens-before后續讀該變量的代碼。
- 線程啟動規則 :Thread的start都有先于后面對于該線程的操作。
- 解鎖規則:對一個鎖的解鎖操作happens-before對這個鎖的加鎖操作。
對于不會影響單線程或者多線程指令重排序操作不做要求,即不會過分干預編譯器和處理器的大部分優化操作,例如下面這段代碼,在單線程情況下,因為兩者聲明沒有任何關聯,處理器為了提高程序執行的并行度完全可以不管任何順序任意執行,這也就是我們常說的as-if-serial,即沒有強關聯的指令,處理器可以根據自己的優化算法執行,任意重排序,對外結果好像就是串行執行一樣:
而對于某些場景, JMM對于編譯器或處理的某些會影響指令重排序的操作進行禁止,如下所示,getOne和getTwo先于最后計算,計算依賴于前兩個變量,操作即兩個get操作happens-before于最后的計算,但是兩個get操作沒有強關聯,所以JVM這兩段代碼進行指令重排序的時候,JMM是允許的,所以執行時getTwo可能會先于getOne執行。
與之相反就是最后的計算,因為依賴于前兩個get,所以JMM模型是明確要求禁止這種情況,于是就提出了happens-before原則,即寫前面的變量happens-before于后面的代碼以及A happens-before B,B happens-before C,那么A happens-before C,按照我們的例子就是每一個get操作都會按照順序寫,因為1操作先于2先于3,所以最終執行順序就是1、2、3:
public static void main(String[] args) {
int one = getOne();//1
int two = getTwo();//2
System.out.println(one + two);//3
}
private static int getOne() {
return 1;
}
private static int getTwo() {
return 2;
}
4.happens-before和JMM有什么關系
JMM原則和禁止重排序的遵循的準則都是基于 happens-before準則要求,也就是要求針對編譯器的指令重排序必須根據該準則通過某種方式落實,最常見的方式就是在生成執行指令前插入內存屏障讓處理器知曉那些指令不可重排序來解決問題,由此實現程序員只需理解happens-before原則的抽象即可理解可見性,由此避免底層編譯器和處理器具體的實現:
5.JMM規范如何解決處理器指令重排序問題
為了保證內存可見性,編譯器在生成指令指令序列時通過內存屏障指令來禁止特定類型的處理器重排序問題,對應的屏障指令有:
- loadload:先加載load1先于后load2的操作。
- loadstore:load1的操作先于后store及其后續存儲指令刷新到內存。
- storestore:store1的數據對其他處理器可見,且先于后store及其后續的寫指令。
- storeload:先store的操作對于后load可見,即先store操作會刷新到內存這一步先于后續load的后續讀指令。
所以對于多核CPU對彼此內存操作不可見導致數據錯亂,我們可以直接通過storeload指令來解決該問題: