1. 程式人生 > >JVM: JVM 記憶體劃分

JVM: JVM 記憶體劃分

概述

如果在大學裡學過或者在工作中使用過 C 或者 C++ 的讀者一定會發現這兩門語言的記憶體管理機制與 Java 的不同。在使用 C 或者 C++ 程式設計時,程式設計師需要手動的去管理和維護記憶體,就是說需要手動的清除那些不需要的物件,否則就會出現記憶體洩漏與記憶體溢位的問題。

如果你使用 Java 語言去開發,你就會發現大多數情況下你不用去關心無用物件的回收與記憶體的管理,因為這一切 JVM 虛擬機器已經幫我們做好了。瞭解 JVM 記憶體的各個區域將有助於我們深入瞭解它的管理機制,避免出現記憶體相關的問題和高效的解決問題。

引出問題

在 Java 程式設計時我們會用到許多不同型別的資料,比如臨時變數、靜態變數、物件、方法、類等等。 那麼他們的儲存方式有什麼不同嗎?或者說他們存在哪?

執行時資料區域

Java 虛擬機器在執行 Java 程式過程中會把它所管理的記憶體分為若干個不同的資料區域,各自有各自的用途。

這其中堆和方法區是執行緒之間共享的,而棧和程式計數器是執行緒私有的。

  • 程式計數器

    執行緒私有的,可以看作是當前執行緒所執行位元組碼的行號指示器。位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。分支、迴圈、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

    這時唯一一個沒有規定任何 OOM 異常的區域。

  • 虛擬機器棧

    虛擬機器棧也是執行緒私有的,生命週期與執行緒相同。棧裡面儲存的是方法的區域性變數物件的引用等等。

    在這片區域中,規定了兩種異常情況,當執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常。當虛擬機器棧動態擴充套件無法申請到足夠的記憶體時會丟擲 OOM 異常。

  • 本地方法棧

    和虛擬機器棧的作用相同,只不過它是為 Native 方法服務。HotSpot 虛擬機器直接將虛擬機器棧和本地方法棧合二為一了。

  • 堆是 Java 虛擬機器所管理記憶體中最大的一塊。是所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。這個區域唯一的作用就是存放物件例項,也就是 NEW 出來的物件。這個區域也是 Java 垃圾收集器的主要作用區域。

    當堆的大小再也無法擴充套件時,將會丟擲 OOM 異常。

    可以說,此記憶體區域唯一的作用就是存放物件例項,幾乎所有的物件例項和陣列都在這裡分配記憶體。

    Java 堆是垃圾收集管理的主要區域,因此也被稱為 GC 堆。垃圾收集都採用分代垃圾回收演算法,所以 Java 堆還可以細分:新聲代(再細緻一點分為 Eden,From Survivor,To Survivor)和老年代。進一步劃分的目的是跟好地回收記憶體,或者更快地分配記憶體。

  • 方法區

    方法區也是執行緒共享的記憶體區域,用於儲存已經被虛擬機器載入的類資訊常量靜態變數等等。當方法區無法滿足記憶體分配需求時,會丟擲 OOM 異常。這個區域也被稱為永久代。

補充

雖然上面的圖裡沒有執行時常量池和直接記憶體,但是這兩部分也是我們開發時經常接觸的。所以給大家補充出來。

  • 執行時常量池

    執行時常量池是方法區的一部分,Class 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用於存放編譯期生成的各種字面量符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。也會丟擲 OOM 異常。

  • 直接記憶體

    直接記憶體並不是虛擬機器執行時資料區的一部分,也不是 Java 虛擬機器規範中定義的記憶體區域,但是卻是NIO 操作時會直接使用的一塊記憶體,雖然不受虛擬機器引數限制,但是還是會受到本機總記憶體的限制,會丟擲 OOM 異常。

這裡有一個概念希望大家能夠清除,堆中使用分代垃圾回收演算法時的永久代表方法區,它並不在堆記憶體中,上面的圖片將其放在一起是為了說明分代垃圾回收演算法會作用在這幾個區域。

JDK 1.8 的改變

對於方法區,它是執行緒共享的,主要用於儲存類的資訊,常量池,方法資料,方法程式碼等。我們稱這個區域為永久代。它也是 JVM 垃圾回收作用的區域。

大部分程式設計師應該都見過 java.lang.OutOfMemoryError:PermGen space 異常,這裡的 PermGen space 其實指的就是方法區。由於方法區主要儲存類的相關資訊,所以對於動態生成類的情況比較容易出現永久代的記憶體溢位,典型的場景是在 JSP 頁面比較多的情況,容易出現永久代記憶體溢位。

在 JDK 1.8 中,HotSpot 虛擬機器已經沒有 PermGen space 方法區這個地方了,取而代之的是一個叫 Metaspace(元空間)的東西。

元空間與方法區最大的區別是:元空間不再虛擬機器中,而是使用本地記憶體。預設情況下,元空間的大小僅受本地記憶體限制。

常量區原本在方法區中,現在方法區被移除了,所以常量池被放倒了堆中。

這樣做的好處是:

這樣更改的好處:

  • 字串常量存在方法區中,容易出現效能問題和記憶體溢位。
  • 類和方法的資訊等比較難確定大小,因此對於方法區大小的指定比較困難,太小容易出現方法區溢位,太大容易導致堆的空間不足。
  • 方法區的垃圾回收會帶來不必要的複雜度,並且回收效率偏低(垃圾回收會在下一章給大家介紹)。

虛擬機器物件揭祕

物件的建立過程,最好是能記住,並且能知道每一步在做什麼。

  1. 類載入檢查:虛擬機器遇到一條 new 指令的時候,首先去檢查這個指令的引數能否在常量池中定位這個類的符號飲用,檢查這個類的符號引用所代表的類是否已被載入,解析,初始化過。如果沒有,那必須先執行響應的類載入過程。簡單來說,就是要看物件的類是否已經被載入過了。

  2. 分配記憶體:在類載入檢查通過後,接下來虛擬機器將會為新生物件分配記憶體。物件所需的記憶體大小在類載入完畢後便可以確定了,為物件分配空間的任務相當於把一塊確定大小的記憶體從 Java 堆中劃分出來。

    分配方式有指標碰撞和空閒列表兩種。選擇那種方式由 Java 堆是否規整決定,而 Java 堆是否規整由垃圾收集器是否帶有壓縮功能決定(複製演算法和標記整理演算法是規整的,標記清除演算法是不規整的)。

    記憶體分配併發問題

    • CAS 失敗重試,CAS 是客觀鎖的一種實現方式。
    • TLAB:為每一個執行緒預先在 Eden 分配一塊記憶體,JVM 在給執行緒中的物件分配記憶體時,首先在 TLAB 分配,如果不夠,使用 CAS 進行分配。
  3. 初始化零值:記憶體分配完畢後,虛擬機器將要分配的記憶體空間都初始化為零值(不包括物件頭)。這一步保證了物件例項在 Java 中不賦初值就可以直接使用。

  4. 設定物件頭:初始化零值完成之後,虛擬機器要對物件進行必要的設定。比如物件的雜湊碼,物件的 GC 分代年齡資訊,偏向鎖,這些資訊放在物件頭中。

  5. 執行 init 方法:上面工作完成後,從虛擬機器視角看,一個新的物件已經產生了。然後執行 init 方法,按照程式設計師的意願將物件進行初始化。

物件構成

HotSpot 虛擬機器中,物件在記憶體中的佈局可以分為三塊區域:物件頭,例項資料和對齊填充。

物件頭中包含兩部分資訊,第一部分用於儲存物件自身執行時資料(雜湊碼,GC 分代年齡,鎖狀態標誌),另一部分是型別指標,即指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。

例項資料部分儲存的物件的有效資訊。

對其填充起到的是佔位的作用。

物件的訪問定位

  1. 控制代碼。
  2. 直接指標。

補充

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

這兩種方式建立的物件是有差別的,第一種方式是在常量池中,第二種方式是在堆記憶體中。

  • 直接使用雙引號宣告創建出來的 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對
    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

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

先有字串 “abc” 放入常量池,然後 new 了一個字串 “abc” 放入 Java 堆。棧中的引用指向堆中的物件。

Java 基本型別的包裝類的大部分都實現了常量池技術,即 Byte,Short,Integer,Long,Character,Boolean。除了 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
  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