面試官:小夥子,能聊明白JMM給你SSP!我:嘚吧嘚吧一萬字,直接征服面試官!

JavaBuild發表於2024-03-16

寫在開頭

面試官:小夥子,JMM瞭解嗎?
我:JMM(Java Memory Model),Java記憶體模型呀,學過的!
面試官:那能給我詳細的聊一聊嗎,越詳細越好!
我:嗯~,確定越詳細越好?起碼得說一萬字,您有時間聽完?
面試官:你要是真能說一萬字全是乾貨的話,我當場拍板要你,給你SSP!
我:這可是您說的,瞧好吧!

為了拿到一個SSP級別的Offer,我開始瘋狂運轉我的大腦,將過去背的八股文與自我理解總結相結合,展開了對JMM(Java記憶體模型)漫長的介紹,內容有點長,同志們保持耐心看完哈。

JMM誕生的背景

在這篇文章《關於Java併發多執行緒的一點思考》中我們提到過Java多執行緒存在的問題,其中有一個關於多執行緒的原子性、可見性、有序性問題,當時針對這個問題我們給出過如下解釋:

“在一個Java程式或者說程序執行的過程中,會涉及到CPU、記憶體、IO裝置,這三者在讀寫速度上存在著巨大差異:CPU速度-優於-記憶體的速度-優於-IO裝置的速度

為了平衡這三者之間的速度差異,達到程式響應最大化,計算機、作業系統、編譯器都做出了自己的努力。

  • 計算機體系結構:給 CPU 增加了快取,均衡 CPU 和記憶體的速度差異;
  • 作業系統:增加了程序與執行緒,分時複用 CPU,均衡 CPU 和 IO 裝置的速度差異;
  • 編譯器:增加了指令執行重排序(這個也會帶來另外的問題,我們在後面的學習中會提到),更好地利用快取,提高程式的執行速度。

這種最佳化是充分必要的,但這種最佳化同時會給多執行緒程式帶來原子性、可見性和有序性的問題。”

為了解決以上問題Java記憶體模型(JMM)應運而生,當然,早期的JMM存在著很多問題,比如非常容易消弱編譯器的最佳化能力,但從JDK5開始,提出了JSR-133(Java Memory Model and Thread Specification Revision),用以規範和修訂Java記憶體模型與執行緒,我們接下來所提及的JMM都是基於新規範的。

JMM如何解決問題

對於 Java 來說,你可以把 JMM 看作是 Java 定義的併發程式設計相關的一組規範,除了抽象了執行緒和主記憶體之間的關係之外,其還規定了從 Java 原始碼到 CPU 可執行指令的這個轉化過程要遵守哪些和併發相關的原則和規範,其主要目的是為了簡化多執行緒程式設計,增強程式可移植性的。

開發者們可以利用這些規範,方便安全的使用多執行緒,甚至於不需要在乎它底層的原理,直接使用一些關鍵字和類(例如:volatile、synchronized、final,各種 Lock)就可以使多執行緒變得安全。

深刻理解JMM

為了更深刻的理解JMM,我們需要理解幾個概念:Java記憶體區域CPU快取指令重排序

Java記憶體模型與Java記憶體區域的區別?

這個問題是很多Java初學者容易搞混淆的,也是很多面試官在面試時喜歡考察的小知識點,雖然名字很相似,但它們的區別卻很大。

Java記憶體模型: 主要針對的是多執行緒環境下,如何在主記憶體與工作記憶體之間安全地執行操作,包括變數的可見性、指令重排、原子操作等,旨在解決由於多執行緒併發程式設計帶來的一些問題,它是一種規範或者說約束。

原子性:一個或者多個操作在 CPU 執行的過程中不被中斷的特性;
可見性:一個執行緒對共享變數的修改,另外一個執行緒能夠立刻看到;
有序性:程式執行的順序按照程式碼的先後順序執行;

Java記憶體區域: 是指Java程式在JVM上執行時所流轉的區域,因此也叫"Java執行時記憶體區域",主要包括以下幾個部分(這裡指JDK1.7,在1.8後方法區被元空間替代,在後面的JVM學習中會詳細講述):

  1. 方法區:儲存了每一個類的結構資訊,如執行時常量池、欄位和方法資料、構造方法和普通方法的位元組碼內容。
  2. :幾乎所有的物件例項以及陣列都在這裡分配記憶體。這是 Java 記憶體管理的主要區域。
  3. :每一個執行緒有一個私有的棧,每一次方法呼叫都會建立一個新的棧幀,用於儲存區域性變數、運算元棧、動態連結、方法出口等資訊。所有的棧幀都是在方法呼叫和方法執行完成之後建立和銷燬的。
  4. 本地方法棧:與棧類似,不過本地方法棧為 JVM 使用到的 native 方法服務。
  5. 程式計數器:每個執行緒都有一個獨立的程式計數器,用於指示當前執行緒執行到了位元組碼的哪一行。
    image

CPU快取

在上文中我們提到過CPU、記憶體、IO裝置,這三者在讀寫速度存在差異,而CPU 快取就是為了解決 CPU 處理速度和記憶體處理速度不對等的問題。
image

如上圖為一個4核CPU的快取架構圖,在CPU快取中一般分為3級,越靠近CPU的快取,速度越快,價格越高,L1與L2為CPU私有,L3為多CPU共用快取。

CPU快取的工作方式:先將資料複製到CPU快取中,查詢時一級級向下查詢,一旦找到結果就返回,不再向下遍歷,若三級快取都沒查到,才會去主存(記憶體)中去查,然後開始運算,並將運算結果寫回主存中,這時若發生多執行緒同時讀寫的話,就會存在可見性(記憶體快取不一致)問題,我們寫個小demo模擬一下。

【程式碼示例1】

public class Test {
    //是否停止 變數
    private static boolean stop = false;
    public static void main(String[] args) throws InterruptedException {
        //啟動執行緒 1,當 stop 為 true,結束迴圈
        new Thread(() -> {
            System.out.println("執行緒 1 正在執行...");
            while (!stop) ;
            System.out.println("執行緒 1 終止");
        }).start();
        //休眠 1 秒
        Thread.sleep(1000);
        //啟動執行緒 2, 設定 stop = true
        new Thread(() -> {
            System.out.println("執行緒 2 正在執行...");
            stop = true;
            System.out.println("設定 stop 變數為 true.");
        }).start();
    }
}

輸出:

執行緒 1 正在執行...
執行緒 2 正在執行...
設定 stop 變數為 true.

原因:
我們會發現,執行緒1執行起來後,休眠1秒,啟動執行緒2,可即便執行緒2把stop設定為true了,執行緒1仍然沒有停止,這個就是因為 CPU 快取導致的可見性導致的問題。執行緒 2 設定 stop 變數為 true,執行緒 1 在 CPU 1上執行,讀取的 CPU 1 快取中的 stop 變數仍然為 false,執行緒 1 一直在迴圈執行。
image

解決辦法:

JMM告訴我們可以透過 volatile、synchronized、Lock介面、Atomic 型別保障可見性;還有一種就是在快取與主存之間增加快取一致性協議,如MSI,MESI等協議,協議包括CPU 快取記憶體與主記憶體互動的時候需要遵守的原則和規範!

這個協議今天就不展開了,後面找時間再單獨更新一篇,畢竟在把它整出來,面試官沒耐心聽下去了。😄

指令重排序

啥是指令重排序呢?我們在給出概念之前,先舉個小例子

a = b + c;
d = e - f ;

現在有一個求和與一個求差操作,理論上,我們按照順序執行的話,程式碼需要先將b、c載入進來,然後執行add(b,c)進行求和,然後在載入e、f,再執行sub(e,f)進行求差,但這樣會有個問題,不管是求和還是求差,都需要等引數裝載完成才能進行,這樣的話,後續的操作均會停頓,對於高速執行的CPU來說,停頓意味著浪費,意味著低效能。

那麼我們可以在執行順序上進行最佳化,在載入b、c時將e、f載入進來,這樣後面的求和求差就減少了停頓時間,提升了效率,而這,就是指令重排序的意思所在!

因此,我們結合這個小例子給出指令重排序的概念為了充分利用資源,提升程式執行的速度

指令重排一般分為以下三種:

  • 編譯器最佳化重排:編譯器在不改變單執行緒程式語義的前提下,重新安排語句的執行順序。
  • 指令並行重排:現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性(即後一個執行的語句無需依賴前面執行的語句的結果),處理器可以改變語句對應的機器指令的執行順序。
  • 記憶體系統重排:由於處理器使用快取和讀寫快取衝區,這使得載入(load)和儲存(store)操作看上去可能是在亂序執行,因為三級快取的存在,導致記憶體與快取的資料同步存在時間差。

Java 原始碼會經歷 編譯器最佳化重排 —> 指令並行重排 —> 記憶體系統重排 的過程,最終才變成作業系統可執行的指令序列。在單執行緒序列下,指令重排序能夠保證語義一致性,但在多執行緒下就會出現意想不到的問題,我們同樣寫個小demo去復現一下。

【程式碼示例2】

public class Test {

    static int x;//靜態變數 x
    static int y;//靜態變數 y

    public static void main(String[] args) throws InterruptedException {
        Set<String> valueSet = new HashSet<String>();//記錄出現的結果的情況
        Map<String, Integer> valueMap = new HashMap<String, Integer>();//儲存結果的鍵值對

        //迴圈 1萬次,記錄可能出現的 v1 和 v2 的情況
        for (int i = 0; i <10000; i++) {
            //給 x y 賦值為 0
            x = 0;
            y = 0;
            valueMap.clear();//清除之前記錄的鍵值對
            Thread t1 = new Thread(() -> {
                int v1 = y;//將 y 賦值給 v1 ----> Step1
                x = 1;//設定 x 為 1  ----> Step2
                valueMap.put("v1", v1);//v1 值存入 valueMap 中  ----> Step3
            }) ;

            Thread t2 = new Thread(() -> {
                int v2 = x;//將 x 賦值給 v2  ----> Step4
                y = 1;//設定 y 為 1  ----> Step5
                valueMap.put("v2", v2);//v2 值存入 valueMap 中  ----> Step6
            });

            //啟動執行緒 t1 t2
            t1.start();
            t2.start();
            //等待執行緒 t1 t2 執行完成
            t1.join();
            t2.join();

            //利用 Set 記錄並列印 v1 和 v2 可能出現的不同結果
            valueSet.add("(v1=" + valueMap.get("v1") + ",v2=" + valueMap.get("v2") + ")");
            System.out.println(valueSet);
        }
    }
}

輸出:

...
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
[(v1=1,v2=0), (v1=0,v2=0), (v1=0,v2=1)]
...

v1=0,v2=0 的執行順序是 Step1 和 Step 4 先執行

v1=1,v2=0 的執行順序是 Step5 先於 Step1 執行

v1=0,v2=1 的執行順序是 Step2 先於 Step4 執行

v1=1,v2=1 出現的機率極低,就是因為 CPU 指令重排序造成的。Step2 被最佳化到 Step1 前,Step5 被最佳化到 Step4 前,至少需要成立一個。

解決辦法:
在 Java 中,volatile 關鍵字可以禁止指令進行重排序最佳化。Happens-Before同樣也可以,接下來,我們就走進JMM的世界,是探索Happens-Before的奧秘!

JMM & Happens-Before

在JSR-133(新版Java記憶體模型)中,定義了Happens-Before(先行發生)原則,用於保證程式在執行過程中的可見性和有序性。

那麼它具體的程式設計思想是什麼呢?簡單兩句話就可以概括!

  1. 為了效能最大化,在不改變程式執行結果的前提下,對編譯器和處理器的約束越少越好,在這個維度上他們可以任意重排序;
  2. 對於會改變程式執行結果的重排序,JMM 要求編譯器和處理器必須禁止這種重排序。

在之前推薦閱讀《Java併發程式設計的藝術》這本書中,有這樣的一個示意圖,個人感覺非常形象!
image

在JMM中對Happens-Before規則進行了定義,我們還是先用一張圖(同樣是上圖的書籍中示意圖)展開描述。
image

所謂的排序規則是指 ① 如果一個操作 happens-before 另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前;② 兩個操作之間存在 happens-before 關係,並不意味著 Java 平臺的具體實現必須要按照 happens-before 關係指定的順序來執行。如果重排序之後的執行結果,與按 happens-before 關係來執行的結果一致,那麼 JMM 也允許這樣的重排序。

知識點補充:happens-before 重要規則

1、程式順序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作 happens-before 於書寫在後面的操作;
2、監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖。
3、volatile 變數規則:對一個 volatile 變數的寫操作 happens-before 於後面對這個 volatile 變數的讀操作。說白了就是對 volatile 變數的寫操作的結果對於發生於其後的任何操作都是可見的。
4、傳遞規則:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C;
5、start 規則:如果執行緒 A 執行操作 ThreadB.start()啟動執行緒 B,那麼 A 執行緒的 ThreadB.start()操作 happens-before 於執行緒 B 中的任意操作。
6、join 規則:如果執行緒 A 執行操作 ThreadB.join()併成功返回,那麼執行緒 B 中的任意操作 happens-before 於執行緒 A 從 ThreadB.join()操作成功返回。

總結

Java記憶體模型講到這裡,也就差不多講完了,其實中間還有快取一致性協議JSR-133文件volatile等關鍵字可以展開說一說,但那樣篇幅太長,而且內容多乏味,就一筆帶過了,感興趣的小夥伴自己去網上搜一些博文看看吧。

結尾彩蛋

如果本篇部落格對您有一定的幫助,大家記得留言+點贊+收藏呀。原創不易,轉載請聯絡Build哥!

image

如果您想與Build哥的關係更近一步,還可以關注“JavaBuild888”,在這裡除了看到《Java成長計劃》系列博文,還有提升工作效率的小筆記、讀書心得、大廠面經、人生感悟等等,歡迎您的加入!

image

相關文章