硬件內(nèi)存模型到 Java 內(nèi)存模型,這些硬核知識(shí)你知多少?
Java 內(nèi)存模型跟上一篇 JVM 內(nèi)存結(jié)構(gòu)很像,我經(jīng)常會(huì)把他們搞混,但其實(shí)它們不是一回事,而且相差還很大的,希望你沒它們搞混,特別是在面試的時(shí)候,搞混了的話就會(huì)答非所問,影響你的面試成績,當(dāng)然也許你碰到了半吊子面試官,那就要恭喜你了。Java 內(nèi)存模型比 JVM 內(nèi)存結(jié)構(gòu)復(fù)雜很多,Java 內(nèi)存模型有一個(gè)規(guī)范叫:《JSR 133 :Java 內(nèi)存模型與線程規(guī)范》,里面的內(nèi)容很豐富,如果你沒看過的話,我建議你看一下。今天我們就簡單的來聊一聊 Java 內(nèi)存模型,關(guān)于 Java 內(nèi)存模型,我們還是先從硬件內(nèi)存模型入手。
硬件內(nèi)存模型
先來看看硬件內(nèi)存簡單架構(gòu),如下圖所示:
硬件內(nèi)存結(jié)構(gòu)
這是一幅簡單的硬件內(nèi)存結(jié)構(gòu)圖,真實(shí)的結(jié)構(gòu)圖要比這復(fù)雜很多,特別是在緩存層,現(xiàn)在的計(jì)算機(jī)中 CPU 緩存一般有三層,你也可以打開你的電腦看看,打開 任務(wù)資源管理器 ---> 性能 ---> cpu ,如下圖所示:
CPU 緩存
從圖中可以看出我這臺(tái)機(jī)器的 CPU 有三級(jí)緩存,一級(jí)緩存 (L1) 、二級(jí)緩存(L2)、三級(jí)緩存(L3),一級(jí)緩存是最接近 CPU 的,三級(jí)緩存是最接近內(nèi)存的,每一級(jí)緩存的數(shù)據(jù)都是下一級(jí)緩存的一部分。三級(jí)緩存架構(gòu)如下圖所示:
圖片來源網(wǎng)絡(luò)
現(xiàn)在我們對(duì)硬件內(nèi)存架構(gòu)有了一定的了解,我們來弄明白一個(gè)問題,為什么需要在 CPU 和內(nèi)存之間添加緩存?
關(guān)于這個(gè)問題我們就簡單點(diǎn)說,我們知道 CPU 是高速的,而內(nèi)存相對(duì)來說是低速的,這就會(huì)造成一個(gè)問題,不能充分的利用 CPU 高速的特點(diǎn),因?yàn)?CPU 每次從內(nèi)存里獲取數(shù)據(jù)的話都需要等待,這樣就浪費(fèi)了 CPU 高速的性能,緩存的出現(xiàn)就是用來消除 CPU 與內(nèi)存之間差距的。緩存的速度要大于內(nèi)存小于 CPU ,加入緩存之后,CPU 直接從緩存中讀取數(shù)據(jù),因?yàn)榫彺孢€是比較快的,所以這樣就充分利用了 CPU 高速的特性。但也不是每次都能從緩存中讀取到數(shù)據(jù),這個(gè)跟我們項(xiàng)目中使用的 redis 等緩存工具一樣,也存在一個(gè)緩存命中率,在 CPU 中,先查找 L1 Cache,如果 L1 Cache 沒有命中,就往 L2 Cache 里繼續(xù)找,依此類推,最后沒找到的話直接從內(nèi)存中取,然后添加到緩存中。當(dāng)然當(dāng) CPU 需要寫數(shù)據(jù)到主存時(shí),同樣會(huì)先刷新寄存器中的數(shù)據(jù)到 CPU 緩存,然后再把數(shù)據(jù)刷新到主內(nèi)存中。
也許你已經(jīng)看出了這個(gè)框架的弊端,在單核時(shí)代只有一個(gè)處理器核心,讀/寫操作完全都是由單核完成,沒什么問題;但是多核架構(gòu),一個(gè)核修改主存后,其他核心并不知道數(shù)據(jù)已經(jīng)失效,繼續(xù)傻傻的使用主存或者自己緩存層的數(shù)據(jù),那么就會(huì)導(dǎo)致數(shù)據(jù)不一致的情況。關(guān)于這個(gè)問題 CPU 硬件廠商也提供了解決辦法,叫做緩存一致性協(xié)議(MESI 協(xié)議),緩存一致性協(xié)議這東西我也不了解,我也說不清,所以就不在這里 BB 了,有興趣的可以自行研究。
聊完了硬件內(nèi)存架構(gòu),我們將焦點(diǎn)回到我們的主題 Java 內(nèi)存模型上,下面就一起來聊一聊 Java 內(nèi)存模型。
Java 內(nèi)存模型
Java 內(nèi)存模型是什么?Java 內(nèi)存模型可以理解為遵照多核硬件架構(gòu)的設(shè)計(jì),用 Java 實(shí)現(xiàn)了一套 JVM 層面的“緩存一致性”,這樣就可以規(guī)避 CPU 硬件廠商的標(biāo)準(zhǔn)不一樣帶來的風(fēng)險(xiǎn)。好了,正式介紹一下 Java 內(nèi)存模型:Java 內(nèi)存模型 ( Java Memory Model,簡稱 JMM ),本身是種抽象的概念,并不是像硬件架構(gòu)一樣真實(shí)存在的,它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個(gè)變量 (包括實(shí)例字段、靜態(tài)字段和構(gòu)成數(shù)組對(duì)象的元素) 的訪問方式,更多關(guān)于 Java 內(nèi)存模型知識(shí)可以閱讀 JSR 133 :Java 內(nèi)存模型與線程規(guī)范。
我們知道 JVM 運(yùn)行程序的實(shí)體是線程,在上一篇 JVM 內(nèi)存結(jié)構(gòu)中我們得知每個(gè)線程創(chuàng)建時(shí),JVM 都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存 ( Java 棧 ),用于存儲(chǔ)線程私有數(shù)據(jù),而 Java 內(nèi)存模型中規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問,但線程對(duì)變量的操作 ( 讀取賦值等 ) 必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存拷貝到自己的工作內(nèi)存空間,然后對(duì)變量進(jìn)行操作,操作完后再將變量寫回主內(nèi)存,不能直接操作主內(nèi)存中的變量。
我們知道 Java 棧是每個(gè)線程私有的數(shù)據(jù)區(qū)域,別的線程無法訪問到不同線程的私有數(shù)據(jù),所以線程需要通信的話,就必須通過主內(nèi)存來完成,Java 內(nèi)存模型就是夾在這兩者之間的一組規(guī)范,我們先來看看這個(gè)抽象架構(gòu)圖:
圖片來源網(wǎng)絡(luò)
從結(jié)構(gòu)圖來看,如果線程 A 與線程 B 之間需要通信的話,必須要經(jīng)歷下面 2 個(gè)步驟:
- 首先,線程 A 把本地內(nèi)存 A 中的共享變量副本中的值刷新到主內(nèi)存中去。
- 然后,線程 B 到主內(nèi)存中去讀取線程 A 更新之后的值,這樣線程 A 中的變量值就到了線程 B 中。
我們來看一個(gè)具體的例子來加深一下理解,看下面這張圖:
圖片來源網(wǎng)絡(luò)
現(xiàn)在線程 A 需要和線程 B 通信,我們已經(jīng)知道線程之間通信的兩部曲了,假設(shè)初始時(shí),這三個(gè)內(nèi)存中的 x 值都為 0。線程 A 在執(zhí)行時(shí),把更新后的 x 值(假設(shè)值為 1)臨時(shí)存放在自己的本地內(nèi)存 A 中。當(dāng)線程 A 和線程 B 需要通信時(shí),線程 A 首先會(huì)把自己本地內(nèi)存中修改后的 x 值刷新到主內(nèi)存中,此時(shí)主內(nèi)存中的 x 值變?yōu)榱?1。隨后,線程 B 到主內(nèi)存中去讀取線程 A 更新后的 x 值,此時(shí)線程 B 的本地內(nèi)存的 x 值也變?yōu)榱? 1,這樣就完成了一次通信。
JMM 通過控制主內(nèi)存與每個(gè)線程的本地內(nèi)存之間的交互,來為 Java 程序員提供內(nèi)存可見性保證。Java 內(nèi)存模型除了定義了一套規(guī)范,還提供了一系列原語,封裝了底層實(shí)現(xiàn)后,供開發(fā)者直接使用。這套實(shí)現(xiàn)也就是我們常用的volatile、synchronized、final 等。
Happens-Before內(nèi)存模型
Happens-Before 內(nèi)存模型或許叫做 Happens-Before 原則更為合適,在 《JSR 133 :Java 內(nèi)存模型與線程規(guī)范》中,Happens-Before 內(nèi)存模型被定義成 Java 內(nèi)存模型近似模型,Happens-Before 原則要說明的是關(guān)于可見性的一組偏序關(guān)系。
為了方便程序員開發(fā),將底層的繁瑣細(xì)節(jié)屏蔽掉,Java 內(nèi)存模型 定義了 Happens-Before 原則。只要我們理解了 Happens-Before 原則,無需了解 JVM 底層的內(nèi)存操作,就可以解決在并發(fā)編程中遇到的變量可見性問題。JVM 定義的 Happens-Before 原則是一組偏序關(guān)系:對(duì)于兩個(gè)操作 A 和 B,這兩個(gè)操作可以在不同的線程中執(zhí)行。如果 A Happens-Before B,那么可以保證,當(dāng) A 操作執(zhí)行完后,A 操作的執(zhí)行結(jié)果對(duì) B 操作是可見的。
Happens-Before 原則一共包括 8 條,下面我們一起簡單的學(xué)習(xí)一下這 8 條規(guī)則。
1、程序順序規(guī)則
這條規(guī)則是指在一個(gè)線程中,按照程序順序,前面的操作 Happens-Before 于后續(xù)的任意操作。這一條規(guī)則還是非常好理解的,看下面這一段代碼
- class Test{
- 1 int x ;
- 2 int y ;
- 3 public void run(){
- 4 y = 20;
- 5 x = 12;
- }
- }
第四行代碼要 Happens-Before 于第五行代碼,也就是按照代碼的順序來。
2、鎖定規(guī)則
這條規(guī)則是指對(duì)一個(gè)鎖的解鎖 Happens-Before 于后續(xù)對(duì)這個(gè)鎖的加鎖。例如下面的代碼,在進(jìn)入同步塊之前,會(huì)自動(dòng)加鎖,而在代碼塊執(zhí)行完會(huì)自動(dòng)釋放鎖,加鎖以及釋放鎖都是編譯器幫我們實(shí)現(xiàn)的
- synchronized (this) {
- // 此處自動(dòng)加鎖
- // x 是共享變量, 初始值 =10
- if (this.x < 12) {
- this.x = 12;
- }
- } // 此處自動(dòng)解鎖
對(duì)于鎖定規(guī)則可以這樣理解:假設(shè) x 的初始值是 10,線程 A 執(zhí)行完代碼塊后 x 的值會(huì)變成 12(執(zhí)行完自動(dòng)釋放鎖),線程 B 進(jìn)入代碼塊時(shí),能夠看到線程 A 對(duì) x 的寫操作,也就是線程 B 能夠看到 x==12。
3、volatile 變量規(guī)則
這條規(guī)則是指對(duì)一個(gè) volatile 變量的寫操作及這個(gè)寫操作之前的所有操作 Happens-Before 對(duì)這個(gè)變量的讀操作及這個(gè)讀操作之后的所有操作。
4、線程啟動(dòng)規(guī)則
這條規(guī)則是指主線程 A 啟動(dòng)子線程 B 后,子線程 B 能夠看到主線程在啟動(dòng)子線程 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 能夠看見主線程對(duì) count 變量的修改,所以在線程中打印出來的是 12 。這也就是線程啟動(dòng)規(guī)則
5、線程結(jié)束規(guī)則
這條是關(guān)于線程等待的。它是指主線程 A 等待子線程 B 完成(主線程 A 通過調(diào)用子線程 B 的 join() 方法實(shí)現(xiàn)),當(dāng)子線程 B 完成后(主線程 A 中 join() 方法返回),主線程能夠看到子線程的操作。當(dāng)然所謂的“看到”,指的是對(duì)共享變量的操作。
- 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、中斷規(guī)則
一個(gè)線程在另一個(gè)線程上調(diào)用 interrupt ,Happens-Before 被中斷線程檢測(cè)到 interrupt 被調(diào)用。
- public class Demo {
- private static int count = 0;
- public static void main(String[] args) throws InterruptedException {
- Thread t1 = new Thread(() -> {
- // t1 線程可以看到被中斷前的數(shù)據(jù)
- System.out.println(count);
- });
- t1.start();
- count = 25;
- // t1 線程被中斷
- t1.interrupt();
- }
- }
mian 線程中調(diào)用了 t1 線程的 interrupt() 方法,mian 對(duì) count 的修改對(duì) t1 線程是可見的。
7、終結(jié)器規(guī)則
一個(gè)對(duì)象的構(gòu)造函數(shù)執(zhí)行結(jié)束 Happens-Before 它的 finalize()方法的開始。“結(jié)束”和“開始”表明在時(shí)間上,一個(gè)對(duì)象的構(gòu)造函數(shù)必須在它的 finalize()方法調(diào)用時(shí)執(zhí)行完。根據(jù)這條原則,可以確保在對(duì)象的 finalize 方法執(zhí)行時(shí),該對(duì)象的所有 field 字段值都是可見的。
8、傳遞性規(guī)則
這條規(guī)則是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens- Before C。