「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

一、Java的運行時區域

在Java中,虛擬機將運行時區域分成6種,如圖:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

  1. 程序計數器:用來記錄當前線程執行到哪一步操作。在多線程輪換的模式中,噹噹前線程時間片用完的時候記錄當前操作到哪一步,重新獲得時間片時根據此記錄來恢復之前的操作。
  2. 虛擬機棧:這就是我們平時所說的棧了,一般用來儲存局部變量表、操作數表、動態鏈接等。
  3. 本地方法棧:這是另一個棧,用來提供虛擬機中用到的本地服務,像線程中的start方法,JUC包裡經常使用的CAS等方法都是從這來的。
  4. 堆:主要的儲存區域,平時所創建的對象都是放在這個區域。其內部還分為新生代、老年代和永久代(也就是方法區,在Java8之後刪除了),新生代又分為兩塊Survivor和一塊Eden,平時創建的對象其實都是在Eden區創建的,不過這些之後再跟垃圾回收器寫在一篇文章。
  5. 方法區:儲存符號引用、被JVM加載的類信息、靜態變量的地方。在Java8之後方法區被移除,使用元空間來存放類信息,常量池和其他東西被移到堆中(其實在7的時候常量池和靜態變量就已經被移到堆中),不再有永久代一說。刪除的原因大致如下:
  6. 容易造成內存溢出或內存洩漏,例如 web開發中JSP頁面較多的情況。
  7. 由於類和方法的信息難以確定,不好設定大小,太大則影響年老代,太小容易內存溢出。
  8. GC不好處理,回收效率低下,調優困難。
  9. 常量池:存放final修飾的成員變量、直接定義的字符串(如 Sring s = "test";這種)還有6種數據類型包裝類型從-128~127對應的對象(這也解釋了我們new兩個在這區間的包裝類型對象時,為什麼他們是一樣的,布爾類型存放的是true和false兩種,浮點類型Double和Float因為精度問題不存入其中)等

在上面的6種類型中,前三種是線程私有的,也就是說裡面存放的值其他線程是看不到的,而後面三種(真正意義上講只有堆一種)是線程之間共享的,這裡面的變量對於各個線程都是可見的。如下圖所示,前三種存放在線程內存中,大家都是相互獨立的,而主內存可以理解為堆內存(實際上只是堆內存中的對象實例數據部分,其他例如對象頭和對象的填充數據並不算入在內),為線程之間共享:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

二、Java內存之間的變量交互

這裡的變量指的是可以放在堆中的變量,其他例如局部變量、方法參數這些並不算入在內。線程內存跟主內存變量之間的交互是非常重要的,Java虛擬機把這些交互規範為以下8種操作,每一種都是原子性的(非volatile修飾的Double和Long除外)操作。

  1. Lock(鎖)操作:操作對象為線程,作用對象為主內存的變量,當一個變量被鎖住的時候,其他線程只有等當前線程解鎖之後才能使用,其他線程不能對該變量進行解鎖操作。
  2. Unlock(解鎖)操作:同上,線程操作,作用於主內存變量,令一個被鎖住的變量解鎖,使得其他線程可以對此變量進行操作,不能對未鎖住的變量進行解鎖操作。
  3. Read(讀):
    線程從主內存讀取變量值,load操作根據此讀取的變量值為線程內存中的變量副本賦值。
  4. Load(加載):將Read讀取到的變量值賦到線程內存的副本中,供線程使用。
  5. Use(使用):讀取線程內存的作用值,用來執行我們定義的操作。
  6. Assign(賦值):在線程操作中變量的值進行了改變,使用此操作刷新線程內存的值。
  7. Store(儲存):將當前線程內存的變量值同步到主內存中,與write操作一起作用。
  8. Write(寫):將線程內存中store的值寫入到主內存中,主內存中的變量值進行變更。

可能有人會不理解read和load、store和write的區別,覺得這兩對的操作類似,可以把其當做一個是申請操作,另一個是審核通過(允許賦值)。例如:線程內存A向主內存提交了變更變量的申請(

store操作),主內存通過之後修改變量的值(write操作)。如下圖:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

參照《深入理解Java虛擬機》

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

對於普通的變量來說(非volatile修飾的變量),虛擬機要求read、load有相對順序即可,例如從主內存讀取i、j兩個變量,可能的操作是read i=>read j=>load j=> load i,並不一定是連續的。此外虛擬機還為這8種操作定製了操作的規則:

  • (read,load)、(store,write)不允許出現單獨的操作。也就是說這兩種操作一定是以組的形式出現的,有read就有load,有store就有write,不能讀取了變量值而不加載到線程內存中,也不能儲存了變量值而不寫到主內存中。
  • 不允許線程放棄最近的assign操作。也就是說當線程使用assign操作對私有內存的變量副本進行了變更的時候,其必須使用write操作將其同步到主內存當中去。
  • 不允許一個線程無原因地(沒有進行assign操作)將私有內存的變量同步到主內存中。
  • 變量必須從主內存產生,即不允許在私有內存中使用未初始化(未進行load或者assgin操作)的變量。也就是說,在use之前必須保證執行了load操作,在store之前必須保證執行了assign操作,例如有成員變量a和局部變量b,如果想進行a = b的操作,必須先初始化b。(一開始說了,變量指的是可以放在堆內存的變量)
  • 一個變量一次只能同時允許一個線程對其進行lock操作。一個主內存的變量被一個線程使用lock操作之後,在這個線程執行unlock操作之前,其他線程不能對此變量進行操作。但是一個線程可以對一個變量進行多次鎖,只要最後釋放鎖的次數和加鎖的次數一致才能解鎖。
  • 當線程使用lock操作時,清除所有私有內存的變量副本。
  • 使用unlock操作時,必須在此操作之前將變量同步到主內存當中。
  • 不允許對沒有進行lock操作的變量執行unlock操作,也不允許線程去unlock其他線程lock的變量。

三、改變規則的Volatile關鍵字

對於關鍵字volatile,大家都知道其一般作為併發的輕量級關鍵字,並且具有兩個重要的語義

  1. 保證內存的可見性:使用volatile修飾的變量在變量值發生改變的時候,會立刻同步到主內存,並使其他線程的變量副本失效。
  2. 禁止指令重排序:用volatile修飾的變量在硬件層面上會通過在指令前後加入內存屏障來實現編譯器級別則是通過下面的規則實現。

這兩個語義都是因為JMM對於volatile關鍵字修飾的變量會有特殊的規則:

在對變量執行use操作之前,其前一步操作必須為對該變量的load操作;在對變量執行load操作之前,其後一步操作必須為該變量的use操作。

也就是說,使用volatile修飾的變量其read、load、use都是連續出現的,所以每次使用變量的時候都要從主內存讀取最新的變量值,替換私有內存的變量副本值(如果不同的話)。在對變量執行assign操作之前,其後一步操作必須為store;在對變量執行store之前,其前一步必須為對相同變量的assign操作。也就是說,其對同一變量的assign、store、write操作都是連續出現的,所以每次對變量的改變都會立馬同步到主內存中。在主內存中有變量a、b,動作A為當前線程對變量a的use或者assign操作,動作B為與動作A對應load或store操作,動作C為與動作B對應的read或write操作;動作D為當前線程對變量b的use或assign操作,動作E為與D對應的load或store操作,動作F為與動作E對應的read或write操作;如果動作A先於動作D,那麼動作C要先於動作F。也就是說,如果當前線程對變量a執行的use或assign操作在對變量buse或assign之前執行的話,那麼當前線程對變量a的read或write操作肯定要在對變量b的read或write操作之前執行。

從上面volatile的特殊規則中,我們可以知道1、2條其實就是volatile內存可見性的語義,第三條就是禁止指令重排序

的語義。另外還有其他的一些特殊規則,例如對於非volatile修飾的double或者long這兩個64位的數據類型中,虛擬機允許對其當做兩次32位的操作來進行,也就是說可以分解成非原子性的兩個操作,但是這種可能性出現的情況也相當的小。因為Java內存模型雖然允許這樣子做,但卻“強烈建議”虛擬機選擇實現這兩種類型操作的原子性,所以平時不會出現讀到“半個變量”的情況。

volatile不具備原子性

雖然volatile修飾的變量可以強制刷新內存,但是其並不具備原子性,稍加思考就可以理解,雖然其要求對變量的(read、load、use)、(assign、store、write)必須是連續出現,即以組的形式出現,但是這兩組操作還是分開的。比如說,兩個線程同時完成了第一組操作(read、load、use),但是還沒進行第二組操作(assign、store、write),此時是沒錯的,然後兩個線程開始第二組操作,這樣最終其中一個線程的操作會被覆蓋掉,導致數據的不準確。如下面代碼:

結果圖:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

解釋一下:因為i++操作其實為i = i + 1,假設在主內存i = 99的時候同時有兩個線程完成了第一組操作(read、load、use),也就是完成了等號後面變量i的讀取操作,這時候是沒問題的,然後進行運算,都得出i+1=100的結果,接著對變量i進行賦值操作,這就開始第二組操作(assign、store、write),是不是同時賦值的無所謂,這樣一來,兩個線程都會以i = 100把值寫到主內存中,也就是說,

其中一個線程的操作結果會被覆蓋,相當於無效操作,這就導致上面程序最終結果的不準確。

如果要保證原子性的話可以使用synchronize關鍵字,其可以保證原子性內存可見性(但是不具備有禁止指令重排序的語義,這也是為什麼double-check的單例模式中,實例要用volatile修飾的原因);當然你也可以使用JUC包的原子類AtomicInteger之類的。

四、先行發生原則(happens-before)

如果單靠volatilesynchronized來維持程序的有序性的話,那麼難免會變得有些繁瑣。然而大部分時候我們並不需要這樣做,因為Java中有一個“先行發生原則”:*如果操作A先行發生於操作B,那麼進行B操作之前A操作的變化都能被B操作觀察到,也就是說B能看到A對變量進行的修改。 *這裡的先後指的是執行順序的先後,與時間無關。例如在下面偽代碼中:

// 在線程A執行,定為A操作
i = 0;

// 線程B執行,定義為B操作
j = i;

// 線程C執行,定義為C操作
i = 1;

假設A操作先於B操作發生,暫時忽略C操作,那麼最終得到的結果必定是i = j = 1;但是如果此時加入C操作,並且跟A、B操作沒有確定先行發生關係,那麼最終的結果就變成了不確定,因為C可能在B之前執行也可能在B之後執行,所以此時就會出現數據不準確的情況。如果一開始沒有A操作先行於B操作這個前提的話,那麼就算沒有C操作,結果也是不確定的。

當然,符合先行發生原則的並不一定按照這個規則來執行,只有在操作之間會有依賴的時候(即下一個操作用到上個操作的變量),此時的先行發生原則才一定適用。例如在下面的偽代碼中,雖然符合先行發生原則,但是也不保證能有序執行。

// 同一線程執行以下操作
// A操作
int i = 0;
// B操作
int j = 1;

這裡完全符合程序次序規則(先行發生原則的一種),但是兩個操作之間並沒有依賴,所以虛擬機完全可以對其進行重排序,使得B操作在A操作之前執行,當然這對程序的正確性並沒有影響。

那麼該如何判斷是否符合先行發生原則呢?就連前面的例子都是通過假設來得出先行發生的。莫慌,Java內存模型為我們提供一些規則,只要符合這些規則之一,那就符合先行發生原則。可以類比為先行發生原則為接口,下面的規則則為實現此接口的實現類。

  • 程序次序規則:在同一個線程中,代碼書寫在前面的操作先行發生於書寫在後面的操作。(以編譯後的class文件為準)
  • 管程鎖定規則:對於同一把鎖,unlock操作總是先行發生於後面對此鎖的lock操作之前。後面指的是時間上的順序。
  • volatile變量規則:對於volatile修飾的變量中,對此變量的寫操作總是先行發生於後面對此變量的讀操作。這裡的後面同樣指的是時間上的順序。
  • 線程啟動規則:一個線程的start()方法先行發生於該線程的每一個動作,也就是說線程的start()方法要先於該線程的run()方法中的任何操作。如下面例子,我在線程A中改變了共享變量i的值,然後在啟動B線程,B線程中run方法是讀取並打印i的值,執行1W次,最終的結果讀取到的都為1:
public static int i = 0;

public static void main(String[] args) {
for (int k = 0; k < 10000; k++) testThread();
}

public static void testThread() {
Thread threadB = new Thread(() -> {
System.err.println("線程B中i的值為:" + i);
System.err.println("線程B執行結束");
});
new Thread(() -> {
i = 1;
// 在修改了共享變量i的值後,啟動線程B
threadB.start();
System.err.println("線程A中執行完之後i的值為:" + i);

}).start();
}

結果圖:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到代碼中斷時間的發生。
  • 線程終止規則:線程的所有操作先行於該線程的終止檢測,也就是先於join()方法執行。如下面代碼中,我在A線程對共享變量i執行100W的自增,再執行100W-1的自減,執行1000次左右,最終join的所有結果都一定是1。
public static int i = 0;

public static void main(String[] args) throws InterruptedException {
// 執行1000次
for (int k = 0; k < 1000; k++) {
i = 0;
testThread();
}
}

public static void testThread() throws InterruptedException {
Thread threadA = new Thread(() -> {
int k = 0;
while (k++ < 100 * 100 * 100) {
i++;
}
while (--k > 1) {
i--;
}
System.err.println("線程A中執行完之後i的值為:" + i);
});
threadA.start();
// 加上下面這段代碼的話,join之前讀到的i可能為0也可能大於0(不一定是1),原因是變量i主內存的read和write操作沒有固定順序
// TimeUnit.NANOSECONDS.sleep(1);
System.out.println("主線程中開啟線程A後i的值為:" + i);
// 線程A終止
threadA.join();
// join之後的結果一定為1
System.err.println("Join之後i的值為:" + i);
}

結果圖:

「乾貨!」史上最詳細Java內存模型以及交互,看完之後一通百通

  • 對象終結規則:一個對象的初始化完成(構造函數執行完畢)先行於其finalize()方法的開始。
  • 傳遞性:如果A操作先行於B操作,B操作先行於C操作,那麼A操作先行於C操作。

這8種就是Java提供的不需要任何同步器的自然規則了,只要符合在8條之一,那麼就符合先行發生原則;反之,則不然。可以通過下面的例子理解:

// 對象中有一個變量i
private int i = 0;
public int getI() {
return i;
}

public void setI(int i) {
this.i = i;
}
// 在線程A執行set操作A
setI(1);

// 在線程B執行相同對象的get操作B
int j = getI();

我們假設在時間上A操作先執行,然後再接著執行B操作,那麼B得到的i是多少

呢?

我們將上面的規則一個個的往裡套,不同線程,程序次序規則OUT;沒有加鎖和volatile關鍵字,管程鎖定和volatile變量規則OUT;關於線程的三個規則和對象終止規則也不符合,OUT;最後一個更不用提,OUT;綜上,這個操作並不符合先行發生原則,所以這個操作是沒法保證的,也就是說B得到的變量i為1為0都有可能,即是線程不安全的。所以判斷線程是否安全的依據是先行發生原則,跟時間順序並沒有太大的關係。

像上面這種情況要修正的話,使其符合其中一條規則即可,例如加上volatile關鍵字或者加鎖(同一把鎖)都可以解決這個問題。


分享到:


相關文章: