硬件內存模型到 Java 內存模型,這些硬核知識你知多少?

Java 內存模型跟上一篇 JVM 內存結構很像,我經常會把他們搞混,但其實它們不是一回事,而且相差還很大的,希望你沒它們搞混,特別是在面試的時候,搞混了的話就會答非所問,影響你的面試成績,當然也許你碰到了半吊子面試官,那就要恭喜你了。Java 內存模型比 JVM 內存結構複雜很多,Java 內存模型有一個規範叫:《JSR 133 :Java內存模型與線程規範》,裏面的內容很豐富,如果你沒看過的話,我建議你看一下。今天我們就簡單的來聊一聊 Java 內存模型,關於 Java 內存模型,我們還是先從硬件內存模型入手。

硬件內存模型

先來看看硬件內存簡單架構,如下圖所示:

硬件內存結構

這是一幅簡單的硬件內存結構圖,真實的結構圖要比這複雜很多,特別是在緩存層,現在的計算機中 CPU 緩存一般有三層,你也可以打開你的電腦看看,打開 任務資源管理器 —> 性能 —> cpu ,如下圖所示:

CPU 緩存

從圖中可以看出我這臺機器的 CPU 有三級緩存,一級緩存 (L1) 、二級緩存(L2)、三級緩存(L3),一級緩存是最接近 CPU 的,三級緩存是最接近內存的,每一級緩存的數據都是下一級緩存的一部分。三級緩存架構如下圖所示:

圖片來源網絡

現在我們對硬件內存架構有了一定的瞭解,我們來弄明白一個問題,爲什麼需要在 CPU 和內存之間添加緩存

關於這個問題我們就簡單點說,我們知道 CPU 是高速的,而內存相對來說是低速的,這就會造成一個問題,不能充分的利用 CPU 高速的特點,因爲 CPU 每次從內存裏獲取數據的話都需要等待,這樣就浪費了 CPU 高速的性能,緩存的出現就是用來消除 CPU 與內存之間差距的。緩存的速度要大於內存小於 CPU ,加入緩存之後,CPU 直接從緩存中讀取數據,因爲緩存還是比較快的,所以這樣就充分利用了 CPU 高速的特性。但也不是每次都能從緩存中讀取到數據,這個跟我們項目中使用的 redis 等緩存工具一樣,也存在一個緩存命中率,在 CPU 中,先查找 L1 Cache,如果 L1 Cache 沒有命中,就往 L2 Cache 裏繼續找,依此類推,最後沒找到的話直接從內存中取,然後添加到緩存中。當然當 CPU 需要寫數據到主存時,同樣會先刷新寄存器中的數據到 CPU 緩存,然後再把數據刷新到主內存中。

也許你已經看出了這個框架的弊端,在單核時代只有一個處理器核心,讀/寫操作完全都是由單核完成,沒什麼問題;但是多核架構,一個核修改主存後,其他核心並不知道數據已經失效,繼續傻傻的使用主存或者自己緩存層的數據,那麼就會導致數據不一致的情況。關於這個問題 CPU 硬件廠商也提供瞭解決辦法,叫做緩存一致性協議(MESI協議),緩存一致性協議這東西我也不瞭解,我也說不清,所以就不在這裏 BB 了,有興趣的可以自行研究。

聊完了硬件內存架構,我們將焦點回到我們的主題 Java 內存模型上,下面就一起來聊一聊 Java 內存模型。

Java 內存模型

Java 內存模型是什麼?Java 內存模型可以理解爲遵照多核硬件架構的設計,用 Java 實現了一套 JVM 層面的“緩存一致性”,這樣就可以規避 CPU 硬件廠商的標準不一樣帶來的風險。好了,正式介紹一下 Java 內存模型:Java 內存模型 ( Java Memory Model,簡稱 JMM ),本身是種抽象的概念,並不是像硬件架構一樣真實存在的,它描述的是一組規則或規範,通過這組規範定義了程序中各個變量 (包括實例字段、靜態字段和構成數組對象的元素) 的訪問方式,更多關於 Java 內存模型知識可以閱讀 JSR 133 :Java內存模型與線程規範。

我們知道 JVM 運行程序的實體是線程,在上一篇 JVM 內存結構中我們得知每個線程創建時,JVM 都會爲其創建一個工作內存 ( Java 棧 ),用於存儲線程私有數據,而 Java 內存模型中規定所有變量都存儲在主內存,主內存是共享內存區域,所有線程都可以訪問,但線程對變量的操作 ( 讀取賦值等 ) 必須在工作內存中進行,首先要將變量從主內存拷貝到自己的工作內存空間,然後對變量進行操作,操作完後再將變量寫回主內存,不能直接操作主內存中的變量。

我們知道 Java棧是每個線程私有的數據區域,別的線程無法訪問到不同線程的私有數據,所以線程需要通信的話,就必須通過主內存來完成,Java 內存模型就是夾在這兩者之間的一組規範,我們先來看看這個抽象架構圖:

圖片來源網絡

從結構圖來看,如果線程 A 與線程 B 之間需要通信的話,必須要經歷下面 2 個步驟:

  1. 首先,線程 A 把本地內存 A 中的共享變量副本中的值刷新到主內存中去。
  2. 然後,線程 B 到主內存中去讀取線程 A 更新之後的值,這樣線程 A 中的變量值就到了線程 B 中。

我們來看一個具體的例子來加深一下理解,看下面這張圖:

圖片來源網絡

現在線程 A 需要和線程 B 通信,我們已經知道線程之間通信的兩部曲了,假設初始時,這三個內存中的 x 值都爲 0。線程 A 在執行時,把更新後的 x 值(假設值爲 1)臨時存放在自己的本地內存 A 中。當線程 A 和線程 B 需要通信時,線程 A 首先會把自己本地內存中修改後的 x 值刷新到主內存中,此時主內存中的 x 值變爲了 1。隨後,線程 B 到主內存中去讀取線程 A 更新後的 x 值,此時線程 B 的本地內存的 x 值也變爲了 1,這樣就完成了一次通信。

JMM 通過控制主內存與每個線程的本地內存之間的交互,來爲 Java 程序員提供內存可見性保證。Java 內存模型除了定義了一套規範,還提供了一系列原語,封裝了底層實現後,供開發者直接使用。這套實現也就是我們常用的volatilesynchronizedfinal 等。

Happens-Before 內存模型

Happens-Before 內存模型或許叫做 Happens-Before 原則更爲合適,在 《JSR 133 :Java內存模型與線程規範》中,Happens-Before 內存模型被定義成 Java 內存模型近似模型,Happens-Before 原則要說明的是關於可見性的一組偏序關係。

爲了方便程序員開發,將底層的煩瑣細節屏蔽掉,Java 內存模型 定義了 Happens-Before 原則。只要我們理解了Happens-Before 原則,無需瞭解 JVM 底層的內存操作,就可以解決在併發編程中遇到的變量可見性問題。JVM 定義的 Happens-Before 原則是一組偏序關係:對於兩個操作A和B,這兩個操作可以在不同的線程中執行。如果A Happens-Before B,那麼可以保證,當A操作執行完後,A操作的執行結果對B操作是可見的

Happens-Before 原則一共包括 8 條,下面我們一起簡單的學習一下這 8 條規則。

1、程序順序規則

這條規則是指在一個線程中,按照程序順序,前面的操作 Happens-Before 於後續的任意操作。這一條規則還是非常好理解的,看下面這一段代碼

class Test{
1	int x ;
2	int y ;
3	public void run(){
4		y = 20;
5		x = 12;		
	}
}

第四行代碼要 Happens-Before 於第五行代碼,也就是按照代碼的順序來。

2、鎖定規則

這條規則是指對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。例如下面的代碼,在進入同步塊之前,會自動加鎖,而在代碼塊執行完會自動釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實現的

synchronized (this) { 
	// 此處自動加鎖 
	// x 是共享變量, 初始值 =10 
	if (this.x < 12) { 
	   this.x = 12; 
	} 
} // 此處自動解鎖

對於鎖定規則可以這樣理解:假設 x 的初始值是 10,線程 A 執行完代碼塊後 x 的值會變成 12(執行完自動釋放鎖),線程 B 進入代碼塊時,能夠看到線程 A 對 x 的寫操作,也就是線程 B 能夠看到 x==12。

3、volatile變量規則

這條規則是指對一個 volatile 變量的寫操作及這個寫操作之前的所有操作 Happens-Before 對這個變量的讀操作及這個讀操作之後的所有操作。

4、線程啓動規則

這條規則是指主線程 A 啓動子線程 B 後,子線程 B 能夠看到主線程在啓動子線程 B 前的操作。

public class Demo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println(count);
        });
        count = 12;
        t1.start();
    }
}

子線程 t1 能夠看見主線程對 count 變量的修改,所以在線程中打印出來的是 12 。這也就是線程啓動規則

5、線程結束規則

這條是關於線程等待的。它是指主線程 A 等待子線程 B 完成(主線程 A 通過調用子線程 B 的 join() 方法實現),當子線程 B 完成後(主線程 A 中 join() 方法返回),主線程能夠看到子線程的操作。當然所謂的“看到”,指的是對共享變量的操作。

public class Demo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // t1 線程修改了變量
            count = 12;
        });
        t1.start();
        t1.join();
        // mian 線程可以看到 t1 線程改修後的變量
        System.out.println(count);
    }
}

6、中斷規則

一個線程在另一個線程上調用 interrupt ,Happens-Before 被中斷線程檢測到 interrupt 被調用。

public class Demo {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            // t1 線程可以看到被中斷前的數據
            System.out.println(count);
        });
        t1.start();
        count = 25;
        // t1 線程被中斷 
        t1.interrupt();
    }
}

mian 線程中調用了 t1 線程的 interrupt() 方法,mian 對 count 的修改對 t1 線程是可見的。

7、終結器規則

一個對象的構造函數執行結束Happens-Before它的finalize()方法的開始。“結束”和“開始”表明在時間上,一個對象的構造函數必須在它的finalize()方法調用時執行完。根據這條原則,可以確保在對象的finalize方法執行時,該對象的所有field字段值都是可見的。

8、傳遞性規則

這條規則是指如果 A Happens-Before B,且 B Happens-Before C,那麼 A Happens- Before C。

最後

目前互聯網上很多大佬都有 Java 內存模型系列教程,如有雷同,請多多包涵了。原創不易,碼字不易,還希望大家多多支持。若文中有所錯誤之處,還望提出,謝謝,歡迎掃碼關注微信公衆號:「平頭哥的技術博文」,和平頭哥一起學習,一起進步。

平頭哥的技術博文

發佈了82 篇原創文章 · 獲贊 3850 · 訪問量 41萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章