JVM記憶體Dump原理與線上分析實戰

語言: CN / TW / HK

原創  得物技術 Bruce

1.前言

當前我們微服務容器化部署JVM 例項很多,常常需要進行JVM heap dump analysis,為了提升JVM 問題排查效率,得物技術保障團隊研究了JVM記憶體Dump 原理與設計開發了JVM 記憶體線上分析。

常見的JVM heap dump analysis 工具如: MAT,JProfile,最常用的功能是大物件分析。功能上本地分析工具更全面,在微服務架構下,成千上萬的例項當需要一次分析的時候,於是我們思考如何提供更方便更快的線上分析方便研發人員快速排障。

 

流程

傳統

線上分析

相比

hprof 獲取

jmap

jmap

相同

hprof 傳輸

1.上傳ftp或物件儲存。

2.生產環境涉及跨網脫敏。

3.跨網下載。

內網OSS(物件儲存)傳輸。

目前jvm 基本進入G1 大記憶體時代。越大記憶體dump 效果越明顯耗時降低(100倍耗時降低)為大規模dump分析打下基礎。

hprof 分析

本地MAT 、JProfiler等分析工具

線上分析、線上分析報告

優點:

  1. 不依賴任何軟體。
  2. 操作簡單,只需一鍵執行指令碼。
  3. 分析耗時比本地工具更快。
  4. 不受記憶體限制,支援大記憶體dump 分析。
  5. 自研不受商業限制。
  6. 微服務環境多例項同時併發分析,不受單機資源限制。

不足:

  1. MAT ,JProfile 功能更豐富

2.JVM 記憶體模型

首先我們快速過一下Java 的記憶體模型, 這部分不必深入,稍微瞭解不影響第三部分 JVM 記憶體分析原理。可回過頭來再看。

JVM 記憶體模型可以從共享和非共享理解,也可以從 stack,heap 理解。GC 主要作用於 heap 區, stack 的記憶體存在系統記憶體。

2.1 Run-Time Data Areas

Java 程式執行起來後,JVM 會把它所管理的記憶體劃分為若干個不同的資料區域。其中一些資料區是在 Java 虛擬機器啟動時建立的,只有在 Java 虛擬機器退出時才會銷燬。其他資料區是每個執行緒。每執行緒資料區在建立執行緒時建立,並在執行緒退出時銷燬。JVM 的資料區是邏輯記憶體空間,它們可能不是連續的實體記憶體空間。下圖顯示了 JVM 執行時資料區域:

  • PC Register

JVM 可以同時支援多個執行執行緒。每個 JVM 執行緒都有自己的 pc(程式計數器)暫存器。如果當前方法是 native方法則PC值為 undefined, 每個CPU 都有一個 PC,一般來說每一次指令之後,PC 值會增加,指向下一個操作指令的地址。JVM 使用PC 保持操作指令的執行順序,PC 值實際上就是指向方法區(Method Area) 的記憶體地址。

  • JVM Stacks

每個 JVM 執行緒都有一個私有 JVM Stack(堆疊), 用於儲存 Frames(幀)。JVM Stack的每一Frame(幀)都儲存當前方法的區域性變數陣列、運算元堆疊和常量池引用。

一個 JVM Stack可能有很多Frame(幀),因為線上程的任何方法完成之前,它可能會呼叫許多其他方法,而這些方法的幀也儲存在同一個 JVM Stack(堆疊)中。

JVM Stack 是一個先進後出(LIFO)的資料結構,所以當前的執行方法位於棧頂,每一個方法開始執行時返回、或丟擲一個未捕獲的異常,則次frame 被移除。

JVM Stack 除了壓幀和彈出幀之外,JVM 堆疊從不直接操作,所以幀可能是堆分配的。JVM 堆疊的記憶體不需要是連續的。

  • Native Method Stack

Native 基本為C/C++ 本地函式,超出了Java 的範疇,就不展開贅述了。接入進入共享區域Heap 區。

2.2 Heap

JVM 有一個在所有 JVM 執行緒之間共享的堆。堆是執行時資料區,從中分配所有類例項和陣列的記憶體。

堆是在虛擬機器啟動時建立的。物件的堆儲存由自動儲存管理系統(稱為垃圾收集器)回收;物件永遠不會被顯式釋放。JVM 沒有假設特定型別的自動儲存管理系統,可以根據實現者的系統要求選擇儲存管理技術。堆的記憶體不需要是連續的。

  • Method Area

JVM 有一個在所有 JVM 執行緒之間共享的方法區。方法區類似於常規語言編譯程式碼的儲存區,或類似於作業系統程序中的“文字”段。它儲存每個類的結構,例如執行時常量輪詢、欄位和方法資料,以及方法和建構函式的程式碼,包括在類和例項初始化和介面初始化中使用的特殊方法。

Method 區域是在虛擬機器啟動時建立的。儘管方法區在邏輯上是堆的一部分,但簡單的實現可能會選擇不進行垃圾收集或壓縮它。方法區可以是固定大小,也可以根據需要進行擴充套件。方法區的記憶體不需要是連續的。

  • Run-Time Constant Pool

執行時常量池是方法區的一部分。Claas 檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行時常量池中存放。

2.3 Thread

Java 程式最終執行的主體是執行緒,那麼JVM 執行時資料區可以按執行緒間是否共享來劃分:

  • 單個執行緒內共享的區: PC Register、JVM Stacks、Native Method stacks。
  • 所有執行緒共享的區: Heap、Method Area、Run-time Constant pool。

Pre-Threads:

  • JVM System Threads
  • Per Thread
  • Program Counter
  • Stack
  • Native Stack
  • Stack Restrictions
  • Frame
  • Local Variables Array
  • Operand Stack
  • Dynamic Linking

JVM System Threads:

如果你使用jconsole或者其他任何debug工具,有可能你會發現有大量的執行緒在後臺執行。這些後臺執行緒隨著main執行緒的啟動而啟動,即,在執行public static void main(String[])後,或其他main執行緒建立的其他執行緒,被啟動後臺執行。

Hotspot JVM 主要的後臺執行緒包括:

  • VM thread: 這個執行緒專門用於處理那些需要等待JVM滿足safe-point條件的操作。safe-point代表現在沒有修改heap的操作發生。這種型別的操作包括:”stop-the-world”型別的GC,thread stack dump,執行緒掛起,或撤銷物件偏向鎖(biased locking revocation)
  • Periodic task thread: 用於處理週期性事件(如:中斷)的執行緒
  • GC threads: JVM中,用於支援不同階段的GC操作的執行緒
  • Compiler threads: 用於在執行時,將位元組碼編譯為原生代碼的執行緒
  • Signal dispatcher thread: 接受傳送給JVM處理的訊號,並呼叫對應的JVM方法

Program Counter (PC)

當前操作指令或opcode的地址指標,如果當前方法是本地方法,則PC值為undefined。每個CPU都有一個PC,一般來說,每一次指令之後,PC值會增加,指向下一個操作指令的地址。JVM使用PC保持操作指令的執行順序,PC值實際上就是指向方法區(Method Area)中的記憶體地址。

Stack

每一個執行緒都擁有自己的棧(Stack),用於在本執行緒中正在執行的方法。棧是一個先進後出(LIFO)的資料結構,所以當前的執行方法位於棧頂。每一個方法開始執行時,一個新的幀(Frame)被建立(壓棧),並新增到棧頂。當方法正常執行返回,或方法執行時丟擲一個未捕獲的異常,則此幀被移除(彈棧)。棧,除了壓棧和彈棧操作外,不會被執行操作,因此,幀物件可以被分配在堆(Heap)記憶體中,並且不需要分配連續記憶體。

Native Stack

不是所有的JVM都支援本地方法,然而,基本上都會為每個執行緒,建立本地方法棧。如果JVM使用C-Linkage模型,實現了JNI(Java Native Invocation),那麼本地棧就會是一個C語言的棧。在這種情況下,本地棧中的方法引數和返回值順序將和C語言程式完全一致。一個本地的方法一般可以回撥JVM中的Java方法(依據具體JVM實現而定)。這樣的本地方法呼叫Java方法一般會使用Java棧實現,當前執行緒將從本地棧中退出,在Java棧中建立一個新的幀。

Stack Restrictions

棧可以使一個固定大小或動態大小。如果一個執行緒請求超過允許的棧空間,允許丟擲StackOverflowError。如果一個執行緒請求建立一個幀,而沒有足夠記憶體時,則丟擲OutOfMemoryError。

Frame

每一個方法被建立的時候都會建立一個 frame,每個 frame 包含以下資訊:

  • 本地變數陣列 Local Variable Array
  • 返回值
  • 操作物件棧 Operand Stack
  • 當前方法所屬類的執行時常量池

Local Variables Array

本地變數陣列包含所有方法執行過程中的所有變數,包括this引用,方法引數和其他定義的本地變數。對於類方法(靜態方法),方法引數從0開始,然後對於例項方法,引數資料的第0個元素是this引用。

本地變數包括:

基本資料型別

bits

bytes

boolean

32

4

byte

32

4

char

32

4

long

64

8

short

32

4

int

32

4

float

32

4

double

64

8

reference

32

4

reference

32

4

 
所有型別都佔用一個數據元素,除了long和double,他們佔用兩個連續陣列元素。(這兩個型別是64位的,其他是32位的)
 

Operand Stack

在執行位元組程式碼指令過程中,使用操作物件棧的方式,與在本機CPU中使用通用暫存器相似。大多數JVM的位元組碼通過壓棧、彈棧、複製、交換、操作執行這些方式來改變操作物件棧中的值。因此,在本地變數陣列中和操作棧中移動複製資料,是高頻操作。

Frame 被建立時,操作棧是空的,操作棧的每個項可以存放JVM 的各種型別,包括 long/double。操作棧有一個棧深,long/double 佔用2個棧深,操作棧呼叫其它有返回結果的方法時,會把結果push 到棧上。

下面舉例說明,通過操作物件棧,將一個簡單的變數賦值為0.

Java:

int i;

編譯後得到以下位元組碼:

 0:        iconst_0        // 將0壓到操作物件棧的棧頂
 1:        istore_1        // 從操作物件棧中彈棧,並將值儲存到本地變數1中

Dyanmic Linking

每個幀都包含一個引用指標,指向執行時常量池。這個引用指標指向當前被執行方法所屬物件的常量池。
當Java Class被編譯後,所有的變數和方法引用都利用一個引用標識儲存在class的常量池中。一個引用標識是一個邏輯引用,而不是指向實體記憶體的實際指標。JVM實現可以選擇何時替換引用標識,例如:class檔案驗證階段、class檔案載入後、高頻呼叫發生時、靜態編譯連結、首次使用時。然後,如果在首次連結解析過程中出錯,JVM不得不在後續的呼叫中,一直上報相同的錯誤。使用直接引用地址,替換屬性欄位、方法、類的引用標識被稱作繫結(Binding),這個操作只會被執行一次,因為引用標識都被完全替換掉,無法進行二次操作。如果引用標識指向的類沒有被載入(resolved),則JVM會優先載入(load)它。每一個直接引用,就是方法和變數的執行時所儲存的相對位置,也就是對應的記憶體偏移量。

Share Between Threads

  • Heap
  • Memory Management
  • Non-Heap Memory
  • Just In Time(JIT) compication
  • Method Area
  • Class File structure
  • classloader
  • Faster class Loading
  • Where is the method area
  • Run Time Constant pool
  • Exception Table
  • Symbol Table
  • Interned Strings(StringTable)

Heap

堆用作為class例項和資料在執行時分配儲存空間。陣列和物件不能被儲存在棧中,因為幀空間在建立時分配,並不可改變。幀中只儲存物件或者陣列的指標引用。不同於原始型別,和本地變數陣列的引用,物件被儲存在堆中,所以當方法退出時,這些物件不會被移除。這些物件只會通過垃圾回收來移除。

  • Young Generation,年輕代 - 在Eden 和 Survivor中來回切換
  • Old Generation (Tenured Generation),老年代或持久帶
  • Permanent Generation

Memory Management

物件和資料不會被隱形的回收,只有垃圾回收機制可以釋放他們的記憶體。

典型的執行流程如下:

a.新的物件和陣列使用年輕代記憶體空間進行建立

b.年輕代GC(Minor GC/Young GC)在年輕代內進行垃圾回收。不滿足回收條件(依然活躍)的物件,將被移動從eden區移動到survivor區。

c.老年代GC(Major GC/Full GC)一般會造成應用的執行緒暫停,將在年輕代中依然活躍的物件,移動到老年代Old Generation (Tenured Generation)。

d.Permanent Generation區的GC會隨著老年代GC一起執行。其中任意一個區域在快用完時,都會觸發GC操作。

Non-Heap Memory

屬於JVM內部的物件,將在非堆記憶體區建立。

非堆記憶體包括:

  • Permanent Generation - the method area,方法區 - interned strings,字串常量
  • Code Cache,程式碼快取。通過JIT編譯為原生代碼的方法所儲存的空間。

Just In Time (JIT) Compilation

Java位元組碼通過解釋執行,然後,這種方式不如JVM使用本地CPU直接執行原生代碼快。為了提供新能,Oracle Hotspot虛擬機器尋找熱程式碼(這些程式碼執行頻率很高),把他們編譯為原生代碼。原生代碼被儲存在非堆的code cache區內。通過這種方式,Hotspot VM通過最適當的方式,開銷額外的編譯時間,提高解釋執行的效率。

java執行時資料區域可以按執行緒每個內部共享和所有執行緒是否共享來理解。

Method Area

方法區中儲存每個類的的詳細資訊,如下:

  • Classloader Reference
  • Run Time Constant Pool

     Numeric constants

     Field references

     Method References

     Attributes

  • Field data

     Per field

         Name

         Type

         Modifiers

         Attributes

  • Method data

     Per method

        Name

        Return Type

        Parameter Types (in order)

        Modifiers

        Attributes

  • Method code

     Per method

         Bytecodes

         Operand stack size

          Local variable size

          Local variable table

                Exception table

                        Per exception handler

                        Start point

                        End point

                        PC offset for handler code

                        Constant pool index for exception class being caught

2.4 Class File 資料結構

Java:

ClassFile {
    u4                        magic;
    u2                        minor_version;
    u2                        major_version;
    u2                        constant_pool_count;
    cp_info                contant_pool[constant_pool_count – 1];
    u2                        access_flags;
    u2                        this_class;
    u2                        super_class;
    u2                        interfaces_count;
    u2                        interfaces[interfaces_count];
    u2                        fields_count;
    field_info                fields[fields_count];
    u2                        methods_count;
    method_info                methods[methods_count];
    u2                        attributes_count;
    attribute_info        attributes[attributes_count];
}
  • magic, minor_version, major_version:JDK規範制定的類檔案版本,以及對應的編譯器JDK版本.
  • constant_pool:類似符號表,但儲存更多的資訊。檢視“Run Time Constant Pool”章節
  • access_flags:class的修飾符列表
  • this_class:指向constant_pool中完整類名的索引。如:org/jamesdbloom/foo/Bar
  • super_class:指向constant_pool中父類完整類名的索引。如:java/lang/Object
  • interfaces:指向儲存在constant_pool中,該類實現的所有介面的完整名稱的索引集合。
  • fields:指向儲存在constant_pool中,該類中所有屬性的完成描述的索引集合。
  • methods:指向儲存在constant_pool中,該類中所有方法簽名的索引集合,如果方法不是抽象或本地方法,則方法體也儲存在對應的constant_pool中。
  • attributes:指向儲存在constant_pool中,該類的所有RetentionPolicy.CLASS和RetentionPolicy.RUNTIME級別的標註資訊。

2.5 JVM 執行時記憶體總結圖

 

隨著JDK 版本和不同廠商的實現,JVM 內部模型有些細微的不同,如JDK 1.8 永久代 -> 元資料空間 等等,大體的 JVM 模型還是差不多。

3.JVM 記憶體分析原理

JVM 記憶體分析的總目的是希望能夠清楚 JVM 各個部分的情況,然後完成TOP N 統計,給出一份 分析報告,方便快遞定位判斷問題根因。

我們一般使用 jmap 對正在執行的java 程序做 記憶體 dump形成 Hprof 檔案,然後下載到本地離線分析。那麼我們線上分析工具面臨的第一個問題就是對 hprof 檔案的解析。

3.1 Hprof 資料結構

當我們使用 jmap 生成 Hprof 檔案,因為它是二進位制檔案直接開啟如下:

這種檔案非常緊湊沒有“分隔符”錯一個位元組,就會失敗,通過 jvm 原始碼可以查到其有資料結構:

http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/services/heapDumper.cpp#l62

  • hprof 總體結構

c++:

HPRO_FILE{
    header    "JAVA PROFILE 1.0.2" (以0為結束)
    u4                  識別符號大小,識別符號用於表示 ,UTF8 Strings、Objects、Stack traces等,
              該值通常與機器CPU位數相關,32位是4,64位是8。
    u4                  high word
    u4                  low word 高位+地位 共同表達從 1970年以來的毫秒數,得到 dump 時的時間
    [record]* record 陣列
}
  • hprof record總體結構

Bash:

Record {
    u1    Tag
    u4      微妙,記錄從 header 得到的時間以來
    [u1]*   bytes 陣列,代表該 record 的內容
}
  • hprof record tags 列表

Record tags 列表比較長,可直接看線上原始碼:

http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/services/heapDumper.cpp#l87

Bash:

  TAG           BODY       notes
----------------------------------------------------------
 HPROF_UTF8               a UTF8-encoded name
               id         name ID
               [u1]*      utf8 字元 (no trailing zero)
 HPROF_LOAD_CLASS         新載入 class
                u4        class serial number (class 編號)
                id        class object ID
                u4        stack trace serial number(堆疊跟蹤序列號)
                id        class name ID
 HPROF_UNLOAD_CLASS       解除安裝 class
                u4        class serial_number(class 編號)
 HPROF_FRAME              a Java stack frame
                id        stack frame ID
                id        method name ID
                id        method signature ID
                id        source file name ID
                u4        class serial number
                i4        line number. >0: normal 正常
                                       -1: unknown 未知
                                       -2: compiled method 編譯方法
                                       -3: native method 本地方法
 HPROF_TRACE              a Java stack trace
               u4         stack trace serial number (stack trace 編號)
               u4         thread serial number (thread 編號)
               u4         number of frames(frames 數量)
               [id]*      stack frame IDs (堆疊幀 id)
 HPROF_ALLOC_SITES        gc 之後,heap 分配的 site 點
               u2         flags 0x0001: 增量 與 全量
                                0x0002: 按需分配與實時排序
                                0x0004: 是否強制 gs
               u4         cutoff ratio (截止率)
               u4         total live bytes
               u4         total live instances
               u8         total bytes allocated
               u8         total instances allocated
               u4         number of sites that follow
               [u1        is_array: 0:  normal object 
                                    2:  object array
                                    4:  boolean array
                                    5:  char array
                                    6:  float array
                                    7:  double array
                                    8:  byte array
                                    9:  short array
                                    10: int array
                                    11: long array
                u4        class serial number (序列號,啟動時可能為0)
                u4        stack trace serial number (stack trace 序列號)
                u4        number of bytes alive (活著的位元組數)
                u4        number of instances alive (活著的例項數)
                u4        number of bytes allocated (分配的位元組數)
                u4]*      number of instance allocated(分配的例項數)
                                    
 HPROF_START_THREAD       一個新的執行緒
               u4         thread serial number (thread 序列號)
               id         thread object ID
               u4         stack trace serial number
               id         thread name ID
               id         thread group name ID
               id         thread group parent name ID
 HPROF_END_THREAD         一個終止執行緒
               u4         thread serial number
 HPROF_HEAP_SUMMARY       heap 概要
               u4         total live bytes
               u4         total live instances
               u8         total bytes allocated
               u8         total instances allocated
 HPROF_CPU_SAMPLES        a set of sample traces of running threads
                u4        total number of samples
                u4        # of traces
               [u4        # of samples
                u4]*      stack trace serial number
 HPROF_CONTROL_SETTINGS   the settings of on/off switches
                u4        0x00000001: alloc traces on/off
                          0x00000002: cpu sampling on/off
                u2        stack trace depth
 When the header is "JAVA PROFILE 1.0.2" a heap dump can optionally
 be generated as a sequence of heap dump segments. This sequence is
 terminated by an end record. The additional tags allowed by format
 "JAVA PROFILE 1.0.2" are:
 HPROF_HEAP_DUMP_SEGMENT  denote a heap dump segment
               [heap dump sub-records]*
               The same sub-record types allowed by HPROF_HEAP_DUMP
 HPROF_HEAP_DUMP_END      denotes the end of a heap dump

HPROF_HEAP_DUMP 內容較多,單獨從上面抽出來:

http://hg.openjdk.java.net/jdk/jdk/file/ee1d592a9f53/src/hotspot/share/services/heapDumper.cpp#l175

Bash:

HPROF_HEAP_DUMP          記憶體dump 真正存放資料的地方
               [heap dump sub-records]*
                          有4中型別 sub-records:
               u1         sub-record type
               HPROF_GC_ROOT_UNKNOWN         unknown root (未知 root)
                          id         object ID
               HPROF_GC_ROOT_THREAD_OBJ      thread object
                          id         thread object ID  (通過 JNI新建立的可能為0)
                          u4         thread sequence number
                          u4         stack trace sequence number
               HPROF_GC_ROOT_JNI_GLOBAL      JNI global ref root (JNI 全域性引用跟)
                          id         object ID
                          id         JNI global ref ID
               HPROF_GC_ROOT_JNI_LOCAL       JNI local ref
                          id         object ID
                          u4         thread serial number
                          u4         frame # in stack trace (-1 表示 empty)
               HPROF_GC_ROOT_JAVA_FRAME      Java stack frame
                          id         object ID
                          u4         thread serial number
                          u4         frame # in stack trace (-1 表示 empty)
               HPROF_GC_ROOT_NATIVE_STACK    Native stack (本地方法)
                          id         object ID
                          u4         thread serial number
               HPROF_GC_ROOT_STICKY_CLASS    System class
                          id         object ID
               HPROF_GC_ROOT_THREAD_BLOCK    Reference from thread block
                          id         object ID
                          u4         thread serial number
               HPROF_GC_ROOT_MONITOR_USED    Busy monitor
                          id         object ID
               HPROF_GC_CLASS_DUMP            class 物件的 dump
                          id         class object ID
                          u4         stack trace serial number
                          id         super class object ID
                          id         class loader object ID
                          id         signers object ID
                          id         protection domain object ID
                          id         reserved
                          id         reserved
                          u4         instance size (in bytes)
                          u2         size of constant pool(常量池大小)
                          [u2,       constant pool index,(常量池索引)
                           ty,       type
                                     2:  object
                                     4:  boolean
                                     5:  char
                                     6:  float
                                     7:  double
                                     8:  byte
                                     9:  short
                                     10: int
                                     11: long
                           vl]*      and value
                          u2         number of static fields
                          [id,       static field name,
                           ty,       type,
                           vl]*      and value
                          u2         number of inst. fields (不包括 super)
                          [id,       instance field name,
                           ty]*      type
               HPROF_GC_INSTANCE_DUMP        正常 object 例項的 dump
                          id         object ID
                          u4         stack trace serial number
                          id         class object ID
                          u4         number of bytes that follow
                          [vl]*      instance field values (先是 class 的,然後 是 super 的,再  super 的 super ,這裡只是這些欄位值的 bytes,還需要按欄位型別轉換)
               HPROF_GC_OBJ_ARRAY_DUMP       dump of an object array
                          id         array object ID
                          u4         stack trace serial number
                          u4         number of elements
                          id         array class ID
                          [id]*      elements
               HPROF_GC_PRIM_ARRAY_DUMP      dump of a primitive array
                          id         array object ID
                          u4         stack trace serial number
                          u4         number of elements
                          u1         element type
                                     4:  boolean array
                                     5:  char array
                                     6:  float array
                                     7:  double array
                                     8:  byte array
                                     9:  short array
                                     10: int array
                                     11: long array
                          [u1]*      elements
  • HPROF tags

Bash:

enum  tag {
  // top-level records
  HPROF_UTF8                    = 0x01,
  HPROF_LOAD_CLASS              = 0x02,
  HPROF_UNLOAD_CLASS            = 0x03,
  HPROF_FRAME                   = 0x04,
  HPROF_TRACE                   = 0x05,
  HPROF_ALLOC_SITES             = 0x06,
  HPROF_HEAP_SUMMARY            = 0x07,
  HPROF_START_THREAD            = 0x0A,
  HPROF_END_THREAD              = 0x0B,
  HPROF_HEAP_DUMP               = 0x0C,
  HPROF_CPU_SAMPLES             = 0x0D,
  HPROF_CONTROL_SETTINGS        = 0x0E,
  // 1.0.2 record types
  HPROF_HEAP_DUMP_SEGMENT       = 0x1C,
  HPROF_HEAP_DUMP_END           = 0x2C,
  // field types
  HPROF_ARRAY_OBJECT            = 0x01,
  HPROF_NORMAL_OBJECT           = 0x02,
  HPROF_BOOLEAN                 = 0x04,
  HPROF_CHAR                    = 0x05,
  HPROF_FLOAT                   = 0x06,
  HPROF_DOUBLE                  = 0x07,
  HPROF_BYTE                    = 0x08,
  HPROF_SHORT                   = 0x09,
  HPROF_INT                     = 0x0A,
  HPROF_LONG                    = 0x0B,
  // data-dump sub-records
  HPROF_GC_ROOT_UNKNOWN         = 0xFF,
  HPROF_GC_ROOT_JNI_GLOBAL      = 0x01,
  HPROF_GC_ROOT_JNI_LOCAL       = 0x02,
  HPROF_GC_ROOT_JAVA_FRAME      = 0x03,
  HPROF_GC_ROOT_NATIVE_STACK    = 0x04,
  HPROF_GC_ROOT_STICKY_CLASS    = 0x05,
  HPROF_GC_ROOT_THREAD_BLOCK    = 0x06,
  HPROF_GC_ROOT_MONITOR_USED    = 0x07,
  HPROF_GC_ROOT_THREAD_OBJ      = 0x08,
  HPROF_GC_CLASS_DUMP           = 0x20,
  HPROF_GC_INSTANCE_DUMP        = 0x21,
  HPROF_GC_OBJ_ARRAY_DUMP       = 0x22,
  HPROF_GC_PRIM_ARRAY_DUMP      = 0x23
}
  • Hprof 解析

現在我們知道 hprof 雖然是 二進位制格式的檔案,但其也有資料結構,就是一條一條 record 記錄。那麼解析就按照對應的格式來完成其格式解析。

核心解析虛擬碼:

Go:

for {
    r, err := p.ParseRecord()
}   

func (p *HProfParser) ParseRecord() (interface{}, error) {
        if p.heapDumpFrameLeftBytes > 0 { // 處理 sub-record
                return p.ParseSubRecord()
        }
        tag, err := p.reader.ReadByte()
        if err != nil {
                return nil, err
        }
        recordHeader, _ := p.parseHeaderRecord()
        switch tag {
        case TagString:
                return p.parseUtf8String(recordHeader)
        ...
        default:
                return nil, fmt.Errorf("unknown record type: 0x%x", tag)
        }
}

func (p *HProfParser) ParseSubRecord() (interface{}, error) {
        tag, err := p.readByte()
        if err != nil {
                return nil, err
        }
        switch tag {
        case TagGcRootUnknown:
                return p.parseGcRootUnknown()
        ...
        default:
                return nil, fmt.Errorf("unknown heap dump record type: 0x%x", tag)
        }
}

上面程式碼完成對 Hprof 檔案的不停read bytes 並將其解析轉換成 結構化的 record。當我們能完成對其轉換成 record 記錄之後,面臨兩個問題:一個儲存問題,最簡單直接儲存在記憶體中,但這種方式依賴主機的實體記憶體,分析大記憶體dump 檔案會受限制,一個是格式問題,最簡單的是儲存 record 的 json 格式,這種方式閱讀性強,但弱點是資料量比較大,於是我們做了一下調研:

  • 1G heap dump 檔案預計有1300W 條 record 記錄。
  • 2G heap dump 檔案預計有2700W 條 record 記錄。
  • 3G heap dump 檔案預計有4000W 條 record 記錄。
  • 12G heap dump 檔案預計有1億5千萬 條 record 記錄。
  • 滿足 insert 要求只能選擇 LSM-tree 資料結構型別的 KV 資料庫,淘汰了圖資料庫。
  • 選用 儲存編碼後的二進位制資料比存入json 格式資料,在耗時和大小上均有1倍以上的提升。

綜合選擇了 LSM-tree 資料結構型別的 KV 資料庫leveldb 配合 proto3 進行二進位制編碼壓縮。進過分析產出報告存入後臺 mongo 。

3.2 Hprof 分析

當我們理解了 jvm 記憶體分佈,理解並完成了 hprof 檔案的解析、儲存。那麼剩下最後一個步完成對其分析,產出分析報告,這裡我們舉兩個例子:1、執行緒分析 2、 大物件分析。

下面我們以下面這段程式碼做成 jar 執行,然後 jmap 生成 heap.hprof 檔案進行分析。

Java:

# Main.Java
public class Main {
    
    public static void main(String[] args) {
        String[] aaa = new String[100000];
        for (int i = 0; i < 100000; i++) {
            aaa[i] = "aaa";
        }
        System.out.println("=============");
        try {
            Thread.sleep(300000);
        } catch (Exception ee) {
            ee.printStackTrace();
        }
    }
}
  • 執行緒資訊分析

我們本地資料庫最終得到的是大量的 record 記錄,那麼這些 record 之間的關聯關係,以及如何使用我們通過幾個例子初步瞭解一下。(jstack 能獲得更詳細的執行緒資訊,從 Heap dump 也能獲得執行緒資訊哦),首先我們通過常用的三個執行緒來感受一下 record 的關係。

main 執行緒:

Java:

Root Thread Object:
    object id: 33284953712
    thread serial num: 5
    stack trace serial num: 6

Instance Dump:
    object id: 33284953712
    stack trace serial num: 1
    class object id: 33285008752
    instance field values:
        threadLocalRandomSecondarySeed = 0
        threadLocalRandomProbe = 0
        threadLocalRandomSeed = 0
        uncaughtExceptionHandler = 0
        blockerLock = 33284954824
        blocker = 0
        parkBlocker = 0
        threadStatus = 225
        tid = 1
        nativeParkEventPointer = 0
        stackSize = 0
        inheritableThreadLocals = 0
        threadLocals = 33284954176
        inheritedAccessControlContext = 33284954136
        contextClassLoader = 33285041480
        group = 33284949288
        target = 0
        stillborn = false
        daemon = false
        single_step = false
        eetop = 140312336961536
        threadQ = 0
        priority = 5
        name = 33284954088
        
Instance Dump:
    object id: 33284954088
    stack trace serial num: 1
    class object id: 33284980264
    instance field values:
        hash = 0
        value = 33284954112
        
Primitive Array Dump:
    object id: 33284954112
    stack trace serial num: 1
    number of elements: 4
    element type: char
        element 1: m
        element 2: a
        element 3: i
        element 4: n

通過上面例子個跟蹤我們基本能獲得 雖然都是 record 但是其不同的型別代表不一樣的資訊,而將他們關聯的東西其實就是上面 JVM 執行時資料區裡面的描述對應。有 class --> object instance --> primitive Array 等等。這裡需要讀者理解 JVM Run-time Data Areas 以及 CLassFile 的資料結構,來完成 record 的關係。

虛擬碼:

Go:

func (j *Job) ParserHprofThread() error {
        err := j.index.ForEachRootThreadObj(func(thread *hprofdata.GcRootThreadObject) error {
                trace, _ := j.index.Trace(uint64(thread.StackTraceSequenceNumber))
                if len(trace.StackFrameIds) != 0 {

                        instance, _ := j.index.Instance(thread.ThreadObjectId)
                        threadName := j.index.ProcessInstance(instance)
                        stackTrace := ""
                        for _, frameId := range trace.StackFrameIds {
                                frame, _ := j.index.Frame(frameId)

                                method_name, _ := j.index.String(frame.MethodNameId)
                                source_file_name, _ := j.index.String(frame.SourceFileNameId)
                                loadclass, _ := j.index.LoadedClass(frame.ClassSerialNumber)
                                className, _ := j.index.String(loadclass.ClassNameId)
                                stackStr := ""
                                if frame.LineNumber > 0 {
                                        stackStr = fmt.Sprintf("  %s.%s(%s:%d) \n",
                                                className,
                                                method_name, source_file_name, frame.LineNumber)
                                } else {
                                        stackStr = fmt.Sprintf("  %s.%s(%s) \n",
                                                className,
                                                method_name, source_file_name)
                                }
                                stackTrace += stackStr
                        }

                        heapThread := &HeapThread{
                                Id:               thread.ThreadObjectId,
                                ThreadName:       threadName,
                                ThreadStackTrace: stackTrace,
                        }
                        j.heapDump.Threads = append(j.heapDump.Threads, heapThread)
                }
                return nil
        })

        if err != nil {
                return err
        }
        return nil
}

獲得效果圖:

3.3 大物件分析

大物件分析思路分別獲得 Instance、 PrimitiveArray 、ObjectArray 這三種物件資料進行 TOP N 排序。

虛擬碼:

Go:

func (a *Analysis) DoAnalysis(identifierSize uint32) ([]*DumpArray, uint64) {

        var allDumpVec []*DumpArray
        var totalDataSize uint64

        classesDumpVec, classTotalDataSize := a.analysisClassInstance(identifierSize)

        allDumpVec = append(allDumpVec,classesDumpVec...)
        totalDataSize = classTotalDataSize

        sort.Sort(DumpArrayWrapper{allDumpVec, func(p, q *DumpArray) bool {
                return q.TotalSize < p.TotalSize
        }})
        return allDumpVec, totalDataSize
}

func (a *Analysis) analysisClassInstance(identifierSize uint32) ([]*DumpArray, uint64) {
        classesInstanceCounters := make(map[uint64]*ClassInstanceCounter)
        _ = a.Index.ForEachInstance(func(instance *hprofdata.GcInstanceDump) error {
                size := instance.DataSize + identifierSize + 8
                counter := classesInstanceCounters[instance.ClassObjectId]
                if counter != nil {
                        counter.addInstance(instance.ObjectId, uint64(size))
                } else {
                        classesInstanceCounters[instance.ClassObjectId] = &ClassInstanceCounter{
                                arrayObjectIds:   []uint64{instance.ObjectId},
                                Type:             0,
                                numberOfInstance: 1,
                                maxSizeSeen:      uint64(size),
                                totalSize:        uint64(size),
                        }
                }
                return nil
        })

        var totalDataSize uint64
        var classesDumpVec []*DumpArray
        pq := queue_helper.New()
        for classId, counter := range classesInstanceCounters {
                totalDataSize += counter.totalSize
                className := a.getClassNameString(classId)

                dumpArray := &DumpArray{
                        Str:            className,
                        ArrayObjectIds: counter.arrayObjectIds,
                        Type:           counter.Type,
                        NumberOfSize:   counter.numberOfInstance,
                        MaxSizeSeen:    counter.maxSizeSeen,
                        TotalSize:      counter.totalSize,
                }
                pq.Insert(dumpArray,dumpArray.TotalSize)
                if pq.Len() > 10 {
                        pq.Pop()
                }
        }
        count := pq.Len()
        for i := 0; i < count; i++ {
                item,_ := pq.Pop()
                array := item.(*DumpArray)
                classesDumpVec = append(classesDumpVec,array)
        }
        sort.Sort(DumpArrayWrapper{classesDumpVec, func(p, q *DumpArray) bool {
                return q.TotalSize < p.TotalSize
        }})
        return classesDumpVec,totalDataSize
}

效果圖:

可以看見最大的物件就是 String 陣列,與我們原始碼寫的一致。

4.JVM分析平臺架構

通過上面我們完成了對一個 jvm heap dump 檔案的解析、儲存、分析。於是我們更近一步完成工具平臺化,支援線上分析、多JVM 同時分析、支援水平擴容、支援大記憶體dump 分析、線上開報告等等。

平臺架構圖:

(整體上也是微服務架構,前面閘道器後面跟各個模組,分析器單獨執行,這樣可以支援多個併發分析任務。)

使用流程圖:

(對應使用者來說我們提供了一鍵命令執行,這張圖介紹一鍵命令背後的邏輯關係。)

成品效果圖:

能看見各個分析任務的實時進度。

分析完成之後可檢視詳細分析報告。

5.總結與展望

本文主要介紹了得物技術保障團隊在 Java 記憶體線上分析方案設計和落地的過程中遇到的問題以及解決方案,解決了研發人員對任何環境JVM例項進行快速記憶體Dump 和線上檢視分析報告,免去一些列dump檔案製作、下載、安裝工具分析等等。

未來得物技術保障團隊計劃繼續開發Java 執行緒分析,Java GC log 分析等等。形成對一個JVM 例項從記憶體、執行緒、GC 情況全方位分析,提升排查Java 應用效能問題效率。

Reference:

《Java 虛擬機器規範(Java SE 8 版)》

《深入理解Java 虛擬機器》

http://www.taogenjia.com/2020/06/19/jvm-runtime-data-areas/

http://wu-sheng.github.io/me/articles/JVMInternals.html

http://wu-sheng.github.io/me/articles/JVMInternals-p2.html

*文/Bruce

關注得物技術,每週一三五晚18:30更新技術乾貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~