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

可能是把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)

分享創造快樂