應用程式程式碼是記憶體

您編寫的程式碼本身就是一種記憶體用量。應用程式執行時,必須將每個類別、方法和字串常數載入 RAM。應用程式的程式碼集越大,耗用的記憶體就越多。

檔案支援的記憶體和隨選分頁

Android 會使用 mmap.apk (例如 .oat.so 檔案) 載入可執行程式碼。這表示程式碼是檔案支援

Android 採用隨選分頁機制,應用程式啟動時,核心不會立即將整個 APK 載入 RAM。而是將檔案對應至程序的虛擬位址空間。應用程式執行時,CPU 會跳至新函式,並觸發「頁面錯誤」。核心會暫停執行緒,從儲存空間讀取該特定 4 KB 的程式碼頁面到實體 RAM,然後繼續執行。

圖表:說明需求分頁,顯示虛擬頁面只會在存取時對應至實體 RAM 頁面

也就是說,您封裝但從未執行的程式碼,不會使用程式碼頁面的實體記憶體。不過,未使用的程式庫仍會增加整體 APK 大小,並大幅增加系統內部中繼資料 (例如 DEX 索引和類別描述元) 所用的記憶體,而這些資料必須讀取,才能得知程式碼是否存在。此外,許多程式庫都含有靜態初始設定式,或在應用程式啟動期間受到依附元件插入架構影響,因此無論如何都會分頁載入 RAM。

網頁逐出和速度變慢

由於檔案支援的記憶體一律可從儲存空間重新讀取,核心會將這些頁面視為「乾淨」。當系統記憶體不足時,核心會從 RAM 逐出 (捨棄) 這些乾淨的程式碼頁面,為其他項目騰出空間。

如果應用程式稍後需要再次執行該程式碼,CPU 會發生錯誤,核心必須從儲存空間重新讀取頁面。應用程式的程式碼越多,就越容易遭到程式碼逐出。使用者使用其他應用程式後返回臃腫的應用程式時,CPU 會不斷停滯,等待程式碼從儲存空間分頁調回,因此會隨機發生卡頓和速度變慢的情況。

頁面錯誤的成本:這項成本會因裝置的儲存空間速度 (UFS 與 eMMC) 和核心狀態而異,但主要頁面錯誤 (從儲存空間讀取 4 KB) 的成本可能介於 0.5 毫秒至 5 毫秒。如果啟動路徑觸及 500 個不同的未最佳化程式碼頁面,您可能會輕易地在應用程式啟動時間中,導入數百毫秒的純 I/O 延遲。

使用 Compiler Explorer 探索程式碼大小

如要瞭解 Java 或 Kotlin 程式碼如何轉換為原生機器碼 (以及記憶體位元組),可以使用 Compiler Explorer

Godbolt 直接內建 Android 支援功能。您可以藉此瞭解 Android 工具鍊 (D8、R8 和 dex2oat) 的不同部分如何轉換您的原始碼。

如何搭配 Android 使用 Compiler Explorer

  1. 前往 godbolt.org
  2. 從語言下拉式選單 (左上角) 選取「Android Java」或「Android Kotlin」。
  3. 在編譯器下拉式選單 (程式碼窗格的右上方) 中,你可以選擇不同工具:
    • d8:顯示 Dalvik 位元組碼 (.dex)。這是最接近原始程式碼的表示法,且較容易解讀。
    • r8:顯示 R8 最佳化工具如何縮減及最佳化位元碼。
    • dex2oat:顯示實際在裝置上執行的最終 ARM64 機器碼。您可以在這裡查看實際的記憶體影響 (每項指令 4 個位元組)。dex2oat 可以指定不同的 ISA,但 ARM64 是行動電話最常見的 ISA。
  4. 來源/輸出內容醒目顯示:將游標懸停在程式碼行上,即可醒目顯示對應的位元碼或機器碼指令,方便追蹤特定陳述式的影響。
  5. 最佳化管道:在反組譯檢視畫面中,您可以按一下「新增...」-> Opt Pipeline。這可讓您查看編譯器採取的內部步驟。您可以檢查 內部表示法 (IR) 在每個階段的轉換情形 (例如「Inliner (before)」和「Inliner (after)」步驟之間),然後再將其降級為最終的 ARM64 機器碼。

螢幕截圖:Compiler Explorer 使用者介面,顯示範例程式及其 dex2oat 輸出反組譯和最佳化管道,並顯示內嵌步驟

對記憶體的重要性

您在 dex2oat 輸出內容中看到的每項指令,都會以 ARM64 ISA 為目標,並在應用程式的可執行檔 (.odex.oat) 中佔用 4 個位元組

請嘗試輸入使用不同語言功能的程式碼,並研究編譯器的輸出內容:

  • 陣列存取與清單迭代器
    • int[] 進行簡單的陣列迴圈,可能會編譯為約 10 個指令 (約 40 個位元組)。
    • List 執行 foreach 迴圈時,會隱含使用 Iterator。由於額外的方法呼叫 (hasNext()next()) 和迭代器物件本身的配置,這可能會導致 30 到 40 個指令 (約 160 個位元組)。
    • R8 最佳化:在適當條件下 (例如 List 證明為 ArrayList 時),R8 最佳化工具可將 foreach 迴圈轉換回簡單的索引迴圈,消除迭代器負擔,並縮減程式碼大小和執行階段記憶體抖動。
  • 虛擬方法呼叫:包括載入物件的類別、在 vtable 中尋找方法,然後分支。這通常需要 4 到 5 條指令 (約 20 個位元組)。
  • 直接/靜態呼叫:通常會轉譯為單一 bl (含連結的分支) 指令 (4 個位元組)。
  • Kotlin Lambda:可產生完整的匿名類別和額外的橋接方法,為簡單的功能區塊新增數百位元組的程式碼和中繼資料負擔。

使用 Compiler Explorer,您可以瞭解複雜的語言功能 (例如 Kotlin Lambda、串流 API 或大量使用泛型) 對應用程式最終編譯大小的影響,以及 R8 等最佳化工具在某些情況下如何抵銷語言抽象化的成本。這項工具可協助您在設計及實作應用程式時,做出明智的取捨。

一般來說,應用程式程式碼越複雜,記憶體用量就越高。反之,如果程式碼較簡單,或是經過 R8 簡化,CPU 指令和儲存空間/RAM 中的位元組就會較少。

使用 meminfoshowmap 評估程式碼影響

您可以使用標準 Android 記憶體工具,查看應用程式程式碼的記憶體用量。

dumpsys meminfo

執行 adb shell dumpsys meminfo <package> 時,「應用程式摘要」部分的「程式碼」類別會提供程式碼相關記憶體的高階檢視畫面:

 App Summary
                       Pss(KB)
                        ------
           Java Heap:     3244
         Native Heap:     5412
                Code:    24512  # <--- Sum of .so, .dex, .oat, .art, etc.

showmap

如要查看更精細的資料,請使用 showmap。這會顯示對應至記憶體的特定檔案以外的區域。

adb shell showmap $(pidof <package>) | grep -E "\.oat|\.odex|\.dex|\.apk"

您會看到應用程式已編譯程式碼的項目:

   size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS object
------- -------- -------- -------- -------- -------- -------- -------- -------- ----------------
  12288     8192     8192     8192        0        0        0        0        0 /data/app/.../base.odex

無效程式碼和 R8

由於每個執行的方法都會佔用記憶體,如果應用程式「過於肥大」,含有不必要的初始化作業或未使用的程式庫,可能會嚴重影響啟動效能和基準記憶體用量。

因此,R8 (ProGuard) 等工具至關重要。R8 會分析應用程式的位元碼,並移除從未呼叫的類別或方法 (「剝除無效程式碼」)。

實作練習:膨脹的成本

為展示程式碼大小的影響,請考慮一項實驗,比較含有 300 個產生類別 (每個類別有 500 個方法) 的兩個應用程式建構版本:

  • CodeBloat (未最佳化):標準的未最佳化建構版本,內含所有產生的類別和不重複字串。
  • CodeBloatOptimized:原始碼相同,但編譯時已啟用 R8 縮減功能。

1. 預先 (AOT) 編譯

為充分發揮檔案支援記憶體的影響,我們會使用 cmd package compile 工具,將應用程式提前 (AOT) 編譯為 .oat 檔案。

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell cmd package compile -m speed -f com.android.codebloat.optimized

請注意,這只是合成範例,一般來說,應用程式會使用 speed-profile 編譯模式 (詳見下文)。

2. 推出及比較

如要查看真正的冷啟動 (系統必須從儲存空間讀取程式碼),我們會在啟動每個應用程式前捨棄核心的頁面快取。這需要根存取權。

啟動未經過最佳化的應用程式:

adb shell am force-stop com.android.codebloat
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5 # Wait for the background thread to load classes
adb shell dumpsys meminfo -s com.android.codebloat

現在對最佳化應用程式執行相同操作:

adb shell am force-stop com.android.codebloat.optimized
# Drop page cache to ensure the start is truly cold
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell am start -W -n com.android.codebloat.optimized/com.android.codebloat.MainActivity
sleep 5
adb shell dumpsys meminfo -s com.android.codebloat.optimized
成果

如果您查看 App Summary 部分的「程式碼」列,會發現有巨大差異:

  • 未經過最佳化 Code:約 30,000 KB (30 MB)
  • 最佳化 Code:約 2,000 KB (2 MB)

由於 R8 判定這些類別中的 500 個方法實際上從未執行任何有用的動作 (doSomething() 方法只會呼叫 method0(),且結果會遭到忽略),因此 R8 從最終 APK 中去除了幾乎所有人工產生的程式碼。

3. 在 Perfetto 中查看影響

在應用程式的初始載入階段,程式碼膨脹的影響顯而易見。具體來說,請在主執行緒上尋找 bindApplication 切片,以及以 madvising 開頭的巢狀切片,這些切片表示系統準備從 APK 及其編譯的程式碼 (.odex) 載入檔案。

在互動式冷啟動時,系統會從這些檔案中 mmap()madvise() 程式碼,以及應用程式載入及執行所需的其他資料。madvising 切片中「size=」後的值表示需要載入的資料量。預先擷取應用程式的程式碼,可加快應用程式啟動速度。

從比較結果可以看出,在應用程式膨脹的案例中,需要從儲存空間載入至 RAM 的應用程式程式碼量大得多,導致時間較長,進而造成應用程式啟動速度變慢。此外,臃腫應用程式啟動的追蹤記錄會顯示載入次要 DEX 檔案 (classes2.dexclasses3.dex) 的切片,這是因為臃腫應用程式無法容納在一個 DEX 檔案中,因此被迫「溢出」。

比較 (Pixel 10a 冷啟動)
指標 未經最佳化 (程式碼膨脹) 最佳化 (CodeBloatOptimized)
base.odex madvise size ~7.9 MB (2.0 毫秒) ~16 KB (0.003 毫秒)
base.apk madvise size ~2.4 MB (2.4 毫秒) 約 4 KB (0.001 毫秒)
classes2.dex madvise size ~7.3 MB (8.6 毫秒) 不適用
classes3.dex madvise size ~7.3 MB (8.0 毫秒) 不適用
madvising時間 ~21 毫秒 ~0.004 毫秒
應用程式載入效能未經最佳化

Perfetto 使用者介面螢幕截圖,顯示 com.android.codebloat 程序,以及主要和次要 DEX 檔案的 madvising 切片

最佳化應用程式載入效能

Perfetto UI 螢幕截圖,顯示 com.android.codebloat.optimized 程序,其中包含單一微小的 madvising 切片

程式碼膨脹的影響因應用程式大小、使用者裝置特性和系統負載而異。

使用 PerfettoSQL 進行載入分析

您可以使用下列查詢,從追蹤記錄中擷取這些指標。

1. 應用程式啟動時間長度

這項指標顯示從啟動應用程式的活動,到活動繪製第一個影格所需的時間。

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT package, dur, startup_type
FROM android_startups
WHERE package LIKE 'com.android.codebloat%';

請參閱: 瞭解不同的應用程式啟動狀態

除了本指南涵蓋的因素外,應用程式的啟動時間還會受到許多其他因素影響!

2. 擷取 madvising 大小和時間長度

這項查詢會放大我們在上方看到的 madvising 部分。

INCLUDE PERFETTO MODULE slices.with_context;

SELECT
  name,
  dur/1e6 AS dur_ms
FROM thread_slice
WHERE process_name LIKE 'com.android.codebloat%'
  AND name LIKE 'madvising %';
3. 主執行緒狀態細目 (各狀態的總持續時間)

這項查詢會顯示應用程式主執行緒在不同狀態下所花費的時間。

SELECT
  p.name AS process_name,
  state,
  sum(dur)/1e6 AS total_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
GROUP BY p.name, state;

您可以調整查詢,只查看應用程式啟動期間的主執行緒狀態。

INCLUDE PERFETTO MODULE android.startup.startups;

SELECT
  p.name AS process_name,
  ts.state,
  -- Calculate only the duration that falls within the startup window
  SUM(
    MAX(0,
      MIN(ts.ts + ts.dur, s.ts + s.dur) - MAX(ts.ts, s.ts)
    )
  ) / 1e6 AS startup_dur_ms
FROM thread_state ts
JOIN thread t USING (utid)
JOIN process p USING (upid)
-- Join on the package name to align thread states with the correct startup
JOIN android_startups s ON s.package = p.name
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.is_main_thread = 1
  -- Only select thread states that overlap with the startup interval
  AND ts.ts + ts.dur > s.ts
  AND ts.ts < s.ts + s.dur
GROUP BY 1, 2
ORDER BY startup_dur_ms DESC;

這可能會揭露一些有趣的問題,例如:

  • Runnable (R) 狀態的時間長度較長,但並未處於 Running 狀態:這表示應用程式啟動遭到 CPU 爭用延遲,也就是說,應用程式的主執行緒無法執行,因為其他執行緒 (可能來自其他應用程式) 佔用 CPU。
  • 可中斷睡眠狀態 (D) 的時間過長:這通常表示 I/O 速度緩慢或記憶體壓力過大,導致應用程式啟動停滯。
  • 睡眠 (S) 狀態時間過長:這表示主執行緒正在等待其他執行緒執行工作。有時這表示應用程式啟動路徑中發生鎖定爭用 (也就是說,主執行緒遭到封鎖,無法使用應用程式中其他執行緒占用的專屬資源)。
4. 檔案支援記憶體上限 (RSS 檔案)

這個指標與應用程式在啟動時載入的程式碼和資料量有密切關聯。如果應用程式「膨脹」程度較高,這裡的數字就會較大,導致系統記憶體壓力增加。這類壓力可能會導致應用程式啟動延遲,因為系統難以滿足分配要求,或是將 CPU 時間從專注於啟動應用程式,轉向從其他程序回收記憶體,以滿足啟動應用程式的即時需求。

SELECT
  p.name AS process_name,
  max(c.value)/1024.0/1024.0 AS max_rss_file_mb
FROM counter c
JOIN process_counter_track t ON c.track_id = t.id
JOIN process p USING (upid)
WHERE p.name LIKE 'com.android.codebloat%'
  AND t.name = 'mem.rss.file'
GROUP BY p.name;

ART 編譯模式和記憶體

Android 執行階段 (ART) 可以透過多種模式編譯應用程式程式碼,這些模式也稱為編譯器篩選器。選取的編譯器篩選器會直接影響應用程式的記憶體用量。

  • verify:ART 只會執行位元碼驗證。不會執行 AOT 編譯。程式碼會透過「解譯器」執行,或在執行階段由「JIT」編譯器編譯。
    • 記憶體影響:磁碟大小最小。原生程式碼記憶體用量會推送至 JIT Cache (匿名髒記憶體)。
  • speed:ART 會對所有方法執行完整的 AOT 編譯。
    • 記憶體影響:最大 .odex 大小。盡量使用檔案支援 (乾淨) 的記憶體
  • speed-profile:ART 只會編譯在 JIT 設定檔中標示為「熱門」的方法。
    • 記憶體影響:平衡做法。系統只會 AOT 編譯最關鍵的程式碼。

最常見的篩選器是 speed-profile,用於安裝使用者應用程式。這是在系統屬性 pm.dexopt.installpm.dexopt.bg-dexopt 中設定,通常是在 build/make/target/product/runtime_libart.mk 中設定。

部分系統應用程式會使用 speed 編譯,也會在系統映像檔建構時編譯。verify 通常只用於開發用途。

用途 一般編譯器篩選器
開發 verify
系統映像檔 speed
使用者應用程式 speed-profile

實作練習:編譯模式和記憶體

我們可以透過 CodeBloat 應用程式查看這些篩選器對記憶體的影響。如要重現這些測量結果,請按照下列步驟操作:

  1. 強制將應用程式重新編譯為目標模式。
  2. 強制停止並冷啟動應用程式。
  3. 等待背景執行緒完成觸控類別 (觀察 logcat 或等待 5 秒)。
  4. 執行 adb shell dumpsys meminfo com.android.codebloat

模式:verify (無 AOT)

adb shell cmd package compile -m verify -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

verify 模式下,應用程式摘要會顯示:* 程式碼 PSS:約 8,000 KB * Dalvik 其他 (JIT):約 25,000 KB

由於沒有任何程式碼經過 AOT 編譯,執行階段必須將熱門方法 JIT 編譯到 JIT 快取中,這會顯示為不乾淨的匿名記憶體 (Dalvik Other)。

模式:speed (完整 AOT)

adb shell cmd package compile -m speed -f com.android.codebloat
adb shell am force-stop com.android.codebloat
adb shell am start -W -n com.android.codebloat/.MainActivity
sleep 5
adb shell dumpsys meminfo com.android.codebloat

speed 模式下,結果會大幅變動:* 程式碼 PSS:約 24,000 KB * Dalvik Other (JIT):約 5,000 KB

應用程式的程式碼現在會從 .odex 檔案對應為乾淨的檔案支援記憶體。這可減輕 JIT 快取壓力,並讓記憶體在壓力下可供清除,而非「卡住」成為髒 RAM。

模式:speed-profile (選擇性 AOT)

新式應用程式可能會組合 baseline.prof 基準設定檔。ART 會根據這項資訊,只編譯啟動時所需的程式碼,以加快啟動速度並節省記憶體。

在本練習中,我們將建立基準設定檔,列出應用程式的啟動類別。但實際上,編譯器也可能會收到來自外部來源 (例如應用程式商店) 的設定檔 (「雲端設定檔」),無論開發人員是否也一併提供他們產生的基準設定檔,這些設定檔都能為應用程式提供眾包 JIT 設定檔。

產生及使用裝置端設定檔

如要查看 speed-profile 的影響,您可以在裝置端產生自己的設定檔:

  1. 重設並開始

    adb shell am force-stop com.android.codebloat
    
  2. 互動:啟動應用程式,讓應用程式執行啟動序列。

  3. 傾印設定檔

    adb shell kill -s SIGUSR1 $(pidof com.android.codebloat)
    

    (這會強制應用程式將目前的設定檔寫入磁碟)。

  4. 安裝設定檔

    adb shell cp /data/misc/profiles/cur/0/com.android.codebloat/primary.prof \
    /data/misc/profiles/ref/com.android.codebloat/primary.prof
    
  5. 編譯

    adb shell cmd package compile -m speed-profile -f com.android.codebloat
    

再次啟動時,您會看到餘額:「程式碼 PSS」會低於 speed (例如約 16,000 KB),因為只有「熱」啟動方法經過編譯,其餘方法則只會在實際使用時,由解譯器或 JIT 處理。

請參閱:

深入探索已編譯的程式碼

如要查看 ART 生成的確切指令,請參閱 art/DISASSEMBLY_GUIDE.md

這份指南詳細說明如何使用:

  • oatdump:在現有 .odex 檔案中查看 ARM64 指令。
  • dex2oat:使用詳細的偵錯標記模擬編譯。

練習:程式碼內嵌

編譯後的程式碼可能意外變大的原因之一是「方法內嵌」。編譯器可能會決定將經常呼叫的小型方法主體,直接複製到呼叫端。

CodeBloat 應用程式中,每個產生的類別中的 doSomething() 方法都會呼叫 method0()。在 speed 模式下編譯時,ART 的最佳化編譯器可能會將 method0() 內嵌至 doSomething()

運動:在裝置上使用 oatdump 驗證:

# 1. Find the path to the application's APK and compiled .odex file
adb shell pm path com.android.codebloat
# Output: package:/data/app/~~.../base.apk

adb shell "dumpsys package com.android.codebloat | grep 'location is' | head -n 1"
# Example output: [location is /data/app/~~.../oat/arm64/base.odex]

# 2. Run oatdump (substituting the correct path to base.odex)
adb shell oatdump --oat-file=/data/app/~~.../oat/arm64/base.odex \
                  --class-filter=com.android.codebloat.GeneratedClass0

在輸出內容中尋找 doSomething 方法。如果已內嵌,您會看到在 doSomething 中直接載入長字串常數的操作說明,而不是以 method0 為目標的 bl 指令。

將最佳化 (CFG) 視覺化

如要查看編譯器決定內嵌方法的時間,可以產生控制流程圖 (CFG)。這會顯示最佳化管道每個階段的程式碼狀態,以及編譯器中介表示法 (IR) 的每次轉換,直到程式碼降級至目標 ISA (例如 ARM64)。

  1. 使用傾印旗標執行 dex2oat:使用 --verbose-methods 旗標將輸出內容限制為特定方法;否則,大型應用程式的 .cfg 檔案可能會成長至數 GB。

    # Substitution of actual paths required:
    adb shell dex2oat64 --dex-file=/data/app/~~.../base.apk \
                        --oat-file=/data/local/tmp/dump.odex \
                        --compiler-filter=speed \
                        --dump-cfg=/data/local/tmp/codebloat.cfg \
                        --verbose-methods=doSomething
    
  2. 提取及檢視:將 .cfg 檔案提取到工作站,然後使用 IR Hydra 開啟。

  3. 找出 Inliner:在 IR Hydra 中載入編譯構件,然後搜尋 doSomething。比較 Inliner 傳遞前後的表示法。您會看到圖表擴大,因為來自 method0的指令會合併到呼叫端。

或者,您也可以使用 Compiler Explorer 中的「Opt Pipeline」工具 (如上節所述),輸入類似的程式碼,查看「Inliner」傳遞中執行的類似轉換。

練習:不穩定欄位和記憶體屏障

MemoryLab 應用程式中,mGarbageSink 欄位會標示為 volatile。這可確保編譯器不會將垃圾配置最佳化。

public volatile byte[] mGarbageSink;

在 ARM64 反組譯中,您會看到這個欄位的每個儲存作業都伴隨著記憶體屏障 (dmb ish),或使用載入取得/儲存發布指令 (ldar/stlr)。這可確保執行緒可見度,但每次存取都會增加幾個額外指令,與一般欄位相比,程式碼大小會稍微增加。

練習:在反組譯中找出欄位存取和相關聯的記憶體屏障。

練習:隱含暫停檢查

如果您拆解迴圈 (例如 generateAllocationChurn 中的迴圈),會發現迴圈主體結尾有一項奇怪的指令:

ldr x21, [x21]

這是隱含暫停檢查。ART 會使用這項資訊,讓垃圾收集器安全地暫停執行緒。暫存器 x21 通常會指向自身。 當 GC 需要暫停執行緒時,會「毒害」該記憶體位置。下次執行該 ldr 時,執行緒會觸發錯誤,而執行階段會擷取該錯誤,並用來將執行緒轉換為暫停狀態。

每個迴圈和每個方法的開頭都會重複這個模式,導致應用程式的程式碼總大小增加。

練習:在方法反組譯中找出所有隱含的暫停檢查,並嘗試將這些檢查與原始原始碼建立關聯。


← WebView | ↑ 向上 | Threads →