Skip to main content

Android 爛裝置想跑人臉辨識- GC 是唯一會關心你OOM 的好人

每個在 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 的一天。那我們來試試看這兩個不可能任務:

  1. kGcCauseForAlloc 變成 kGcCauseBackground
  2. 降低 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,但 kGcCauseForNativeAllocJava 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()
  • 確保 mallocfree 的完美對應(這是基本功!)
  • 控制 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 有啥能搞的