1. 程式人生 > 實用技巧 >JVM學習筆記之Java記憶體區域與OOM

JVM學習筆記之Java記憶體區域與OOM

Java 記憶體區域與 OOM

虛擬機器基本結構圖示

一、執行時資料區域

執行時資料區域 圖示

標註顏色的兩塊區域:所有執行緒共享的資料區域

1.1 程式計數器(progams count Register)

程式計數器是一塊比較小的記憶體空間,可以把它看作當前執行緒正在執行的位元組碼的行號指示器。程式計數器裡面記錄的是當前執行緒正在執行的那一條位元組碼指令的地址。當然,程式計數器是執行緒私有的。但是,如果當前執行緒執行的是一個執行緒本地的方法,那麼此時這個執行緒的程式計數器為 undefined。

本地方法是使用關鍵字 native 修飾的方法

如:public native String intern();

程式計數器的作用:

  • 位元組碼直譯器通過改變程式計數器來一次讀取指令,從而實現程式碼的流程控制,如順序執行、選擇、迴圈、異常處理等。
  • 在多執行緒的條件下,程式計數器用來記錄當前執行緒執行的位置,從而當執行緒被切換回來的時候能夠知道這個執行緒上次執行到哪個地方了。

程式計數器的特點:

  • 是一塊比較小的儲存空間
  • 是執行緒私有的,即每一個執行緒都有一個獨立程式計數器
  • 是唯一一個不會出現 OOM(OutOfMemoryError)的記憶體區域
  • 宣告週期隨著執行緒的開始而建立,隨著執行緒的終止而結束

1.2 堆(Head)

堆定義:

  • 在 Java 虛擬機器中堆是一個執行緒共享的區域。在執行區域中,堆主要用於存放 new 關鍵字建立的物件例項和分配的陣列空間。

  • 堆在 Java 虛擬機器啟動時被建立。Java 堆是完全自動化管理的,通過垃圾回收機制,垃圾物件會自動清理釋放記憶體,而不需要顯示地釋放。

  • 如果堆的分配的空間太多,覺得沒必要,也可以手動調整。

  • 如果計算需要的記憶體多於可以提供的堆記憶體,則 Java 虛擬機器將會丟擲記憶體溢位異常(OutOfMemoryError)。

堆記憶體劃分及其引數調整圖示:

堆空間調整引數:

  • -Xms:設定初始分配大小,預設為實體記憶體的 1/64
  • -Xmx:最大分配記憶體,預設為實體記憶體的 1/4
  • -XX:+PrintGCDetails:輸出詳細的 GC 處理日誌
  • -XX:+PrintGCTimeStamps:輸出 GC 的時間戳資訊
  • -XX:+PrintGCDateStamps:輸出 GC 的時間戳資訊(以日期的形式)
  • -XX:+PrintHeapAtGC:在 GC 進行處理的前後列印堆記憶體資訊
  • -Xloggc:(SavePath):設定日誌資訊儲存檔案
  • 在堆記憶體的調整策略中,基本上只要調整兩個引數:-Xms 和-Xmx

其它引數請看官網文件

堆空間瞭解

新生代:這是短暫居住的地方,分為兩個空間

  • Eden 空間:使用在此空間上分配的 new 關鍵字記憶體建立的物件。
  • 倖存者空間(Survivor Space):這是包含從 Eden 空間收集 java 垃圾收集後存活的物件的池。
  • Eden、S0、S1 3 個部分預設的比例是 8:1:1 的大小

老年代:這個池基本上包含了終身和虛擬(預留)空間,並將持有年輕一代垃圾收集後存活的那些物體。

  • 終身空間:這個記憶體池包含多個垃圾收集裝置後倖存下來的物件,這些物件在從倖存者空間收集垃圾後存活。

注意:如果在 Java 堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,Java 虛擬機器將會丟擲 OutOfMemoryError 異常。

1.3 本地方法棧(Native Method Stacks)

本地方法棧類似於 Java 棧,主要儲存了本地方法呼叫的狀態。區別不過是 Java 棧為 JVM 執行 Java 方法服務,而本地方法棧為 JVM 執行 Native 方法服務。本地方法棧也會丟擲 StackOverflowError 和 OutOfMemoryError 異常。

1.4 方法區(Method Area)

什麼是方法區:

  • 在 JVM 中,方法區是可供各個執行緒共享執行時的記憶體區域。
  • 方法區域傳統語言中的編譯程式碼儲存區或者作業系統程序的正文段的作用非常類似,它儲存了每一個類的結構資訊,例如執行時常量池、欄位和方法資料、類的建構函式和普通方法的位元組碼內容、還包括一些類、例項、介面初始化的時候用到的特殊方法。
  • 在 Hotspot 虛擬機器中,JDK 1.7 版本稱作永久代(Permanent Generation),而在 JDK 1.8 則稱為 元空間(Metapace)。
  • 方法區有個別名叫做非堆(Non-Heap),用於區別於 Java 堆區。

方法區 jvm 引數調整:

#Java8 以前版本引數設定
-XX:PermSize=10m
-XX:MaxPermSize=55m

#Java8後引數設定
-XX:MetaspaceSize=10m
-XX:MaxMetaspaceSize=55m

方法區的特點:

  • 執行緒共享:方法區是堆的一個邏輯部分,因此和對一樣是執行緒共享的。整個虛擬機器中只有一個方法區。

  • 記憶體回收低:方法區中的資訊一般需要長期存在,回收一遍記憶體之後可能之後少量資訊無效。對方法區的記憶體回收主要是 對常量池的回收和對型別的解除安裝。

  • JVM 規範對方法區的定義比較寬鬆:和堆一樣,允許固定大小,也允許可擴充套件大小,還允許不實現垃圾回收。

執行時常量池:

  • 類載入後,Class 檔案結構中常量池中的資料將被儲存在執行時常量池中

  • 具備動態性,在執行期間也可以將新的常量放入池中,如 String 類的 intern()方法

注意:根據《Java 虛擬機器規範》的規定,如果方法區無法滿足新的記憶體分配需求時,將丟擲 OutOfMemoryError 異常。

1.5 Java 虛擬機器棧(Java Virtual Machine Stacks)

與程式計數器一樣,Java 虛擬機器棧(Java Virtual Machine Stack)也是執行緒私有的,它的生命週期與執行緒相同。

虛擬機器棧描述的是 Java 方法執行的執行緒記憶體模型:

  • 每個方法被執行的時候,Java 虛擬機器都會同步建立一個棧幀(StackFrame)用於儲存區域性變量表、運算元棧、動態連線、方法出口等資訊。
  • 每一個方法被呼叫直至執行完畢的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。

注意:

  • 如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲 StackOverflowError 異常;
  • 如果 Java 虛擬機器棧容量可以動態擴充套件,當棧擴充套件時無法申請到足夠的記憶體會丟擲 OutOfMemoryError 異常。

說明:

  • 字(Word) 指的是計算機記憶體中佔據一個單獨的記憶體單元編號的一組二進位制串,一般 32 位計算機上一個字為 4 個位元組長度

二、Java 虛擬機器物件

2.1 物件的建立過程

圖示

相關概念認識:

  • 指標碰撞(Bump The Pointer):
  • 假設 Java 堆中記憶體是絕對規整的,所有被使用過的記憶體都被放在一邊,空閒的記憶體被放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間方向挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump The Pointer)。
  • 空閒列表(Free List):
    • 使用一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。
  • 本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)
    • 把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在 Java 堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)。
    • 虛擬機器是否使用 TLAB,可以通過-XX:+/-UseTLAB 引數來設定。

2.2 物件的記憶體佈局

圖示

物件頭(Header):

Mark Word

  • 用於儲存物件自身的資料,如:雜湊碼(HashCode)、  GC 分代年齡、鎖狀態標誌、執行緒持有鎖、偏向執行緒 ID、偏向時間戳等,這部分資料的長度在 32 為和 64 位的虛擬機器(未開啟壓縮指標)中分別為 32bit 和 64bit

  • MarkWord 是根據物件的狀態區分不同的狀態位,從而區分不同的儲存結構(32bit 下)

    • 正常物件: 物件的 HashCode (25bit) + 物件的分代年齡(4bit)+是否偏向鎖狀態(1bit, 值為 0) + 鎖標誌狀態(2bit,值 01)
    • 偏向物件: 執行緒 ID(23bit )+ Epoch (2bit)+ 物件的分代年齡(4bit)+是否偏向鎖狀態(1bit) + 鎖標誌狀態(2bit)

  • 在標頭檔案 src/hotspot/share/oops/markOop.hpp(openJDK10)的註釋中,描述的物件頭 Mark Work 的儲存狀態
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
//

物件頭的另一部分是型別指標(Klass Pointer):

  • Klass Pointer(型別指標):即指向當前物件的類的元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。
  • 並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說查詢物件的元資料資訊並不一定要經過物件本身。
  • 另外,如果是陣列,物件頭中還有一塊用於存放陣列長度的資料,因為虛擬機器可以通過普通 Java 物件的元資料資訊確定 Java 物件的大小,但是從陣列的元資料中無法確定陣列的大小。

物件例項資料(Instance Data)

對齊填充(Padding)

  • 第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。
  • 由於 HotSpot VM 的自動記憶體管理系統要求物件起始地址必須是 8 位元組的整數倍,換句話說就是物件的大小必須是 8 位元組的整數倍。
  • 物件頭正好是 8 位元組的倍數(1 倍或者 2 倍),因此當物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。

2.3 物件的訪問地址

直接指標訪問(訪問速度更快)

控制代碼發訪問

三、記憶體區域 OOM 異常實戰

3.1 堆記憶體 OOM

/**
 * VM Args : -Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError
 *  限制堆記憶體大小: -Xmx20m -Xms20m
 *  匯出當前的記憶體堆轉儲快照: -XX:+HeapDumpOnOutOfMemoryError ,可以使用 MAT分析工具開啟匯出.hprof的檔案進行分析
 */
public class HeadOOM {
    static class OOMObject{}
    public static void main(String[] args) {
        List<OOMObject> list=new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

簡單的分析步驟:

  • 首先先判斷是記憶體洩漏(Memory Leak)還是記憶體溢位(Memory Overflow)
  • 如果是記憶體洩漏,可進一步通過工具檢視洩漏物件到 GC Roots 的引用鏈,找到洩漏物件是通過怎樣的引用路徑、與哪些 GC Roots 相關聯,才導致垃圾收集器無法回收它們
  • 否則,應當檢查 Java 虛擬機器的堆引數(-Xmx 與-Xms)設定,與機器的記憶體對比,看看是否還有向上調整的空間。再從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長、儲存結構設計不合理等情況,儘量減少程式執行期的記憶體消耗。

注意:

  • 記憶體洩漏(Memory Leak):是指程式在申請記憶體後,無法釋放已申請的記憶體空間,一次記憶體洩露危害可以忽略,但記憶體洩露堆積後果很嚴重,無論多少記憶體,遲早會被佔光。最終導致記憶體溢位
  • 記憶體溢位(Memory Overflow):是指程式在申請記憶體時,沒有足夠的記憶體空間供其使用,出現 out of memory;比如申請了一個 integer,但給它存了 long 才能存下的數,那就是記憶體溢位。

3.2 虛擬機器棧和本地方法棧溢位

請求的棧深度大於虛擬機器所允許的最大深度

/**
 * VM arg:  -Xss128k
 *
 * 測試:如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲StackOverflowError異常
 */
public class JavaVMStackSOF {

    private int stackLlength = 0;

    private void stackLeak() {
        this.stackLlength++;
        this.stackLeak();
    }
    public static void main(String[] args) {
        JavaVMStackSOF javaVMStackSOF=new JavaVMStackSOF();
        try {
            javaVMStackSOF.stackLeak();
        } catch (Throwable e) {
            System.out.println("棧長度 "+ javaVMStackSOF.stackLlength);
            throw e;
        }
    }
}

//輸出
棧長度 1000
Exception in thread "main" java.lang.StackOverflowError
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:14)

當擴充套件棧容量無法申請到足夠的記憶體時

/**
 * VM arg:  -Xss128k
 *
 * 如果虛擬機器的棧記憶體允許動態擴充套件,當擴充套件棧容量無法申請到足夠的記憶體時,將丟擲OutOfMemoryError異常。
 */
public class JavaVMStackSOF2 {

    private static int stackLlength = 0;

    private static void test(){
        long  unuse1, unuse2, unuse3, unuse4, unuse5, unuse6, unuse7, unuse8, unuse9, unuse10, unuse11, unuse12, unuse13, unuse14, unuse15, unuse16, unuse17, unuse18, unuse19, unuse20, unuse21, unuse22, unuse23, unuse24, unuse25, unuse26, unuse27, unuse28, unuse29, unuse30, unuse31, unuse32, unuse33, unuse34, unuse35, unuse36, unuse37, unuse38, unuse39, unuse40, unuse41, unuse42, unuse43, unuse44, unuse45, unuse46, unuse47, unuse48, unuse49, unuse50, unuse51, unuse52, unuse53, unuse54, unuse55, unuse56, unuse57, unuse58, unuse59, unuse60, unuse61, unuse62, unuse63, unuse64, unuse65, unuse66, unuse67, unuse68, unuse69, unuse70, unuse71, unuse72, unuse73, unuse74, unuse75, unuse76, unuse77, unuse78, unuse79, unuse80, unuse81, unuse82, unuse83, unuse84, unuse85, unuse86, unuse87, unuse88, unuse89, unuse90, unuse91, unuse92, unuse93, unuse94, unuse95, unuse96, unuse97, unuse98, unuse99, unuse100;
        stackLlength++;
        test();
        unuse1= unuse2= unuse3= unuse4= unuse5= unuse6= unuse7= unuse8= unuse9= unuse10= unuse11= unuse12= unuse13= unuse14= unuse15= unuse16= unuse17= unuse18= unuse19= unuse20= unuse21= unuse22= unuse23= unuse24= unuse25= unuse26= unuse27= unuse28= unuse29= unuse30= unuse31= unuse32= unuse33= unuse34= unuse35= unuse36= unuse37= unuse38= unuse39= unuse40= unuse41= unuse42= unuse43= unuse44= unuse45= unuse46= unuse47= unuse48= unuse49= unuse50= unuse51= unuse52= unuse53= unuse54= unuse55= unuse56= unuse57= unuse58= unuse59= unuse60= unuse61= unuse62= unuse63= unuse64= unuse65= unuse66= unuse67= unuse68= unuse69= unuse70= unuse71= unuse72= unuse73= unuse74= unuse75= unuse76= unuse77= unuse78= unuse79= unuse80= unuse81= unuse82= unuse83= unuse84= unuse85= unuse86= unuse87= unuse88= unuse89= unuse90= unuse91= unuse92= unuse93= unuse94= unuse95= unuse96= unuse97= unuse98= unuse99= unuse100=0;
    }
    public static void main(String[] args) {
        try {
            test();
        } catch (Throwable e) {
            System.out.println("棧長度 "+ stackLlength);
            throw e;
        }
    }
}

//輸出
棧長度 52
Exception in thread "main" java.lang.StackOverflowError
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF2.test(JavaVMStackSOF2.java:14)
	at cn.hdj.jvm.memoryarea.JavaVMStackSOF2.test(JavaVMStackSOF2.java:15)

3.3 方法區和執行時常量池溢位

常量池溢位

/**
 * 方法區和執行時常量池  OOM
 * 在JDK 6或更早之前的HotSpot虛擬機器中,常量池都是分配在永久代中,
 * 使用-XX:PermSize 或 -XX:MaxPermSize引數限制永久的的大小,當超出-XX:MaxPermSize限制的大小時會丟擲OOM
 * <p>
 * <p>
 * <p>
 * 自JDK7起,原本存放在永久代的字串常量池被移至Java堆之中,
 * 所以在JDK 7及以上版本,限制方法區的容量對該測試用例來說是毫無意義的,只有限制堆的大小才會出現OOM
 * <p>
 * JVM  arg:
 * Java7 : -XX:PermSize=6m  -XX:MaxPermSize=6m
 * Java8 : -XX:MaxMetaspaceSize=6m
 */
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        //測試OOM
        Set<String> strings = new HashSet<>();
        Short i = 0;
        while (true) {
            strings.add(String.valueOf(i++).intern());
        }
    }
}

String.intern()返回引用的測試

以下執行環境為:openjdk jdk8u265

public class RuntimeConstantPoolOOM_intern {
    public static void main(String[] args) {
        // jdk8u265 中 sun.misc.Version#launcher_name  不是java 而是openjdk

        String str1 = new StringBuilder("計算機").append("軟體").toString();
        System.out.println(str1.intern() == str1);  //true
        String str2 = new StringBuilder("open").append("jdk").toString();
        System.out.println(str2.intern() == str2);  //false
    }
}

先了解 intern 方法:

  • 在 JDK 6 中,intern()方法會把首次遇到的字串例項複製到永久代的字串常量池中儲存,返回這個字串例項在永久代儲存的引用
  • 而在 JDK 7 中,intern()方法實現就不需要再拷貝字串的例項到永久代了,既然字串常量池已經移到 Java 堆中,那隻需要在常量池裡記錄一下首次出現的例項引用
  • 還有所有的字串字面量和字串值常量表達式都會呼叫 intern,存入字串常量池中

再簡單分析:

  • 在呼叫 str1.intern()之前,通過 intern 方法的瞭解,字串 "計算機軟體" 還沒有存入常量池中
  • 呼叫了 str1.intern()後,在 JDK7 及以後版本,會把首次出現的字串例項引用,存入常量池中,並返回例項引用;所以,str1.intern() == str1 的比較結果為 true
  • 再看 str2 , 因為 sum.misc.Version 裡面的靜態常量 launcher_name 欄位的值“openjdk”再 JVM 啟動過程中被載入而存入常量池中了,導致在執行 str2.intern()時,不符合 intern()方法要求“首次遇到”的原則,所以 str2 的引用是堆裡的字串例項引用,str2.intern()則是常量池裡的引用,str2.intern() == str2 的結果為 false
  • 進一步瞭解 https://www.zhihu.com/question/51102308/answer/124441115

方法區 OOM

依賴

  <dependency>
            <groupId>cglib</groupId>
            <artifactId>cglib</artifactId>
            <version>3.3.0</version>
        </dependency>
/**
 * 方法區OOM  -XX:MaxMetaspaceSize=12m
 *
 * -XX:MetaspaceSize 元空間初始值大小
 * -XX:MaxMetaspaceSize 元空間最大值大小,預設值為-1,不限制大小
 * -XX:MinMetaspaceFreeRatio  作用是在垃圾收集之後控制最小的元空間剩餘容量的百分比,可減少因為元空間不足導致的垃圾收集的頻率
 *
 *
 * 注意:
 * 1. 在經常執行時生成大量動態類的應用場景裡,就應該特別關注這些類的回收狀況,防止方法區OOM
 */
public class JavaMethodAreaOOM {

    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer=new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false); //不使用快取
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o,objects);
                }
            });
            enhancer.create();
        }

    }

    static class OOMObject{}
}

3.4 本機直接記憶體溢位

/**
 * 直接記憶體OOM
 *
 * VM args : -Xmx20m -XX:MaxDirectMemorySize=10
 *
 * 注意:
 * 程式中又直接或間接使用了DirectMemory(典型的間接使用就是NIO),那就可以考慮重點檢查一下直接記憶體方面的原因了。
 */
public class DirectMemoryOOM {


    private final static int _1M = 1024*1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field declaredField = Unsafe.class.getDeclaredFields()[0];
        declaredField.setAccessible(true);
        Unsafe unsafe = (Unsafe) declaredField.get(null);
        while (true) {
            unsafe.allocateMemory(_1M);
        }
    }
}

程式碼:cn.hdj.jvm.memoryarea 包中
https://github.com/h-dj/Java-Learning

參考