淺談Java記憶體模型以及交互

  • 2019 年 12 月 2 日
  • 筆記

一、Java的運行時區域

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

  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虛擬機把這些交互規範為以下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虛擬機》

對於普通的變數來說(非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關鍵字修飾的變數會有特殊的規則:

  1. 在對變數執行use操作之前,其前一步操作必須為對該變數的load操作;在對變數執行load操作之前,其後一步操作必須為該變數的use操作。也就是說,使用volatile修飾的變數其read、load、use都是連續出現的,所以每次使用變數的時候都要從主記憶體讀取最新的變數值,替換私有記憶體的變數副本值(如果不同的話)。
  2. 在對變數執行assign操作之前,其後一步操作必須為store;在對變數執行store之前,其前一步必須為對相同變數的assign操作。也就是說,其對同一變數的assign、store、write操作都是連續出現的,所以每次對變數的改變都會立馬同步到主記憶體中。
  3. 在主記憶體中有變數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),此時是沒錯的,然後兩個執行緒開始第二組操作,這樣最終其中一個執行緒的操作會被覆蓋掉,導致數據的不準確。如下面程式碼:

public class TestForVolatile {        public static volatile int i = 0;        public static void main(String[] args) throws InterruptedException {          // 創建四個執行緒,每個執行緒對i執行一定次數的自增操作          new Thread(() -> {              int k = 0;              while (k++ < 10000) {                  i++;              }              System.err.println("執行緒" + Thread.currentThread().getName() + "執行完畢");          }).start();          new Thread(() -> {              int k = 0;              while (k++ < 10000) {                  i++;              }              System.err.println("執行緒" + Thread.currentThread().getName() + "執行完畢");          }).start();          new Thread(() -> {              int k = 0;              while (k++ < 10000) {                  i++;              }              System.err.println("執行緒" + Thread.currentThread().getName() + "執行完畢");          }).start();          new Thread(() -> {              int k = 0;              while (k++ < 10000) {                  i++;              }              System.err.println("執行緒" + Thread.currentThread().getName() + "執行完畢");          }).start();       // 睡眠一定時間確保四個執行緒全部執行完畢          Thread.sleep(1000);        // 最終結果為33555,沒有預期的4W          System.out.println(i);              }    }

結果圖:

解釋一下:因為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)

如果單靠volatile和synchronized來維持程式的有序性的話,那麼難免會變得有些繁瑣。然而大部分時候我們並不需要這樣做,因為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();      }    

結果圖:

  • 執行緒中斷規則:對執行緒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);      }

結果圖:

  • 對象終結規則:一個對象的初始化完成(構造函數執行完畢)先行於其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關鍵字或者加鎖(同一把鎖)都可以解決這個問題。

Tags: