Java的內存模型


  今天周末,閑來無事,干嘛呢?當然看書啊,總結啊!讀完書光回想是沒用的,必須有個自己的第一遍理解,第二遍理解.....,就比如簡簡單單的JMM說來輕松,網上博客雖多,圖文代碼加以解釋的甚少,並沒有給讀者一種層次感。所以我想寫這么一篇博客,算是總結自己的第一遍理解,同時盡自己最大的可能讓大家理解的時候有一種層次感。

  整篇博客都是參考《深入理解Java虛擬機》加上自己讀了兩遍之后的理解完成的,創作不易,望轉載告之,謝謝!

 

先在記錄此篇博客之前,給一個大概的目錄結構方便讀者留意:

1、Java內存模型介紹

  • 什么是內存模型?-------對比Cache存儲層次結構
  • 工作內存與主內存是什么?-----------結合線程理解
  • 工作與主內存之間的交互-----------線程之間的交互(有圖)
  • 如何保證線程一致性?-------------八種操作的協議規定  

2、Volatile關鍵字規則

  • Volatile的兩層含義-----------可見性與禁止重排序(代碼舉例)
  • 關於Volatile的誤解----------Volatile在高並發條件下不一定是安全的+Volatile並非原子性的(代碼舉例)
  • Volatile與Synchronized簡單比較--------------Volatile大部分情況下比Synchronized性能高

3、double與long的非原子性

 


 

一、Java的內存模型介紹

  第一次見到Java的內存模型,正如《深入理解JVM》中那樣提到Cache與主存的關系,我也第一時間想起來了這個,於是便畫了如下的存儲系統的層次結構圖

 

  Cache與主內存之間為了能保持一致性,會不斷跟Cache進行交互,也就是地址映像,主要有直接映像、全相聯映像、組相聯映像,emmmm...打住,這不是正題,只是順便給自己個機會看《操作系統》就當做復習下,好了接下來是正題,先畫出JMM圖如下:

  

  從圖中可以看出要學好Java線程(高並發)是必須要知道JMM的,同時工作內存就好比Cache,與主內存之間進行交互,需要注意的的是這里的工作內存與主內存並不是我們所知道的內存這個概念,也不只是簡單的Java Heap與Java Stack那樣簡單的概念,為了進一步知道工作內存與主內存是什么,接下來先了解它們,此時你可以先不用看圖,了解后再看更佳。

  1、工作內存與主內存是什么?它們有什么規定?

      (1)工作內存:每條線程都有自己的內存,這就是工作內存,在工作內存中主要是保存使用到的變量在主內存的拷貝(即存放主內存中工作內存用到的變量拷貝);

      (2)主內存:VM內存的一部分,是新增變量的地方以及每個線程中所有變量來源之處,是可以被共享的數據元素。

      (3)內存模型中的變量:是指實例字段與靜態字段構成數組對象的元素,即能被共享的數據元素,而不是被線程私有的局部變量與方法參數等。所有的變量都會存儲在主內存(VM內存的一部分)中

      (4)每條線程對變量的操作都必須在工作內存中進行,而不能直接操作主內存

      (5)每條線程之間的工作內存是不能被共享的,不能相互訪問各自的變量,線程之間的變量“交流”只能通過主內存來實現

      (6)如果非要將JMM中的主內存與工作內存跟Java HeapJava StackMethod Area做比較(實則兩者不是一個概念),那么可以認為工作內存就是Java Stack(很好理解,這是因為Java Stack是線程私有的,線程之間不能共享),主內存就是Java Heap中實例數據(很好理解,Java Heap中對象的實例數據是可以共享的)

 

   2、工作內存與主內存之間的交互

      這是理解多線程最重要的部分,多線程必然會涉及到內存之間的交互,Java的多線程之間的交互實則就是工作內存與主內存之間的交互,那么它們之間肯定要有相互交互的規定(即協議),主要分為八種:

      (1) Lock:作用於主內存的變量,將該變量標識為某一條線程獨占的資源,其他線程不能占用。

      (2) Unlock:與Lock相反,作用於主內存變量,釋放被Lock的變量。

      (3)Read:作用於主內存的變量,將該變量從主內從中取出,傳到線程的工作內存中。之后Load加載到工作內存的變量副本中。

      (4) Load:將Read取到的變量放入工作內存的變量副本中。

      (5) Use:將工作內存中變量傳遞給執行引擎,聽從VM指令的安排(被使用到時會有相關指令)

      (6)Assign:接受執行引擎返回Use之后執行的結果值,將該值賦值給工作內存中對應的變量。

      (7)Store:將功能內存中的值傳遞到主內存中,之后Write放入主內存的變量中。

      (8) Write:將Store的值放入主內存的變量中。

   

      好了,突然一下子要記住八種操作,頭也會而且可能還記不住,那么結合圖總結下吧:

      (1) 要把一個變量從主內存copy到工作內存,只需要Read->Load順序即可。

          

        (其中Variable Duplicate變量拷貝是屬於工作內存Working Memory的,這里主要是為了能更好的展示,所以分離了,希望不要誤解!)

 

      (2)如果把工作內存的變量同步回主內存,只需要Store->Write順序即可。

          

         (其中Variable是屬於Main Memory的,這里主要是為了能更好的展示,所以分離了,希望不要誤解!)

 

      (3) 如果VM用到這個變量(即有關的操作指令),則執行Use->Assign即可。

         這里就不畫圖了,簡單來說就是我們在程序中用到變量,對變量初始化、更新等,也就是只要在VM中有相關操作該變量的指令,就會從工作內存中被Use,之后Assign賦值會寫回公共內存,如i++,先拿到i,之后i+1,最后賦值i=i。

    注意:這些操作之間並不要求一定要連續,只要保證前后順序即可,比如Read A, Read B, Load A, Load B即可,而不需要Read A, Load A, Read B, Load B

 

  3、如何保證線程的一致性?

    微觀上講我們需要實現線程的一致性這個目標,而宏觀上就是如何確保在高並發下是安全的,其實主要是通過八種操作之間的規定,才能保證多線程下一致性:

      (1) 不允許readloadstorewrite中單一操作出現

      (2)不允許最近的賦值動作即assgin被丟棄(工作內存中變量改變了必須同步回主內存中);

      (3) 不允許線程中變量沒有改變(即沒有assign操作),就把該變量數據同步回主內存(不接受毫無理由的同步回主內存);

      (4) 一個變量的產生只能在主內存中,不允許工作內存使用一個未被初始化(即未被assgin賦值或load加載)的變量,即一個新的變量產生必須在主內存中,再read->load到工作內存變量副本中,之后進行中Use->Assign賦值,最后才有可能stroe->write同步回主存,換句話說就是對一個變量進行use/store之前必須先進行assign/load操作。

      (5)LockUnlock操作是成對出現的,一個變量只能被一個lock操作,一個線程可以多次lock操作。

      (6) 一個線程的lockunlock操作要對應,不允許線程Aunlock線程B的變量。同理,如果沒有lock,那么不允許unlock

      (7) 一個變量執行lock操作,會將工作內存中對應的變量清空,在執行引擎獲取這個變量之前,必須load/assgin初始化這個變量,這是因為執行引擎要獲取的變量必須是最新的值,在lock-unlock過程中該變量可能發生改變,所以必須重新初始化保證獲得最新的值。

 

二、Volatile關鍵字

  實際上,我們在程序中操作的變量是工作內存的變量副本,那么每次變量被改變(Use->Assign)后,都會同步回(Store->Write)主內存中,保持了變量的一致性,但是這只是單線程的情況下,那么在多線程情況下呢?比如線程A已經改變了變量的值,還沒來的及同步回主內存,線程B就已經從主內存中將舊的變量值Read->Load到工作內存。這就造成了被線程A修改后的變量值對線程B不可見的問題,導致變量不一致。最輕量的能解決此問題就是利用好Volatile關鍵字,那么Volatile是如何實現的呢?

  簡單來說被Volatile關鍵字的變量一旦被改變后就會立即同步回內存中,保證其他線程能獲得最新的當前變量值,而普遍變量不會立即同步回內存(事實上什么時候同步回內存是不確定的),所以導致不可見性。

  (1)保證此變量對所有線程的可見性:

    ① 線程的可見性並不是誤認為Volatile對所有線程的立即可見,也就是對某個變量寫操作立馬能反映到所有線程中,因此在高並發的情況下是安全的”,“Volatile在高並發下是安全的”這個最后的結論是不成立的。

    ② Java中相關的操作並不是原子操作,比如i++,其實是分為兩步(可以使用Javap反編譯查看指令代碼)的:先i+1,之后i=i+1。所以Volatile在高並發情況下並不是安全的。 

 1 /**
 2  * 演示使用Volatile在高並發下狀態的不安全性:
 3  * @author Jian
 4  *
 5  */
 6 public class VolatileDemo {
 7     private static final int THREAD_NUM = 10;//線程數目
 8     private static final long AWAIT_TIME = 5*1000;//等待時間
 9     private volatile static int  counter = 0;
10     
11     public static void increase() { counter++; }
12     
13     public static void main(String[] args) throws InterruptedException {
14         ExecutorService exe = Executors.newFixedThreadPool(THREAD_NUM);
15         for (int i = 0; i < THREAD_NUM; i++) {
16             exe.execute(new Runnable() {
17                 @Override
18                 public void run() {
19                     for (int j = 0; j < 1000; j++) {
20                         increase();
21                     }
22                 }
23             });
24         }
25         //檢測ExecutorService線程池任務結束並且是否關閉:一般結合shutdown與awaitTermination共同使用
26         //shutdown停止接收新的任務並且等待已經提交的任務
27         exe.shutdown();
28         //awaitTermination等待超時設置,監控ExecutorService是否關閉
29         while (!exe.awaitTermination(AWAIT_TIME, TimeUnit.SECONDS)) {
30                 System.out.println("線程池沒有關閉");
31         }
32         System.out.println(counter);
33     }
34 }

 按道理說最后變量i的結果應該是10*1000=10000,但是運行后你會發現輸出結果都是小於10000且各不相同的值,造成這樣的結果實則不是Volatile的鍋,而是Java的非原子性,只是希望我們在關注並使用Volatile關鍵字的時候需要知道在高並發下不一定是安全的。

 

  (2使用Volatile可以禁止指令重排序優化:

    也就是一般普通變量(未被Volatile修飾)只能保證最后的變量結果是對的,但是不會保證變量涉及到的程序代碼中順序與底層執行指令順序是一致。需要注意的是重排序是一種編譯過程中的一種優化手段。

    下列只能用偽代碼的形式舉例,因為指令重排序涉及到反編譯指令碼等(我並不了解,實際上一點也不)

 

 1 public class VolatileDemo2 {
 2     //是否已經完成初始化標志
 3     private /*volatile*/ static boolean initialized = false;
 4     private static int taskA = 0;
 5     public static void main(String[] args) throws InterruptedException {
 6         ExecutorService exe = Executors.newFixedThreadPool(2);
 7         //線程A
 8         exe.execute(new Runnable() {
 9             @Override
10             public void run() {
11                 //A線程的任務是加1,完成初始化
12                 taskA++;
13                 //initialized初始化完成,賦值為true,必須是先執行+1操作,才能賦值true
14                 //但是由於重排序這里可能先於taskA++執行,導致讀取到的結果可能為0。
15                 initialized = true;
16             }
17         });
18         exe.execute(new Runnable() {
19             @Override
20             public void run() {
21                 //線程B的任務是等待線程A初始化完成后,再讀取taskA的值
22                 while(!initialized) {
23                     try {
24                         System.out.println("線程A還未初始化");
25                         Thread.sleep(1000);
26                     } catch (InterruptedException e) {
27                         e.printStackTrace();
28                     }
29                 }
30                 System.out.println(taskA);
31             }
32         });
33         exe.shutdown();
34         while (!exe.awaitTermination(5*1000, TimeUnit.SECONDS)) {
35                 System.out.println("線程池沒有關閉");
36         }
37     }
38 }

 

需要主要的就是下面的代碼,雖然線程A中是保證了有序執行,再標志初始化完成,但是在指令中可能是先賦initialized為true,然后線程B這時候“搶先一步”先讀initialized,那么變量taskA的值就可能為0(實際業務中可能會是致命錯誤!)

taskA++;
initialized = true;

如果不使用volatile關鍵字,那么只有當明確賦值了initialized的方法被調用,接下來的任務才能不會出錯(只要結果是true就行,不用管指令順序):

boolean volatile initialized;
public void setInitialized(){
    initialized = true;
}

public otherWorks(){
  //初始化完成方法被明確調用,強制initialized結果為true,不用管指令順序
  setInitialized();  
  while(!initialized){
      //other thread's tasks
    }    
}

    (3)VolatileSynchronized性能對比:一般情況下Volatile的同步機制要優於Synchronized(但是VMSynchronized做了很多優化,所以其實也是說不准的),但是Volatile好就好在讀取變量跟普通變量的讀取幾乎沒啥差別,但是寫操作會慢一點(這是因為會在代碼中加入內存屏障,保證指令不會亂序)

 4doublelong型變量非原子性

    (1doublelong的非原子性:JMM中規定longdouble這樣的64位並且沒有被volatile修飾數據可以划分為兩部分32位來進行操作,即VM允許對64位的數據類型的loadstorereadwrite不保證其原子性。因為非原子性的存在,按理論上來說某個線程在極小的概率下可能會存在讀到“半個變量”的情況。

    (2)雖然由於longdouble非原子性存在,但是VM對其的操作是具有原子性的,即對操作原子性,對數據非原子性。所以longdouble不需要被要求加volatile關鍵字。

 

 

 

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM