1. 程式人生 > 程式設計 >硬體記憶體模型到 Java 記憶體模型,這些硬核知識你知多少?(恭喜fpx)

硬體記憶體模型到 Java 記憶體模型,這些硬核知識你知多少?(恭喜fpx)

【北京】 IT技術人員面對面試、跳槽、升職等問題,如何快速成長,獲得大廠入門資格和升職加薪的籌碼?與大廠技術大牛面對面交流,解答你的疑惑。《從職場小白到技術總監成長之路:我的職場焦慮與救贖》活動連結:碼客

恭喜fpx,新王登基,lpl*b 我們是冠軍

原文連結:segmentfault.com/a/119000002…

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。

最後

(想自學習程式設計的小夥伴請搜尋圈T社群,更多行業相關資訊更有行業相關免費視訊教程。完全免費哦!)