
每個在 Android 裝置上跑高負載應用的人,都跟 OOM(Out Of Memory)這位惡名昭彰的兄弟打過交道。不如說我根本每天照三餐處理這問題,從我第一年工作開始…
尤其當你現在的設備是低階到不能再低階的 Qualcomm QM215 配上區區 2GB RAM。而在被 LMKD 無情擊殺之前,你還有個方式可以掙扎一下,那就是調教 GC。
GC Log 解析:
我先貼上一段我最常看到的,也是我在這破銅爛鐵上一直努力處理的 GC log:
com.kuke I/com.kuke: NativeAlloc concurrent copying GC freed 111541(2850KB) AllocSpace objects, 15(19MB) LOS objects, 10% free, 67MB/75MB, paused 337us total 396.170ms
com.kuke I/com.kuke: Background concurrent copying GC freed 138625(3293KB) AllocSpace objects, 3(14MB) LOS objects, 10% free, 67MB/75MB, paused 1.042ms total 410.415ms
怎麼解析這堆天書?
<GC_Reason> <GC_Name> <Objects_freed>(<Size_freed>) AllocSpace Objects, <Large_objects_freed>(<Large_object_size_freed>) LOS objects, <Heap_stats>, <Pause_time>
重點是 GC_Reason,這個很重要,我們挖一下 AOSP 看看到底發生什麼事:
Java
// What caused the GC? (從 AOSP 偷來的註解)
enum GcCause {
kGcCauseForAlloc, // 沒記憶體了,快救救我!
kGcCauseBackground, // 系統很貼心的預防性清理
kGcCauseForNativeAlloc, // Native 層哭著要記憶體
// ... 其他一堆你平常看不到的
};
兩大 GC 分析
1. kGcCauseForAlloc – 「急診室等級」的 GC
- 什麼時候出現:你想
new
一個物件,結果系統說:「沒錢了啦!」被迫觸發緊急 GC 來搶救記憶體。 - 有多慘:對應日誌關鍵詞
GC for alloc
。會阻塞你的執行緒,等 GC 完才能繼續。通常比較「急」,代表系統記憶體已經在加護病房了。 - 使用者體感:明顯卡頓,就是那種「欸?怎麼卡住了?」的感覺。
2. kGcCauseBackground – 「健檢等級」的 GC
- 什麼時候出現:系統很貼心地說:「我看記憶體有點亂,幫你整理一下」,進行預防性的背景清理。
- 有多爽:對應日誌關鍵詞
Background concurrent
。非同步執行,不會卡你的主執行緒。有機會減少之後那些要命的for alloc
。 - 使用者體感:幾乎無感,這是最「健康」的 GC 型態。
把急診變健檢
既然我們是記憶體緊張,Camera stream 不斷湧進資料,AI model 不間斷 inference,不可能有停止 GC 的一天。那我們來試試看這兩個不可能任務:
- 把
kGcCauseForAlloc
變成kGcCauseBackground
- 降低
kGcCauseBackground
頻率
廢話,但是重要
避免瞬間分配大量或大物件,降低臨時記憶體壓力,讓系統能在背景 GC 就提前完成清理,不要等到需要 blocking thread 的時候再來哭。
架構調整 – 「牽線木偶」戰術
更狠一點,根據第一性原則,我們就不要在 Java 的 heap 中 new
物件啊!
千萬不要覺得我在講幹話,其實早期要突破 Android app Heap 限制最佳的方法就是把會用到記憶體的操作全部放到 Native 層進行。也就是所謂的牽線木偶,連 Google 都用這招在 Bitmap
上。
只要是跑這些東西,一定是在 native 用 C++ 處理比較快:
- Camera 資料處理
- 圖像處理 OpenCV
- AI model 的 Inference (TensorRT, TFLite)
整個流程變成:
Camera → YUV → JNI → Model 推論 → JNI 回傳 → Overlay 顯示
優勢明顯:
- Native 記憶體需求 > Java Heap 需求
- 前台記憶體需求 > 後台運行的 APP
- 有效減少 GC 次數與開銷
Framework 調教指南
寫這種調教文章真的要人命,Framework 層面的修改一定要多方評估!
Dalvik/ART VM 參數調教
下面是針對我們的爛裝置,提出的假說及驗證:
# 這是正常 app 能使用的 heap 上限,調低他,去壓制其他app 的記憶體用量
dalvik.vm.heapgrowthlimit=160m
# 在 largeHeap=true 設定最大堆大小,不需要 512m,調低他
dalvik.vm.heapsize=256m
為什麼要壓制其他 app 的 heapsize?
由於我們的 app 主要在 Native 層運行且不需要 Large Heap,降低此值可有效壓制其他啟用 android:largeHeap="true"
的應用程式的最大 Java 堆佔用。這能釋放更多系統記憶體,對整體系統資源分配有益。heapgrowthlimit
也可以適度下降,但要多考慮系統穩定性,可以參考 vendor 提供的 low memory 設置。
# 建議提高
dalvik.vm.heapmaxfree=16m
# 建議提高
dalvik.vm.heapminfree=8m
# 或 0.65,建議降低
dalvik.vm.heaptargetutilization=0.7
這邊的權衡考量:
正常來說我們的 heap 資源極度少,因此容易觸發 GC。而任何情況下的 GC 都會造成 CPU 資源佔用,所以需要考慮的是次數不要太頻繁,盡量不出現像鋸齒狀的記憶體用量圖。因此適度調高可以降低回收次數,讓每次回收量更多是一個好方向。
但是!還有更多考量:
這裡多補充一下,只要是 CV 處理,肯定也伴隨大量 CPU 資源,所以其實 CPU 資源通常也在 70-80% 使用率。更麻煩的是: 如果 heap 的使用緩衝提高之後,會不會擠壓了其他系統的空間,造成 zRAM 高速運作就為了增加緩衝空間,結果出現一些想不到的 ANR 情況?
# 減少初始 heap,避免冷啟大 GC
dalvik.vm.heapstartsize=8m
冷啟動的考量:
我們不需要考慮太多 app 切換的情況,也不需要多快的啟動速度,所以可以從小開始。
評估: 只要是 framework 層面的修改,一定要多方評估,找出最適合的參數設置,這沒有捷徑,只有苦工。當然思路的精煉也是很重要!
第三天魔王:kGcCauseForNativeAlloc
寫到這裡,你可能會問:「欸,你剛說 C++ 沒有 GC,怎麼又冒出 kGcCauseForNativeAlloc
?搞得我好亂啊!」
事情是這樣的… C++ 確實沒有 GC,但 kGcCauseForNativeAlloc
是 Java GC 為了救 Native 而觸發的,不是 Native 本身有 GC。
為什麼 Java GC 要管 Native 的事?
因為某些 Native 記憶體是跟 Java 物件綁在一起的(像 Bitmap、CameraMetadataNative、TFLite buffer wrapper)。
ART 的邏輯是:「也許這個 native malloc 失敗是因為 Java heap 裡還活著一些對應的 Java 物件?不然我幫你 GC 一下看能不能釋放一點 native 記憶體!」
流程是這樣:
JNI malloc(50MB)
↓
系統記憶體不足,malloc 哭哭
↓
ART:「讓我試試 GC!」(kGcCauseForNativeAlloc)
↓
清理 Java heap 中綁 native pointer 的物件
↓
透過 NativeAllocationRegistry 調用 cleaner → free native memory
↓
malloc 再試一次:成功 or 還是不夠就 crash
NativeAllocationRegistry 是什麼鬼?
這是 Android 提供的「綁定神器」,讓你把:
- Java 物件(如 Bitmap wrapper、TFLite wrapper)
- Native 資源(如
malloc()
出來的 pointer)
綁在一起。當 Java 物件被 GC 時,系統會自動幫你釋放 Native 資源。簡單說就是: Java 物件死了,Native 記憶體也跟著解脫。
處理 kGcCauseForNativeAlloc 的基本功
1. 資源重用大法
C++
// ❌ 每幀都新建,
Bitmap* newBitmap = createBitmap();
// ✅ 重用 buffer pool,
Bitmap* reusedBitmap = bitmapPool.acquire();
- Decode bitmap 後立即
recycle()
+ 設為 null CameraMetadataNative
用完立即close()
- 確保
malloc
與free
的完美對應(這是基本功!) - 控制 bitmap 大小(不要 decode 超過螢幕解析度的怪物)
- 避免長時間佔用大型 native buffer
- 使用
NativeAllocationRegistry.register()
告知 ART 有 native 記憶體 - 使用 direct
ByteBuffer
(ART 可以追蹤) - 不要讓 Java wrapper 長期活著(會延遲 cleaner 觸發)
寫到這邊,上面的叮囑根本就像個老鳥在跟新來的 RD 諄諄教誨,但這卻是血淋淋的現實。在系統面調整已經沒什麼好談的情況下,接下來就是回到 APP 端寫出好程式碼的基本功,尤其是 JNI 的部分。
最終大魔王:評估永遠是重點
重要到要粗體的話:沒有最好的參數,只有最適合當下情境的配置。
優化是苦工,但思路的精煉同樣重要
https://blog.csdn.net/Androiddddd/article/details/109678554
https://juejin.cn/post/6891918738846105614
https://github.com/Timdk857/Android-Architect-Growth-Path-1
下一張應該看看 JNI 有啥能搞的