1. 程式人生 > >淺談Java的記憶體模型以及互動

淺談Java的記憶體模型以及互動

本文的記憶體模型只寫虛擬機器記憶體模型,物理機的不予描述。

 

Java記憶體模型

  在Java中,虛擬機器將執行時區域分成6中,如下圖:

                

  1.  程式計數器:用來記錄當前執行緒執行到哪一步操作。在多執行緒輪換的模式中,噹噹前執行緒時間片用完的時候記錄當前操作到哪一步,重新獲得時間片時根據此記錄來恢復之前的操作。
  2. 虛擬機器棧:這就是我們平時所說的棧了,一般用來儲存區域性變量表、運算元表、動態連結等。
  3. 本地方法棧:這是另一個棧,用來提供虛擬機器中用到的本地服務,像執行緒中的start方法,JUC包裡經常使用的CAS等方法都是從這來的。
  4. 堆:主要的儲存區域,平時所建立的物件都是放在這個區域。其內部還分為新生代、老年代和永久代(也就是方法區,在Java8之後刪除了),新生代又分為兩塊Survivor和一塊Eden,平時建立的物件其實都是在Eden區建立的,不過這些之後再跟垃圾回收器寫在一篇文章。
  5. 方法區:儲存符號引用、被JVM載入的類資訊、靜態變數的地方。在Java8之後方法區被移除,使用元空間來存放類資訊,常量池和其他東西被移到堆中(其實在7的時候常量池和靜態變數就已經被移到堆中),不再有永久代一說。刪除的原因大致如下:
    1. 容易造成記憶體溢位或記憶體洩漏,例如 web開發中JSP頁面較多的情況。
    2. 由於類和方法的資訊難以確定,不好設定大小,太大則影響年老代,太小容易記憶體溢位。

    3. GC不好處理,回收效率低下,調優困難。

  6. 常量池:存放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修飾的變數在程式碼語句的前後會加上一些記憶體屏障來禁止指令的重新排序。

但這兩個語義都是因為在使用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),此時是沒錯的,然後兩個執行緒開始第二組操作,這樣最終其中一個執行緒的操作會被覆蓋掉,導致資料的不準確。如果你覺得這是JOJO的奇妙比喻,可以看下面的程式碼來理解

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原則等內容後面再進行補充。如果文章有任何不對的地方望大家指出,感激不盡!