您編寫的程式碼本身就是一種記憶體用量。應用程式執行時,必須將每個類別、方法和字串常數載入 RAM。應用程式的程式碼集越大,耗用的記憶體就越多。
檔案支援的記憶體和隨選分頁
Android 會使用 mmap 從 .apk (例如 .oat 或 .so 檔案) 載入可執行程式碼。這表示程式碼是檔案支援。
Android 採用隨選分頁機制,應用程式啟動時,核心不會立即將整個 APK 載入 RAM。而是將檔案對應至程序的虛擬位址空間。應用程式執行時,CPU 會跳至新函式,並觸發「頁面錯誤」。核心會暫停執行緒,從儲存空間讀取該特定 4 KB 的程式碼頁面到實體 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
- 前往 godbolt.org。
- 從語言下拉式選單 (左上角) 選取「Android Java」或「Android Kotlin」。
- 在編譯器下拉式選單 (程式碼窗格的右上方) 中,你可以選擇不同工具:
d8:顯示 Dalvik 位元組碼 (.dex)。這是最接近原始程式碼的表示法,且較容易解讀。r8:顯示 R8 最佳化工具如何縮減及最佳化位元碼。dex2oat:顯示實際在裝置上執行的最終 ARM64 機器碼。您可以在這裡查看實際的記憶體影響 (每項指令 4 個位元組)。dex2oat可以指定不同的 ISA,但 ARM64 是行動電話最常見的 ISA。
- 來源/輸出內容醒目顯示:將游標懸停在程式碼行上,即可醒目顯示對應的位元碼或機器碼指令,方便追蹤特定陳述式的影響。
- 最佳化管道:在反組譯檢視畫面中,您可以按一下「新增...」-> Opt Pipeline。這可讓您查看編譯器採取的內部步驟。您可以檢查 內部表示法 (IR) 在每個階段的轉換情形 (例如「Inliner (before)」和「Inliner (after)」步驟之間),然後再將其降級為最終的 ARM64 機器碼。

對記憶體的重要性
您在 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 中的位元組就會較少。
使用 meminfo 和 showmap 評估程式碼影響
您可以使用標準 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.dex、classes3.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 毫秒 |
應用程式載入效能未經最佳化

最佳化應用程式載入效能

程式碼膨脹的影響因應用程式大小、使用者裝置特性和系統負載而異。
使用 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.install 和 pm.dexopt.bg-dexopt 中設定,通常是在 build/make/target/product/runtime_libart.mk 中設定。
部分系統應用程式會使用 speed 編譯,也會在系統映像檔建構時編譯。verify 通常只用於開發用途。
| 用途 | 一般編譯器篩選器 |
|---|---|
| 開發 | verify |
| 系統映像檔 | speed |
| 使用者應用程式 | speed-profile |
實作練習:編譯模式和記憶體
我們可以透過 CodeBloat 應用程式查看這些篩選器對記憶體的影響。如要重現這些測量結果,請按照下列步驟操作:
- 強制將應用程式重新編譯為目標模式。
- 強制停止並冷啟動應用程式。
- 等待背景執行緒完成觸控類別 (觀察 logcat 或等待 5 秒)。
- 執行
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 的影響,您可以在裝置端產生自己的設定檔:
重設並開始:
adb shell am force-stop com.android.codebloat互動:啟動應用程式,讓應用程式執行啟動序列。
傾印設定檔:
adb shell kill -s SIGUSR1 $(pidof com.android.codebloat)(這會強制應用程式將目前的設定檔寫入磁碟)。
安裝設定檔:
adb shell cp /data/misc/profiles/cur/0/com.android.codebloat/primary.prof \ /data/misc/profiles/ref/com.android.codebloat/primary.prof編譯:
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)。
使用傾印旗標執行
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提取及檢視:將
.cfg檔案提取到工作站,然後使用 IR Hydra 開啟。找出 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 時,執行緒會觸發錯誤,而執行階段會擷取該錯誤,並用來將執行緒轉換為暫停狀態。
每個迴圈和每個方法的開頭都會重複這個模式,導致應用程式的程式碼總大小增加。
練習:在方法反組譯中找出所有隱含的暫停檢查,並嘗試將這些檢查與原始原始碼建立關聯。