分析 Java 記憶體

Java 和 Kotlin 應用程式會透過垃圾收集堆積管理記憶體。當物件無法再存取時,垃圾收集器 (GC) 最終會回收其空間。如果不再需要的物件仍由「GC 根」保留,就會發生記憶體流失,導致物件無法回收。

核心概念

GC 根

垃圾收集器根是一種特殊物件,垃圾收集器一律會將其視為可存取。例如:

  • 有效執行緒 (以及目前正在執行的 Java 堆疊框架所參照的物件)。
  • 類別,其中包含正在執行的函式。
  • JNI 參照 (原生程式碼保留的全域或區域參照)。

GC 根路徑

只要有從 GC 根目錄到物件的參照鏈結,該物件就是「可存取」,無法進行垃圾收集。這個鏈結稱為「GC 根路徑」。如要修正記憶體流失問題,請找出並中斷這個鏈結。

GC 根路徑

主控樹狀結構

雖然 GC 根路徑會說明物件存留的原因,但不會說明如果中斷該參照,可回收多少記憶體。為此,我們使用主控樹狀結構。

如果從任何 GC 根目錄 到物件 B 的每個路徑都必須經過物件 A,則物件 A 會「控管」物件 B。如果 A 占據 B,則回收 A 也會確保 B 可以回收,因為從任何根目錄到 B 沒有其他路徑。

下圖顯示物件圖表和對應的支配者樹狀結構。請注意,在圖表中,物件 D 同時由 A 和 B 觸及,因此 A 和 B 都不是 D 的主導者,而是 GC 根是其最近的主導者。

支配樹

取得 Java 記憶體快照資料

記憶體快照資料是 Java 堆積中所有物件在特定時間點的快照。

使用 ADB

如要從執行中的程序擷取記憶體快照資料,您可以將套件名稱直接傳遞至 am dumpheap。如要執行這項指令,您必須使用 <profileable android:shell="true"/> 或 <debuggable> 建構應用程式。

# 1. Trigger the dump (the command takes a moment to complete):
adb shell am dumpheap -g -b png com.android.memorylab /data/local/tmp/heap.hprof

# 2. Pull the file to your development machine:
adb pull /data/local/tmp/heap.hprof .

使用 Perfetto

您也可以在 Perfetto 設定中啟用 android.java_hprof 資料來源,讓 Perfetto 擷取 Java 堆積傾印做為系統追蹤記錄的一部分。這有助於將堆積狀態與其他系統事件建立關聯。

如要使用 Perfetto 擷取 MemoryLab 應用程式的記憶體快照資料,可以使用下列指令:

# Create a temp file for the configuration
cat > /tmp/java_heap.pbtx <<EOF
data_sources: {
    config {
        name: "android.java_hprof"
        java_hprof_config {
            process_cmdline: "com.android.memorylab"
        }
    }
}
EOF

# Run trace command referencing the file
external/perfetto/tools/record_android_trace -o java_heap.perfetto-trace \
  -t 10s -c /tmp/java_heap.pbtx

請參閱:Perfetto 文件中的 Java 記憶體快照資料。

使用 AHAT 進行分析

建議使用 AHAT (Android Heap Analysis Tool) 在網頁瀏覽器中查看 .hprof 檔案。

開始 AHAT

如果路徑中已安裝 ahat,請使用下列指令啟動:

ahat heap.hprof

或執行獨立 JAR:

java -jar ahat.jar heap.hprof

然後在瀏覽器中開啟 http://localhost:7100。

如要瞭解如何取得或建構 AHAT,請參閱 AHAT 來源存放區。

主要分析工作流程

尋找漏水

在「分配」檢視畫面中,搜尋「活動」課程 (MainActivity)。

顯示執行個體的 AHAT 檢視畫面

按一下課程即可查看所有「執行個體」。

AHAT 檢視畫面,顯示 MainActivity 執行個體 點選 MainActivity 例項進行檢查。

AHAT 顯示執行個體詳細資料

在執行個體檢視畫面中,您可以找到「Sample Path from GC Root」(從 GC 根目錄的範例路徑),當中會顯示防止物件遭到垃圾收集的參照鏈結,以及「Object Size」(物件大小),當中會顯示這個特定執行個體保留的記憶體量。

從 GC 根目錄和物件大小取得 AHAT 樣本路徑

分析點陣圖

AHAT 支援檢視 android.graphics.Bitmap 物件,這類物件通常會耗用大量記憶體。點選 Bitmap 執行個體,即可查看內容的算繪預覽畫面。

AHAT 點陣圖預覽

活動洩漏頁面

AHAT 具有專門的檢視畫面,可識別外洩的活動,這是 Android 中最常見且影響最大的記憶體外洩問題之一。

  1. 動作:在 MemoryLab 中,輕觸「Leak an Activity」(洩漏活動)。這會啟動 LeakedActivity,而這個活動會刻意洩漏自身資訊。
  2. 傾印:擷取記憶體快照資料。
  3. 分析:按一下 AHAT 側欄中的「活動洩漏」。
  4. 驗證:AHAT 會將 com.android.memorylab.LeakedActivity 列為洩漏,因為其 mDestroyed 欄位為 true (表示活動生命週期已結束),但仍可從 GC 根目錄存取。

AHAT 活動洩漏頁面

比較記憶體快照資料

比較兩個堆積傾印檔是找出記憶體問題最有效的方法之一。比較「乾淨」的基準傾印與執行某些動作後擷取的傾印,即可立即查看累積了哪些物件。

練習:透過差異比較找出記憶體流失問題

  1. 基準:啟動 MemoryLab 並擷取基準記憶體快照資料:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/base.hprof
    adb pull /data/local/tmp/base.hprof .
    
  2. 動作:在應用程式中多次輕觸「Allocate Java Memory(10MB)」。

  3. 最後:擷取第二個記憶體快照資料:

    adb shell am dumpheap com.android.memorylab /data/local/tmp/leaked.hprof
    adb pull /data/local/tmp/leaked.hprof .
    
  4. 比較:以第二個傾印做為主要傾印,第一個傾印做為基準,啟動 AHAT:

    java -jar out/host/linux-x86/framework/ahat.jar leaked.hprof --baseline base.hprof
    
  5. 分析總覽:「總覽」頁面現在包含「Δ (Delta)」資料欄。您會看到 app 堆積的大量正增量,表示記憶體大幅成長。

AHAT 總覽 (含 Delta)

  1. 向下鑽取:按一下選單中的「已取得根存取權」。這個頁面會顯示可從 GC 根目錄存取的物件,並依保留大小排序。頂端會顯示 MainActivity,並有大幅正向的變化量。

AHAT Rooted View with Delta

記錄配置堆疊追蹤

雖然「Sample Path from GC Root」(從 GC 根目錄的範例路徑) 會說明物件仍存留的原因,但不會說明物件的建立方式。配置堆疊追蹤記錄會提供配置物件的確切程式碼行。

概念和取捨:記錄每個配置的堆疊追蹤需要大量運算資源,並會耗用大量記憶體。在大型正式版應用程式中,這可能會導致應用程式幾乎無法使用。不過,MemoryLab 應用程式夠小,因此我們可以安全地啟用這項追蹤功能,找出分配來源。

練習:找出位元組陣列的來源

  1. 從追蹤開始:強制停止 MemoryLab,然後使用 --track-allocation 標記重新啟動。增加預設堆疊深度,擷取更多脈絡。

    # Increase the allocation tracker's stack depth (requires a process restart)
    adb shell setprop dalvik.vm.allocTrackerMaxStack 16
    
    adb shell am force-stop com.android.memorylab
    adb shell am start --track-allocation -n com.android.memorylab/.MainActivity
    
  2. 動作:輕觸「Allocate Java Memory(10MB)」幾次。

  3. 傾印:擷取並提取記憶體快照資料。

  4. 分析:在 AHAT 中開啟傾印檔。前往大型 byte[] 執行個體。 (例如檢查 MainActivity → mJavaAllocations (ArrayList) → elementData (Object[]) → 陣列元素 [0])。

  5. 驗證:在執行個體檢視畫面中,查看「Allocation Site」(分配網站) 部分。 系統會顯示導致 MainActivity.allocateJava 的完整堆疊追蹤記錄。

AHAT 分配網站

找出重複字串和水合膨脹

即使應用程式沒有傳統的 GC 根逸漏,在 JSON、Protobuf、Cursor 或 Room 資料庫還原序列化期間建立的數千個重複 java.lang.String 執行個體,仍可能導致 Java 堆積膨脹。鍵、狀態字串、類別標籤或網址通常會在每個網路回應或資料庫查詢中重新分配。在大型動態消息、訊息和內容應用程式中,重複字串通常占用的即時String記憶體比例高達 30% 至 60%。

如要在 AHAT 中檢查重複字串,請按照下列步驟操作:

  1. 開啟「配置」頁面,然後依 java.lang.String 篩選。
  2. 使用 --baseline 比較兩個堆積傾印時,請檢查在動態饋給補水或載入本機快取後,java.lang.String 執行個體計數和總位元組是否不成比例地增加。
  3. 瀏覽 java.lang.String 執行個體表格 (依大小或值排序),找出多個記憶體內模型物件保留的相同字串值。
  • 解決方法:避免在任意使用者或網路輸入內容上隨意呼叫 String.intern(),因為執行階段內部資料表是全域的,可能會導致鎖定爭用或保留字串的時間超出必要範圍。請改用有界範圍的重複資料刪除快取 (例如剖析器或轉接程式中的 LruCache<String, String>),在還原序列化期間刪除高頻網域字串,或將固定值集表示為列舉或整數常數。

分析 Java 記憶體動態 (合併剖析)

如要全面瞭解應用程式的記憶體行為,您可以將記憶體計數器、執行緒活動和以呼叫堆疊為基礎的分配設定檔,合併為單一 Perfetto 追蹤記錄。這樣一來,您就能將全系統的記憶體指標 (例如 RSS 和堆積大小) 與特定程式碼執行和分配位置建立關聯。

我們將使用合併設定,啟用下列功能:

  • 記憶體計數器 (linux.process_stats):輪詢 RSS 和其他記憶體指標。
  • ATrace (dalvik、memory、sched 類別):擷取執行緒狀態和 GC 事件。
  • Heapprofd (android.heapprofd):每 5 秒持續傾印,同時鎖定 com.android.art (Java) 和 libc.malloc (原生) 堆積。

練習:合併記憶體分析

在本練習中,我們將執行 MemoryLab 應用程式,並執行一連串記憶體作業,觀察追蹤記錄中的不同模式:

  1. 基準:閒置狀態。
  2. Java 耗用:立即進行垃圾收集的暫時配置。
  3. 持續配置 Java 物件:配置會保留在記憶體中的 Java 物件。
  4. 點陣圖配置:配置大型圖像資產 (位於原生堆積/圖形記憶體中)。
  5. 回收:釋出所有已分配的資源。

1. 啟動及準備

  1. 強制停止並重新啟動應用程式,確保應用程式處於乾淨狀態:

    adb shell am force-stop com.android.memorylab
    adb shell am start -W -n com.android.memorylab/.MainActivity
    

2. 開始追蹤並觸發序列

我們會啟動 40 秒的追蹤記錄,並使用 am broadcast 指令觸發記憶體事件。

  1. 開始追蹤:

    adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/java_memory.perfetto-trace <<EOF
    buffers: {
        size_kb: 131072
        fill_policy: RING_BUFFER
    }
    data_sources: {
        config {
            name: "linux.process_stats"
            target_buffer: 0
            process_stats_config {
                scan_all_processes_on_start: true
                proc_stats_poll_ms: 100
            }
        }
    }
    data_sources: {
        config {
            name: "linux.ftrace"
            target_buffer: 0
            ftrace_config {
                ftrace_events: "sched/sched_switch"
                ftrace_events: "task/task_newtask"
                ftrace_events: "task/task_rename"
                ftrace_events: "ftrace/print"
                atrace_categories: "dalvik"
                atrace_categories: "am"
                atrace_categories: "res"
                atrace_categories: "memory"
                atrace_categories: "sched"
                atrace_apps: "com.android.memorylab"
            }
        }
    }
    data_sources: {
        config {
            name: "android.heapprofd"
            target_buffer: 0
            heapprofd_config {
                sampling_interval_bytes: 4096
                process_cmdline: "com.android.memorylab"
                heaps: "libc.malloc"
                heaps: "com.android.art"
                shmem_size_bytes: 8388608
                block_client: true
                continuous_dump_config {
                    dump_phase_ms: 1000
                    dump_interval_ms: 5000
                }
            }
        }
    }
    duration_ms: 40000
    EOF
    
  2. 觸發序列 (在追蹤作業執行期間,於主機終端機中執行下列指令,並遵守建議的時間):

    # Wait ~5s for trace initialization, then start Java churn:
    adb shell am broadcast -a com.android.memorylab.CHURN_JAVA
    
    # Wait ~10s (at 15s mark), allocate 10MB of persistent Java memory:
    adb shell am broadcast -a com.android.memorylab.ALLOC_JAVA
    
    # Wait ~5s (at 20s mark), allocate 20MB of Bitmaps (native/graphics):
    adb shell am broadcast -a com.android.memorylab.LEAK_BITMAP
    
    # Wait ~10s (at 30s mark), free everything:
    adb shell am broadcast -a com.android.memorylab.FREE_ALL
    
  3. 替代方法 (CLI 工具):您也可以直接使用 heap_profile 指令碼啟動設定檔,並透過連續傾印作業,同時鎖定 Java 和原生堆積:

    external/perfetto/tools/heap_profile -n com.android.memorylab \
      --heaps com.android.art,libc.malloc \
      -c 5000 \
      -d 40000 \
      -o java_memory_profile
    

3. 分析合併追蹤記錄

在 Perfetto UI 中開啟收集到的 java_memory.perfetto-trace。

Perfetto 中的重要軌跡

分析時間軸前,請先找出下列重要軌道,以利com.android.memorylab程序進行:

  1. mem.rss.anon (匿名 RSS):位於程序的「記憶體」部分下方。這個指標會測量 OS 分配給程序的實體記憶體 (RAM)。代表實際的記憶體用量。
  2. Heap size (KB):同樣位於「記憶體」部分下方。這是 Dalvik/ART 專屬的計數器,代表為 Java 堆積保留的虛擬位址空間。這反映了 VM 的內部堆積限制,會隨著物件分配和 GC 執行而波動。
  3. HeapTaskDaemon:位於程序下的執行緒清單中。這是背景執行緒,ART 垃圾收集器會在其中執行大部分工作。這裡的活動表示有效的 Google Cloud 認證。
  4. 連續分配傾印 (heapprofd):沿著頂端時間軸顯示為彩色切片。每個切片代表一段時間。按一下單一切片或選取時間範圍,即可檢查「com.android.art」(Java 分配) 或「libc.malloc」(原生分配) 的「火焰圖」 (位於底部窗格),瞭解該期間分配的內容。

依時間順序分析階段

讓我們依時間順序查看追蹤記錄,瞭解這些軌跡在運動的每個階段如何互動。

第 1 階段:基準 (0 秒 - 5 秒)
  • 現況:應用程式處於閒置狀態,等待指令。
  • 追蹤狀態:
    • mem.rss.anon:基準線的平坦線 (通常約為 60 到 80 MB,視裝置而定)。
    • Heap size (KB):平坦線,與初始 Java 堆積分配量相符。
    • HeapTaskDaemon:閒置 (未顯示執行作業的切片)。
    • 配置傾印:顯示最少的基準配置。

顯示第 1 階段基準的 Perfetto UI

第 2 階段:Java 分配流失 (5 秒 - 15 秒)
  • 目前狀況:系統啟動 AllocationChurnThread,並重複分配 1MB 陣列,然後捨棄這些陣列。
  • 追蹤狀態:
    • Heap size (KB):顯示快速鋸齒模式。隨著配置作業累積,堆積大小會增加,並在 GC 執行時大幅減少。
    • HeapTaskDaemon:顯示近乎恆定的活動,執行切片與 Heap size 鋸齒狀圖的下降趨勢完全一致。
    • mem.rss.anon:追蹤 Java 堆積活動。
    • Java 堆積配置傾印:選取這個軌跡中的切片,即可查看 com.android.art 堆積配置。

Perfetto UI 顯示第 2 階段的流失情形 分配樣本顯示 AllocationChurnThread 是主要分配器,所有分配項目都共用指向 MainActivity.java 內 lambda 的相同呼叫堆疊。

Perfetto UI 顯示第 2 階段的 Java 分配情形

第 3 階段:持續配置 Java (15 秒 - 20 秒)
  • 發生情況:我們配置了 10 MB 的 Java 物件,並在 mJavaAllocations 中保留這些物件的參照。
  • 追蹤狀態:
    • Heap size (KB):鋸齒狀的基準線會向上移動約 10 MB。
    • mem.rss.anon:大約增加 10 MB,因為 OS 必須以新的實體頁面備份這項持續性分配作業。
    • Java 堆積配置傾印:選取這個軌跡中的切片,即可查看 com.android.art 堆積配置。
    • 分配傾印 (火焰圖):檢查這個視窗中傾印的 com.android.art 堆積,會發現來自 MainActivity.allocateJava 的新分配路徑,導致保留大小。

Perfetto UI 顯示第 3 階段的持續性 Java 分配 選取涵蓋一段時間的分配樣本,這段時間與永久分配的 10 MB 增量重疊。您應該會看到分配呼叫堆疊分歧到兩個不同的網站,一個負責我們之前看到的短期分配流失,另一個則負責新的長期分配。

Perfetto UI 顯示第 3 階段的 Java 分配情形

階段 4:位元對應分配 (20 秒到 30 秒)
  • 發生什麼情況:我們也會配置 20 MB 的點陣圖。
  • 追蹤狀態:
    • Heap size (KB):與先前相同。
    • mem.rss.anon:顯示大約 20 MB 的顯著增幅,對應於點陣圖像素資料的原生分配。
    • 分配傾印 (火焰圖):這次請著重於 libc.malloc (原生) 堆積的切片。

Perfetto UI 顯示第 4 階段的點陣圖配置原生分配呼叫堆疊會顯示源自原生圖形程式庫的點陣圖分配。這是原生配置追蹤的絕佳用途,因為您不會在 Java 堆積中看到這些點陣圖配置。

Perfetto UI 顯示第 4 階段的原生分配情形

第 5 階段:回收 (30 秒 - 40 秒)
  • 發生情況:我們會觸發 FREE_ALL,清除所有持續性 Java 分配和點陣圖的參照,然後明確執行 System.gc()。
  • 追蹤狀態:
    • Heap size (KB):降回基準層級。
    • mem.rss.anon:下降,顯示 OS 回收實體頁面。
    • HeapTaskDaemon:在處理垃圾收集作業時,顯示最後一波活動。

Perfetto UI 顯示第 5 階段的回收作業

監控過往的 OOM (ApplicationExitInfo)

在 LMK 發生時擷取這類事件,非常適合用於主動偵錯,但如果是現場遙測,則可以使用 ApplicationExitInfo API。這樣一來,應用程式就能瞭解先前工作階段終止的原因。

ActivityManager am = getSystemService(ActivityManager.class);
List<ApplicationExitInfo> exitReasons = am.getHistoricalProcessExitReasons(null, 0, 1);
if (!exitReasons.isEmpty()) {
    ApplicationExitInfo info = exitReasons.get(0);
    if (info.getReason() == ApplicationExitInfo.REASON_LOW_MEMORY) {
        // App was killed by the system Low Memory Killer
    }
}

最佳做法

  1. 基準優先:請務必在應用程式初始化後,但在執行要測試的動作前,先取得「基準」記憶體快照資料。
  2. 使用 AHAT 的「Activity Leaks」頁面:AHAT 包含專用的「Activity Leaks」頁面,可自動找出已遭刪除但仍保留在記憶體中的 Activity 執行個體。這通常是找出常見漏水問題最快的方式。
  3. 檢查 GC 根路徑:針對任何洩漏的物件,使用 AHAT 中的「Path from Root」(根路徑) 檢視畫面,瞭解確切是哪個參照讓物件保持存留狀態 (例如靜態欄位、長時間執行的執行緒或已註冊的監聽器)。

← 工具 | ↑ 向上 | 點陣圖 →