歡迎光臨
每天分享高質量文章

可能是把Java記憶體區域講的最清楚的一篇文章

來自:Java面試通關手冊(微訊號:Java_Guide)

寫在前面(常見面試題)

基本問題:

  • 介紹下 Java 記憶體區域(執行時資料區)

  • Java 物件的建立過程(五步,建議能默寫出來並且要知道每一步虛擬機器做了什麼)

  • 物件的訪問定位的兩種方式(控制代碼和直接指標兩種方式)

拓展問題:

  • String類和常量池

  • 8種基本型別的包裝類和常量池

1   概述

對於 Java 程式員來說,在虛擬機器自動記憶體管理機制下,不再需要像C/C++程式開發程式員這樣為內一個 new 操作去寫對應的 delete/free 操作,不容易出現記憶體洩漏和記憶體上限溢位問題。正是因為 Java 程式員把記憶體控制權利交給 Java 虛擬機器,一旦出現記憶體洩漏和上限溢位方面的問題,如果不瞭解虛擬機器是怎樣使用記憶體的,那麼排查錯誤將會是一個非常艱巨的任務。

2 執行時資料區域

Java 虛擬機器在執行 Java 程式的過程中會把它管理的記憶體劃分成若干個不同的資料區域。

執行時資料區域

這些組成部分一些事執行緒私有的,其他的則是執行緒共享的。

執行緒私有的:

  • 程式計數器

  • 虛擬機器棧

  • 本地方法棧

執行緒共享的:

  • 方法區

  • 直接記憶體

2.1 程式計數器

程式計數器是一塊較小的記憶體空間,可以看作是當前執行緒所執行的位元組碼的行號指示器。位元組碼直譯器工作時透過改變這個計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳轉、異常處理、執行緒恢復等功能都需要依賴這個計數器來完。

另外,為了執行緒切換後能恢復到正確的執行位置,每條執行緒都需要有一個獨立的程式計數器,各執行緒之間計數器互不影響,獨立儲存,我們稱這類記憶體區域為“執行緒私有”的記憶體。

從上面的介紹中我們知道程式計數器主要有兩個作用:

  1. 位元組碼直譯器透過改變程式計數器來依次讀取指令,從而實現程式碼的流程控制,如:順序執行、選擇、迴圈、異常處理。

  2. 在多執行緒的情況下,程式計數器用於記錄當前執行緒執行的位置,從而當執行緒被切換回來的時候能夠知道該執行緒上次執行到哪兒了。

註意:程式計數器是唯一一個不會出現OutOfMemoryError的記憶體區域,它的生命週期隨著執行緒的建立而建立,隨著執行緒的結束而死亡。

2.2 Java 虛擬機器棧

與程式計數器一樣,Java虛擬機器棧也是執行緒私有的,它的生命週期和執行緒相同,描述的是 Java 方法執行的記憶體模型。

Java 記憶體可以粗糙的區分為堆記憶體(Heap)和棧記憶體(Stack),其中棧就是現在說的虛擬機器棧,或者說是虛擬機器棧中區域性變數表部分。 (實際上,Java虛擬機器棧是由一個個棧幀組成,而每個棧幀中都擁有:區域性變數表、運算元棧、動態連結、方法出口資訊。)

區域性變數表主要存放了編譯器可知的各種資料型別(boolean、byte、char、short、int、float、long、double)、物件取用(reference型別,它不同於物件本身,可能是一個指向物件起始地址的取用指標,也可能是指向一個代表物件的控制代碼或其他與此物件相關的位置)。

Java 虛擬機器棧會出現兩種異常:StackOverFlowError 和 OutOfMemoryError。

  • StackOverFlowError: 若Java虛擬機器棧的記憶體大小不允許動態擴充套件,那麼當執行緒請求棧的深度超過當前Java虛擬機器棧的最大深度的時候,就丟擲StackOverFlowError異常。

  • OutOfMemoryError: 若 Java 虛擬機器棧的記憶體大小允許動態擴充套件,且當執行緒請求棧時記憶體用完了,無法再動態擴充套件了,此時丟擲OutOfMemoryError異常。

Java 虛擬機器棧也是執行緒私有的,每個執行緒都有各自的Java虛擬機器棧,而且隨著執行緒的建立而建立,隨著執行緒的死亡而死亡。

2.3 本地方法棧

和虛擬機器棧所發揮的作用非常相似,區別是: 虛擬機器棧為虛擬機器執行 Java 方法 (也就是位元組碼)服務,而本地方法棧則為虛擬機器使用到的 Native 方法服務。 在 HotSpot 虛擬機器中和 Java 虛擬機器棧合二為一。

本地方法被執行的時候,在本地方法棧也會建立一個棧幀,用於存放該本地方法的區域性變數表、運算元棧、動態連結、出口資訊。

方法執行完畢後相應的棧幀也會出棧並釋放記憶體空間,也會出現 StackOverFlowError 和 OutOfMemoryError 兩種異常。

2.4 堆

Java 虛擬機器所管理的記憶體中最大的一塊,Java 堆是所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件實體,幾乎所有的物件實體以及陣列都在這裡分配記憶體。

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap).從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集演演算法,所以Java堆還可以細分為:新生代和老年代:在細緻一點有:Eden空間、From Survivor、To Survivor空間等。進一步劃分的目的是更好地回收記憶體,或者更快地分配記憶體。

在 JDK 1.8中移除整個永久代,取而代之的是一個叫元空間(Metaspace)的區域(永久代使用的是JVM的堆記憶體空間,而元空間使用的是物理記憶體,直接受到本機的物理記憶體限制)。

推薦閱讀:

  • 《Java8記憶體模型—永久代(PermGen)和元空間(Metaspace)》:http://www.cnblogs.com/paddix/p/5309550.html

2.5 方法區

方法區與 Java 堆一樣,是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。

HotSpot 虛擬機器中方法區也常被稱為 “永久代”,本質上兩者並不等價。僅僅是因為 HotSpot 虛擬機器設計團隊用永久代來實現方法區而已,這樣 HotSpot 虛擬機器的垃圾收集器就可以像管理 Java 堆一樣管理這部分記憶體了。但是這並不是一個好主意,因為這樣更容易遇到記憶體上限溢位問題。

相對而言,垃圾收集行為在這個區域是比較少出現的,但並非資料進入方法區後就“永久存在”了。

2.6 執行時常量池

執行時常量池是方法區的一部分。Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有常量池資訊(用於存放編譯期生成的各種字面量和符號取用)

既然執行時常量池時方法區的一部分,自然受到方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 異常。

JDK1.7及之後版本的 JVM 已經將執行時常量池從方法區中移了出來,在 Java 堆(Heap)中開闢了一塊區域存放執行時常量池。

——圖片來源:https://blog.csdn.net/wangbiao007/article/details/78545189

推薦閱讀:

  • 《Java 中幾種常量池的區分》: https://blog.csdn.net/qq_26222859/article/details/73135660

2.7 直接記憶體

直接記憶體並不是虛擬機器執行時資料區的一部分,也不是虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用。而且也可能導致OutOfMemoryError異常出現。

JDK1.4中新加入的 NIO(New Input/Output) 類,引入了一種基於通道(Channel)快取區(Buffer) 的 I/O 方式,它可以直接使用Native函式庫直接分配堆外記憶體,然後透過一個儲存在 Java 堆中的 DirectByteBuffer 物件作為這塊記憶體的取用進行操作。這樣就能在一些場景中顯著提高效能,因為避免了在 Java 堆和 Native 堆之間來回覆制資料

本機直接記憶體的分配不會收到 Java 堆的限制,但是,既然是記憶體就會受到本機總記憶體大小以及處理器定址空間的限制。

3 HotSpot 虛擬機器物件探秘

透過上面的介紹我們大概知道了虛擬機器的記憶體情況,下麵我們來詳細的瞭解一下 HotSpot 虛擬機器在 Java 堆中物件分配、佈局和訪問的全過程。

3.1 物件的建立

下圖便是 Java 物件的建立過程,我建議最好是能默寫出來,並且要掌握每一步在做什麼。

Java物件的建立過程

①類載入檢查: 虛擬機器遇到一條 new 指令時,首先將去檢查這個指令的引數是否能在常量池中定位到這個類的符號取用,並且檢查這個符號取用代表的類是否已被載入過、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。

②分配記憶體:類載入檢查透過後,接下來虛擬機器將為新生物件分配記憶體。物件所需的記憶體大小在類載入完成後便可確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從 Java 堆中劃分出來。分配方式“指標碰撞”“空閑串列” 兩種,選擇那種分配方式由 Java 堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定

記憶體分配的兩種方式:(補充內容,需要掌握)

選擇以上兩種方式中的哪一種,取決於 Java 堆記憶體是否規整。而 Java 堆記憶體是否規整,取決於 GC 收集器的演演算法是”標記-清除”,還是”標記-整理”(也稱作”標記-壓縮”),值得註意的是,複製演演算法記憶體也是規整的

記憶體分配併發問題(補充內容,需要掌握)

在建立物件的時候有一個很重要的問題,就是執行緒安全,因為在實際開發過程中,建立物件是很頻繁的事情,作為虛擬機器來說,必須要保證執行緒是安全的,通常來講,虛擬機器採用兩種方式來保證執行緒安全:

  • CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止。虛擬機器採用 CAS 配上失敗重試的方式保證更新操作的原子性。

  • TLAB: 為每一個執行緒預先在Eden區分配一塊兒記憶體,JVM在給執行緒中的物件分配記憶體時,首先在TLAB分配,當物件大於TLAB中的剩餘記憶體或TLAB的記憶體已用盡時,再採用上述的CAS進行記憶體分配

③初始化零值: 記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),這一步操作保證了物件的實體欄位在 Java 程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。

④設定物件頭: 初始化零值完成之後,虛擬機器要對物件進行必要的設定,例如這個物件是那個類的實體、如何才能找到類的元資料資訊、物件的雜湊嗎、物件的 GC 分代年齡等資訊。 這些資訊存放在物件頭中。 另外,根據虛擬機器當前執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。

⑤執行 init 方法: 在上面工作都完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但從 Java 程式的視角來看,物件建立才剛開始, 方法還沒有執行,所有的欄位都還為零。所以一般來說,執行 new 指令之後會接著執行 方法,把物件按照程式員的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。

3.2 物件的記憶體佈局

在 Hotspot 虛擬機器中,物件在記憶體中的佈局可以分為3快區域:物件頭實體資料對齊填充

Hotspot虛擬機器的物件頭包括兩部分資訊第一部分用於儲存物件自身的自身執行時資料(雜湊嗎、GC分代年齡、鎖狀態標誌等等),另一部分是型別指標,即物件指向它的類元資料的指標,虛擬機器透過這個指標來確定這個物件是那個類的實體。

實體資料部分是物件真正儲存的有效資訊,也是在程式中所定義的各種型別的欄位內容。

對齊填充部分不是必然存在的,也沒有什麼特別的含義,僅僅起佔位作用。 因為Hotspot虛擬機器的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或2倍),因此,當物件實體資料部分沒有對齊時,就需要透過對齊填充來補全。

3.3 物件的訪問定位

建立物件就是為了使用物件,我們的Java程式透過棧上的 reference 資料來操作堆上的具體物件。物件的訪問方式有虛擬機器實現而定,目前主流的訪問方式有①使用控制代碼②直接指標兩種:

  1. 控制代碼: 如果使用控制代碼的話,那麼Java堆中將會劃分出一塊記憶體來作為控制代碼池,reference 中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件實體資料與型別資料各自的具體地址資訊;

    使用控制代碼
  2. 直接指標:  如果使用直接指標訪問,那麼 Java 堆對像的佈局中就必須考慮如何放置訪問型別資料的相關資訊,而reference 中儲存的直接就是物件的地址。

使用直接指標

這兩種物件訪問方式各有優勢。使用控制代碼來訪問的最大好處是 reference 中儲存的是穩定的控制代碼地址,在物件被移動時只會改變控制代碼中的實體資料指標,而 reference 本身不需要修改。使用直接指標訪問方式最大的好處就是速度快,它節省了一次指標定位的時間開銷。

四  重點補充內容

String 類和常量池

1 String 物件的兩種建立方式:

     String str1 = "abcd";
     String str2 = new String("abcd");
     System.out.println(str1==str2);//false

這兩種不同的建立方法是有差別的,第一種方式是在常量池中拿物件,第二種方式是直接在堆記憶體空間建立一個新的物件。

記住:只要使用new方法,便需要建立新的物件。

2 String 型別的常量池比較特殊。它的主要使用方法有兩種:

  • 直接使用雙引號宣告出來的 String 物件會直接儲存在常量池中。

  • 如果不是用雙引號宣告的 String 物件,可以使用 String 提供的 intern 方String.intern() 是一個 Native 方法,它的作用是:如果執行時常量池中已經包含一個等於此 String 物件內容的字串,則傳回常量池中該字串的取用;如果沒有,則在常量池中建立與此 String 內容相同的字串,並傳回常量池中建立的字串的取用。

          String s1 = new String("計算機");
          String s2 = s1.intern();
          String s3 = "計算機";
          System.out.println(s2);//計算機
          System.out.println(s1 == s2);//false,因為一個是堆記憶體中的String物件一個是常量池中的String物件,
          System.out.println(s3 == s2);//true,因為兩個都是常量池中的String對

3 String 字串拼接

          String str1 = "str";
          String str2 = "ing";

          String str3 = "str" + "ing";//常量池中的物件
          String str4 = str1 + str2; //在堆上建立的新的物件     
          String str5 = "string";//常量池中的物件
          System.out.println(str3 == str4);//false
          System.out.println(str3 == str5);//true
          System.out.println(str4 == str5);//false

儘量避免多個字串拼接,因為這樣會重新建立物件。如果需要改變字串的話,可以使用 StringBuilder 或者 StringBuffer。

String s1 = new String(“abc”);這句話建立了幾個物件?

建立了兩個物件。

驗證:

        String s1 = new String("abc");// 堆記憶體的地值值
        String s2 = "abc";
        System.out.println(s1 == s2);// 輸出false,因為一個是堆記憶體,一個是常量池的記憶體,故兩者是不同的。
        System.out.println(s1.equals(s2));// 輸出true

結果:

false
true

解釋:

先有字串”abc”放入常量池,然後 new 了一份字串”abc”放入Java堆(字串常量”abc”在編譯期就已經確定放入常量池,而 Java 堆上的”abc”是在執行期初始化階段才確定),然後 Java 棧的 str1 指向Java堆上的”abc”。

8種基本型別的包裝類和常量池

  • Java 基本型別的包裝類的大部分都實現了常量池技術,即Byte,Short,Integer,Long,Character,Boolean;這5種包裝類預設建立了數值[-128,127]的相應型別的快取資料,但是超出此範圍仍然會去建立新的物件。

  • 兩種浮點數型別的包裝類 Float,Double 並沒有實現常量池技術。

        Integer i1 = 33;
        Integer i2 = 33;
        System.out.println(i1 == i2);// 輸出true
        Integer i11 = 333;
        Integer i22 = 333;
        System.out.println(i11 == i22);// 輸出false
        Double i3 = 1.2;
        Double i4 = 1.2;
        System.out.println(i3 == i4);// 輸出false

Integer 快取原始碼:

/**
*此方法將始終快取-128到127(包括端點)範圍內的值,並可以快取此範圍之外的其他值。
*/

    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

應用場景:

  1. Integer i1=40;Java 在編譯的時候會直接將程式碼封裝成Integer i1=Integer.valueOf(40);,從而使用常量池中的物件。

  2. Integer i1 = new Integer(40);這種情況下會建立新的物件。

  Integer i1 = 40;
  Integer i2 = new Integer(40);
  System.out.println(i1==i2);//輸出false

Integer比較更豐富的一個例子:

  Integer i1 = 40;
  Integer i2 = 40;
  Integer i3 = 0;
  Integer i4 = new Integer(40);
  Integer i5 = new Integer(40);
  Integer i6 = new Integer(0);

  System.out.println("i1=i2   " + (i1 == i2));
  System.out.println("i1=i2+i3   " + (i1 == i2 + i3));
  System.out.println("i1=i4   " + (i1 == i4));
  System.out.println("i4=i5   " + (i4 == i5));
  System.out.println("i4=i5+i6   " + (i4 == i5 + i6));   
  System.out.println("40=i5+i6   " + (40 == i5 + i6));     

結果:

i1=i2   true
i1=i2+i3   true
i1=i4   false
i4=i5   false
i4=i5+i6   true
40=i5+i6   true

解釋:

陳述句i4 == i5 + i6,因為+這個運運算元不適用於Integer物件,首先i5和i6進行自動拆箱操作,進行數值相加,即i4 == 40。然後Integer物件無法與數值進行直接比較,所以i4自動拆箱轉為int值40,最終這條陳述句轉為40 == 40進行數值比較。

參考:

  • 《深入理解Java虛擬機器:JVM高階特性與最佳實踐(第二版》

  • 《實戰java虛擬機器》

  • https://www.cnblogs.com/CZDblog/p/5589379.html

  • https://www.cnblogs.com/java-zhao/p/5180492.html

  • https://blog.csdn.net/qq_26222859/article/details/73135660

  • https://blog.csdn.net/cugwuhan2014/article/details/78038254


編號822,輸入編號直達本文

●輸入m獲取文章目錄

推薦↓↓↓

Web開發

更多推薦18個技術類微信公眾號

涵蓋:程式人生、演演算法與資料結構、駭客技術與網路安全、大資料技術、前端開發、Java、Python、Web開發、安卓開發、iOS開發、C/C++、.NET、Linux、資料庫、運維等。

贊(0)

分享創造快樂