寫在前面
JAVA 內(nèi)存模型是我看過很多遍,也忘了很多遍裸删,每隔一段時間就會感到模糊的一部分內(nèi)容兼呵。直到我閱讀了 Jakob Jenkov 大神這篇對初學者非常友好的 Java Memory Model砚作。我對其做了翻譯懂扼,一方面加深理解禁荸,便于日后復習右蒲,也希望能夠幫到更多需要的伙伴阀湿。【非逐字翻譯瑰妄,英文不錯的同學建議閱讀原文】
相比之前讀過的大部分書籍和博客陷嘴,這篇文章沒有在一開始就引入過多的細節(jié)。而是先以一個宏觀的視角切入间坐,讓讀者先對 JAVA 內(nèi)存模型有一個清晰的上層認識灾挨。再結(jié)合硬件內(nèi)存架構(gòu)模型邑退,講述了 JAVA 內(nèi)存模型與硬件內(nèi)存架構(gòu)模型的關(guān)系與區(qū)別(初學者非常容易混淆 JAVA 內(nèi)存模型和硬件內(nèi)存模型)。這兩點恰恰是我在學習 JAVA 內(nèi)存模型的過程中劳澄,最大的痛點地技。
為什么學習 JAVA 內(nèi)存模型
寬泛的說學習 JAVA 內(nèi)存模型能讓我們對 JAVA 程序的運行有一個更清晰的認識。更具體的秒拔,通過 JAVA 內(nèi)存模型莫矗,我們可以了解到不同線程對于共享的變量,是如何讀寫的砂缩。以及如何在必要的時候作谚,以同步的方式(syncronize)訪問共享變量。這對我們理解 JAVA 多線程編程庵芭,以及寫出正確的多線程并行程序十分重要妹懒。
JAVA 內(nèi)存模型
JAVA 內(nèi)存模型是 JVM 內(nèi)部的一種內(nèi)存模型,邏輯上可以主要分為線程棧(Thread Stack)和堆(Heap)兩部分双吆,如下圖所示:
線程棧(Thread Stack)
每個線程都擁有自己的線程棧眨唬,線程棧里面存放著相應(yīng)線程執(zhí)行方法(Method)時涉及的所有本地變量(local variables)。每個線程只能訪問自己的線程棧好乐,線程棧之間是互相不可見的单绑。
所有基本類型(boolean, byte, short, char, int, long, float, double)的本地變量是直接存儲于線程棧內(nèi)的,線程間均不可見曹宴。一個線程可能會通過拷貝的方式搂橙,把自己線程棧內(nèi)的基礎(chǔ)類型變量提供給另一個線程。但一定無法直接提供該變量本身笛坦。
所有對象類型的變量区转,棧中存儲的都只是一個引用,對象本身存儲于堆中版扩。
堆(heap)
JAVA 應(yīng)用中废离,所有的對象都是存儲在堆中的——包括對象版本的基礎(chǔ)類型(Byte, Integer, Long 等等)〗嘎可以總結(jié)為下圖:
- 基礎(chǔ)類型的本地變量是直接存儲在線程棧中的蜻韭。
- 非基礎(chǔ)類型的本地變量(即對象引用變量),線程棧中存儲的只是一個引用柿扣,實際的對象是存儲在堆中的肖方。
- 堆中對象可能會包含成員變量,這些成員變量無論是基礎(chǔ)類型變量未状,還是對象引用類型的變量俯画,都會隨對象存儲在堆中。
- 靜態(tài)變量司草,隨其所屬類一并存儲于堆中艰垂。
舉個例子
為了展示變量在線程棧和堆中的存儲情況泡仗,我們參照圖片 Java Memory Model 2,寫了如下代碼:
public class Main{
public static void main(String[] args){
Thread thread1 = new Thread(new MyRunnable());
Thread thread2 = new Thread(new MyRunnable());
thread1.start();
thread2.start();
}
}
public class MyRunnable implements Runnable {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... do more with local variables.
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... do more with local variable.
}
}
public class MySharedObject {
//static variable pointing to instance of MySharedObject
public static final MySharedObject sharedInstance =
new MySharedObject();
//member variables pointing to two objects on the heap
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member2 = 67890;
}
代碼中猜憎,兩個線程都會執(zhí)行 MyRunnable 類的 run 方法娩怎,run 方法調(diào)用 methodOne,methodOne 調(diào)用 methodTwo胰柑。最終各變量的存儲和關(guān)系可以描述為下圖:
結(jié)合代碼和這張圖峦树,我們應(yīng)該能清晰了解到 JAVA 代碼中各變量,實際運行時 JAVA 內(nèi)存模型中的存儲位置了旦事。
硬件內(nèi)存架構(gòu)
開頭我們說過魁巩,JAVA 內(nèi)存模型只是 JVM 內(nèi)部的一種內(nèi)存模型。它和我們熟悉的硬件內(nèi)存架構(gòu)模型有什么關(guān)系姐浮?又是如何一起工作的呢谷遂?
我們先了解一下硬件內(nèi)存架構(gòu),如下圖所示:
現(xiàn)在常見的電腦都是多 CPU 或者多核的卖鲤,這也是為什么我們的電腦可以實際支撐真實的多線程并行工作肾扰。在這樣的電腦上執(zhí)行多線程并行的 JAVA 程序時,不同的線程是有可能運行在不同的 CPU 上的蛋逾。
每個 CPU 都有一組寄存器(CPU Registers)—— CPU 內(nèi)部的內(nèi)存集晚。由于寄存器比主存(Main Memory)更快,CPU在操作存儲于寄存器的數(shù)據(jù)時区匣,會比操作主存數(shù)據(jù)快的多偷拔。
現(xiàn)在的 CPU 都還通常會有一個 CPU 緩存層(CPU Cache Memory Layer)。操作緩存層的速度介于寄存器和主存之間亏钩。(注:有的 CPU 也會設(shè)計多級緩存莲绰,比如 Cache Memory Layer1,Cache Memory Layer2 等姑丑,了解即可蛤签,不影響我們此處對 CPU 緩存的理解)
計算機都會有一個主存(Main Memory)。所有 CPU 都可以訪問它栅哀。
通常來說震肮,CPU 把需要的部分數(shù)據(jù)從主存拷貝到緩存,緩存中的部分數(shù)據(jù)會被拷貝到寄存器留拾,然后基于寄存器內(nèi)的數(shù)據(jù)完成計算戳晌,最終將結(jié)果逐級會寫到主存中。(在某個恰當?shù)臅r機將寄存器的數(shù)據(jù)寫回緩存间驮,然后再在某個恰當?shù)臅r機把緩存的數(shù)據(jù)寫回主存躬厌,比如我們需要釋放一部分緩存在存儲我們此時需要用到的其他數(shù)據(jù))。
JAVA 內(nèi)存模型和硬件內(nèi)存架構(gòu)的關(guān)系
硬件內(nèi)存架構(gòu)并不按照堆竞帽,棧區(qū)分扛施。實際上,JAVA 內(nèi)存模型中堆和棧存儲的數(shù)據(jù)屹篓,都會存儲到硬件內(nèi)存的主存上疙渣。而在某些時間點,部分的堆/棧數(shù)據(jù)也會出現(xiàn)在 CPU 緩存堆巧,或者寄存器上妄荔。如下圖所示:
一臺電腦有多個CPU,多個寄存器谍肤,多個緩存啦租。而我們的 JAVA 對象/變量可能存儲在這么多不同的位置,這就直接帶來了兩個問題:
- 共享變量(shared variables)在線程間的可見性問題
- 共享變量在多線程讀寫時的競爭條件(race condition)問題
共享變量的可見性問題
寫 JAVA 代碼時我們知道荒揣,在沒有正確使用 volatile 關(guān)鍵字或者 synchronization 時篷角,一個共享變量被線程A的修改,對線程B而言可能是不可見的系任。
這個比較好理解恳蹲,兩個運行于不同CPU的線程,分別從主存拷貝同一個變量到各自CPU的緩存甚至是寄存器中俩滥,由于他們后續(xù)一段時間對該變量的讀寫都僅僅發(fā)生在各自的緩存或寄存器內(nèi)的拷貝上嘉蕾,這些修改對不同線程間是不可見的。如下圖所示:
通過使用 volatile 關(guān)鍵字可以解決該問題霜旧。經(jīng)過 volatile 修飾的變量错忱,每次都會直接從主存讀取,并且保證每一次的修改都會回寫到主存上挂据。
競爭條件(race condition)
當多個線程想要同時修改同一個共享變量的時候航背,就會產(chǎn)生競爭條件問題。
假設(shè)我們有兩個執(zhí)行在不同CPU的線程:線程A和線程B棱貌。他們都讀取了主存中的一個共享變量 count = 1玖媚。然后分別在各自 CPU 緩存內(nèi)對其做了 +1 操作。原本我們期望的計算結(jié)果是 count + 1 + 1 = 3婚脱。但由于這兩次 +1 操作在不同的 CPU 緩存內(nèi)同時進行今魔,最終線程A和B將自己計算的結(jié)果回寫到主存時,結(jié)果為:count + 1 = 2障贸。如下圖所示:
該問題可以通過同步化來處理——保證一段代碼错森,同一時間,只能有一個線程執(zhí)行篮洁。JAVA 中同步化操作通過 synchronized 關(guān)鍵字實現(xiàn)涩维。