1. 程式人生 > >史上最詳細Java記憶體區域講解

史上最詳細Java記憶體區域講解

常見面試題

基本問題

  • 介紹下 Java 記憶體區域(執行時資料區)
  • Java 物件的建立過程(五步,建議能默寫出來並且要知道每一步虛擬機器做了什麼)
  • 物件的訪問定位的兩種方式(控制代碼和直接指標兩種方式)

拓展問題

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

一、概述

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

二、執行時資料區域

Java 虛擬機器在執行 Java 程式的過程中會把它管理的記憶體劃分成若干個不同的資料區域。JDK. 1.8 和之前的版本略有不同,下面會介紹到。

JDK 1.8之前:

JDK 1.8 :

執行緒私有的:

  • 程式計數器
  • 虛擬機器棧
  • 本地方法棧

執行緒共享的:

  • 方法區
  • 直接記憶體(非執行時資料區的一部分)

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虛擬機器棧,而且隨著執行緒的建立而建立,隨著執行緒的死亡而死亡。

擴充套件:那麼方法/函式如何呼叫?

Java 棧可用類比資料結構中棧,Java 棧中儲存的主要內容是棧幀,每一次函式呼叫都會有一個對應的棧幀被壓入Java棧,每一個函式呼叫結束後,都會有一個棧幀被彈出。

Java方法有兩種返回方式:

  1. return 語句。
  2. 丟擲異常。

不管哪種返回方式都會導致棧幀被彈出。

2.3 本地方法棧

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

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

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

2.4 堆

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

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC堆(Garbage Collected Heap).從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集演算法,所以Java堆還可以細分為:新生代和老年代:再細緻一點有:Eden空間、From Survivor、To Survivor空間等。進一步劃分的目的是更好地回收記憶體,或者更快地分配記憶體。 上圖所示的 eden區、s0區、s1區都屬於新生代,tentired 區屬於老年代。大部分情況,物件都會首先在 Eden 區域分配,在一次新生代垃圾回收後,如果物件還存活,則會進入 s0 或者 s1,並且物件的年齡還會加 1(Eden區->Survivor 區後物件的初始年齡變為1),當它的年齡增加到一定程度(預設為15歲),就會被晉升到老年代中。物件晉升到老年代的年齡閾值,可以通過引數 -XX:MaxTenuringThreshold 來設定。

2.5 方法區

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

方法區也被稱為永久代。很多人都會分不清方法區和永久代的關係,為此我也查閱了文獻。

方法區和永久代的關係

《Java虛擬機器規範》只是規定了有方法區這麼個概念和它的作用,並沒有規定如何去實現它。那麼,在不同的 JVM 上方法區的實現肯定是不同的了。 方法區和永久代的關係很像Java中介面和類的關係,類實現了介面,而永久代就是HotSpot虛擬機器對虛擬機器規範中方法區的一種實現方式。 也就是說,永久代是HotSpot的概念,方法區是Java虛擬機器規範中的定義,是一種規範,而永久代是一種實現,一個是標準一個是實現,其他的虛擬機器實現並沒有永久帶這一說法。

常用引數

JDK 1.8 之前永久代還沒被徹底移除的時候通常通過下面這些引數來調節方法區大小

-XX:PermSize=N //方法區(永久代)初始大小
-XX:MaxPermSize=N //方法區(永久代)最大大小,超過這個值將會丟擲OutOfMemoryError異常:java.lang.OutOfMemoryError: PermGen

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

JDK 1.8 的時候,方法區(HotSpot的永久代)被徹底移除了(JDK1.7就已經開始了),取而代之是元空間,元空間使用的是直接記憶體。

下面是一些常用引數:

-XX:MetaspaceSize=N //設定Metaspace的初始(和最小大小)
-XX:MaxMetaspaceSize=N //設定Metaspace的最大大小

與永久代很大的不同就是,如果不指定大小的話,隨著更多類的建立,虛擬機器會耗盡所有可用的系統記憶體。

為什麼要將永久代(PermGen)替換為元空間(MetaSpace)呢?

整個永久代有一個 JVM 本身設定固定大小上線,無法進行調整,而元空間使用的是直接記憶體,受本機可用記憶體的限制,並且永遠不會得到java.lang.OutOfMemoryError。你可以使用 -XX:MaxMetaspaceSize 標誌設定最大元空間大小,預設值為 unlimited,這意味著它只受系統記憶體的限制。-XX:MetaspaceSize 調整標誌定義元空間的初始大小如果未指定此標誌,則 Metaspace 將根據執行時的應用程式需求動態地重新調整大小。

當然這只是其中一個原因,還有很多底層的原因,這裡就不提了。

2.6 執行時常量池

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

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

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

2.7 直接記憶體

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

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

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

三、HotSpot 虛擬機器物件探祕

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

3.1 物件的建立

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

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

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

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

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

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

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

  • CAS+失敗重試: CAS 是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因為衝突失敗就重試,直到成功為止。虛擬機器採用 CAS 配上失敗重試的方式保證更新操作的原子性。
  • TLAB: 為每一個執行緒預先在Eden區分配一塊兒記憶體,JVM在給執行緒中的物件分配記憶體時,首先在TLAB分配,當物件大於TLAB中的剩餘記憶體或TLAB的記憶體已用盡時,再採用上述的CAS進行記憶體分配

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

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

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

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