寫在前面
JAVA 內(nèi)存模型是我看過很多遍,也忘了很多遍,每隔一段時間就會感到模糊的一部分內(nèi)容。直到我閱讀了 Jakob Jenkov 大神這篇對初學(xué)者非常友好的 Java Memory Model。我對其做了翻譯,一方面加深理解,便于日后復(fù)習(xí),也希望能夠幫到更多需要的伙伴?!痉侵鹱址g,英文不錯的同學(xué)建議閱讀原文】
相比之前讀過的大部分書籍和博客,這篇文章沒有在一開始就引入過多的細(xì)節(jié)。而是先以一個宏觀的視角切入,讓讀者先對 JAVA 內(nèi)存模型有一個清晰的上層認(rèn)識。再結(jié)合硬件內(nèi)存架構(gòu)模型,講述了 JAVA 內(nèi)存模型與硬件內(nèi)存架構(gòu)模型的關(guān)系與區(qū)別(初學(xué)者非常容易混淆 JAVA 內(nèi)存模型和硬件內(nèi)存模型)。這兩點恰恰是我在學(xué)習(xí) JAVA 內(nèi)存模型的過程中,最大的痛點。
為什么學(xué)習(xí) JAVA 內(nèi)存模型
寬泛的說學(xué)習(xí) JAVA 內(nèi)存模型能讓我們對 JAVA 程序的運行有一個更清晰的認(rèn)識。更具體的,通過 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 等等)??梢钥偨Y(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 把需要的部分?jǐn)?shù)據(jù)從主存拷貝到緩存,緩存中的部分?jǐn)?shù)據(jù)會被拷貝到寄存器,然后基于寄存器內(nèi)的數(shù)據(jù)完成計算,最終將結(jié)果逐級會寫到主存中。(在某個恰當(dāng)?shù)臅r機將寄存器的數(shù)據(jù)寫回緩存,然后再在某個恰當(dāng)?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)
當(dāng)多個線程想要同時修改同一個共享變量的時候,就會產(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)。