本頁為最早的版本..方便之後整裡
每個在 Android 裝置上跑高負載應用的人,都跟 OOM(Out Of Memory)這位惡名昭彰的兄弟打過交道,不如說我根本每天照三餐處理這問題,從我第一年工作開始...尤其當你現在的設備是低階到不能再低階的 Qualcomm QM215 配上區區 2GB RAM。而在LMKD之前,你還有個方式避免那就是 GC
我先貼上一段,我最常看到的,也是我在這裝置上一直努力在處理的 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(or young) copying GC freed 138625(3293KB) AllocSpace objects, 3(14MB) LOS objects, 10% free, 67MB/75MB, paused 1.042ms total 410.415ms
先來看看怎麼解析這段LOG
I/art: <GC_Reason> <GC_Name> <Objects_freed>(<Size_freed>) AllocSpace Objects, <Large_objects_freed>(<Large_object_size_freed>) <Heap_stats> LOS objects, <Pause_time(s)>
GC_Reason 這個很重要我們往下挖一點點到 AOSP
// What caused the GC?
enum GcCause {
// Invalid GC cause used as a placeholder.
kGcCauseNone,
// GC triggered by a failed allocation. Thread doing allocation is blocked waiting for GC before retrying allocation.
kGcCauseForAlloc,
// A background GC trying to ensure there is free memory ahead of allocations.
kGcCauseBackground,
// An explicit System.gc() call.
kGcCauseExplicit,
// GC triggered for a native allocation when NativeAllocationGcWatermark is exceeded.
// (This may be a blocking GC depending on whether we run a non-concurrent collector).
kGcCauseForNativeAlloc,
// GC triggered for a collector transition.
kGcCauseCollectorTransition,
// Not a real GC cause, used when we disable moving GC (currently for GetPrimitiveArrayCritical).
kGcCauseDisableMovingGc,
// Not a real GC cause, used when we trim the heap.
kGcCauseTrim,
// Not a real GC cause, used to implement exclusion between GC and instrumentation.
kGcCauseInstrumentation,
// Not a real GC cause, used to add or remove app image spaces.
kGcCauseAddRemoveAppImageSpace,
// Not a real GC cause, used to implement exclusion between GC and debugger.
kGcCauseDebugger,
// GC triggered for background transition when both foreground and background collector are CMS.
kGcCauseHomogeneousSpaceCompact,
// Class linker cause, used to guard filling art methods with special values.
kGcCauseClassLinker,
// Not a real GC cause, used to implement exclusion between code cache metadata and GC.
kGcCauseJitCodeCache,
// Not a real GC cause, used to add or remove system-weak holders.
kGcCauseAddRemoveSystemWeakHolder,
// Not a real GC cause, used to prevent hprof running in the middle of GC.
kGcCauseHprof,
// Not a real GC cause, used to prevent GetObjectsAllocated running in the middle of GC.
kGcCauseGetObjectsAllocated,
// GC cause for the profile saver.
kGcCauseProfileSaver,
// GC cause for running an empty checkpoint.
kGcCauseRunEmptyCheckpoint,
};
我們先到我們 LOG中提到的第二個 GC 情況 『Background concurrent(or young) copying GC freed』 ,都是由於 HEAP 不夠,造成的 GC
- kGcCauseForAlloc -
- 觸發時機:
- 嘗試透過
new
分配一個新物件時,發現 堆空間不足,GC 被強制觸發來回收空間。
- 嘗試透過
- 特徵:
- 對應日誌關鍵詞:
GC for alloc
- 對應日誌關鍵詞:
- 造成應用程式執行緒阻塞(blocking),等待 GC 完成才能繼續。
- 通常比較「急」,代表系統內存吃緊。
- 常發生於突然分配大物件、或內存碎片過多導致無法取得連續空間時。
- 對使用者體感: 明顯卡頓。
- 觸發時機:
kGcCauseBackground
—- 背景回收(非同步GC)
- 觸發時機:
系統在「記憶體尚可接受的情況下」主動在背景執行 GC,進行 預防性回收。 - 特徵:
- 對應日誌關鍵詞:
GC concurrent background
或GC background
- 非同步執行,不會阻塞應用主執行緒。
- 有機會減少之後因為
for alloc
而造成的阻塞。 - 系統在判斷 JVM 壓力指標(如分配速率、空間使用率)達門檻時主動進行。
- 對應日誌關鍵詞:
- 對使用者體感: 幾乎無感,理想情況下是最「健康」的 GC 型態。
這邊我不打算針對基礎 GC 原理談,只針對我們會看到情況下手,由於我們是記憶體緊張, Camera stream 不段湧進的資料, AI model 不間斷的infer ,不可能會有停止GC的一天
我們先試試看底下兩個命題有沒有辦法達到
- kGcCauseForAlloc 想辦法變成
kGcCauseBackground
kGcCauseBackground
頻率降低
kGcCauseForAlloc 想辦法變成 kGcCauseBackground
這能嗎?說變就變啊?
- 避免瞬間分配大量或大物件,降低臨時記憶體壓力,讓系統能在背景 GC(
kGcCauseBackground
)就提前完成清理,不要等到需要blocking thread 的時候再來處理。除了程式碼上的優化之外,如果能改造 framework 的話 ,下面有個 Dalvik/ART VM Parameters 設定是可以增加 GC 頻率,讓背景 GC 提前回收空間,降低for alloc
機率。
- 更屌一點,首先 alloc 就是在說 JAVA 層中的 new 物件這樣行為去觸發 緊急記憶體回收,根據第一性原則,我們就不要在 JAVA 的 heap 中 new 物件啊,這麼簡單,千萬不要覺得我在講幹話,其實早期要突破 android app Heap 限制最佳的方法就是想辦法把會用到記憶體的全部放到 Native 曾進行操作.也就是所謂的牽線木偶,這個連 google 都用這招在 bitmap ,但這部分我們後續再談
多個情境說明,只要是跑 camera 資料處理,圖像處理 Opencv, AI model 的 Infer , tensorRt , TFlite ,一定是在 native 用 c++ 處理比較快,整理如下
- camera → YUV → JNI → model 推論 → JNI 回傳 → overlay 顯示
- Native 需求大於 Heap
- 前台記憶體需求大於 後台運行的APP,包含系統, Launcher 等等
- 減少 GC 次數與開銷-次要目標
下面是針對我們的爛裝置,提出的假說及驗證,寫這種文章真的夭壽麻煩
Dalvik/ART VM Parameters
dalvik.vm.heapgrowthlimit=160m # 這是正常 app 能使用的 heap 上限,調低他
dalvik.vm.heapsize=256m # 在 largeHeap=true 設定最大堆大小,不需要 512m,調低他
// 由於我們的 app 主要在 Native 層運行且不需要 Large Heap,降低此值可有效壓制其他啟用 android:largeHeap="true" 的應用程式的最大 Java 堆佔用。這能釋放更多系統記憶體,對整體系統資源分配有益。
// heapgrowthlimit 也可以適度下降,但要多考慮系統穩定性,可以參考 vendor 提供的 low memory 設置
dalvik.vm.heapmaxfree=16m # 建議提高
dalvik.vm.heapminfree=8m # 建議提高
dalvik.vm.heaptargetutilization=0.7 # 或 0.65,建議降低
//正常來說 我們的 heap 資源極度少,因此容易觸發 GC.而任何情況下的GC都會造成 cpu 資源佔用,所以需要考慮的次數應該是不要太平凡,勁量不出現像鋸齒狀的記憶體用量圖,因此適度調高可以降低回收次數,讓每次回收量更多是一個好方向,另外這裡多補充一下,只要是 CV 處理,肯定也伴隨大量 cpu 資源,所以其實 CPU 資源通常也在 70-80%使用率,
//這裡還需要多考慮 如果 heap 的使用緩衝提高之後,會不會擠壓了其他系統的空間,造成 zram 高速運作就為了增加緩衝空間.一些想不到的 ANR 情況
// 這裡筆者還是要談 『評估』的重要性,這也是為何這系列,第一章節重點在 評估第一,沒有最好,只有剛好
dalvik.vm.heapstartsize=8m //減少初始 heap,避免冷啟大 GC
//我們不需要考慮太多切換的情況.也不需要多快的啟動速度
寫到這邊,還是不斷提醒 只要是 framework 層面的修改,一定要多方評估,找出最適合的參數設置,這沒有捷徑,只有苦工.當然思路的精煉也是很重要
目前來說,應該是已經解決了這兩種 GC .kGcCauseForAlloc.kGcCauseBackground. 但因為我們上面提出的一個解法是『將 heap 的操作,通通放到 native 層』這確實解決了我們對 heap 的記憶體限制,達到前台可以使用全部記憶體的可能.可是這就造成了下一種 GC 產生的可能
kGcCauseForNativeAlloc
前述,將 OpenCV 放到 native 雖然能突破 java heap 限制,但這並不意外無限記憶體啊,而 C++更沒有 GC這種機制,所以在寫 JNI 相關的時候,反而要更警慎,對於記憶體的操作,務必遵守 malloc 跟 free 之間的對應。
等等,你剛剛說 C++ 沒有 GC,但是卻又提出 kGcCauseForNativeAlloc,泥搞得我好亂啊
事情是這樣的 C++真的沒有 GC ,這應該是資工系應該有的常識,kGcCauseForNativeAlloc
是 GC 沒錯,但它是為了幫 native 分配「搶救記憶體」而觸發的 Java GC,不是 native memory 本身有 GC。
那為什麼 Java GC 要來管 native 的事?
因為 某些 native 記憶體是跟 Java 對象綁在一起的(像 Bitmap, CameraMetadataNative, TFLite buffer wrapper):
ART 的假設:
「也許這個 native malloc 是因為 Java heap 裡還活著一些對應的 Java 對象?不然我幫你 GC 一下看能不能釋放一點 native heap!」
所以它會觸發
GC_FOR_NATIVE_ALLOC
,來試圖清理:
- bitmap pixel buffer
- native buffer 被 registry 綁定的 Java 對象
- 其他 JNI wrapper
JNI malloc(50MB)
↓
系統內存不足
↓
ART 嘗試 GC(kGcCauseForNativeAlloc)
↓
清理 Java heap
↓
若 Java heap 中有綁 native pointer 的對象(ex: NativeAllocationRegistry)
→ 調用 cleaner → free native memory
↓
malloc 成功 or 還是不夠就 crash
NativeAllocationRegistry
是什麼?
它是一個 Android 提供的工具類,目的是幫你把:
- Java 對象(如 Bitmap wrapper、TFLite wrapper)
- native 資源(如
malloc()
出來的 pointer)
這兩者綁在一起,當 Java 對象被 GC 時,系統會自動幫你釋放 native 資源。
NativeAllocationRegistry
會註冊一個 Java 對象 reference(可以是任何 Object),並附上一個 native free 函數(finalizer)與 native memory 的大小。
當這個 Java 對象被 GC 時,ART 會呼叫一個 Cleaner
機制,進而觸發你註冊的 native free 函數,釋放 native 記憶體。
大家看懂了上面了吧,看不懂留言發問,那接下來看看到底要怎麼樣降低 kGcCauseForNativeAlloc 發生頻率
- 避免每幀新建 Bitmap → 改為重用
- 每次 decode bitmap 後立即
recycle()
+= null
- CameraMetadataNative 用完立即
close()
- 自行 malloc 的 native buffer 要對應
free()
,避免長時間佔用 - 建立 buffer pool / image pool 重複使用 native 資源
- 控制 bitmap 大小(避免 decode 超過螢幕解析度)
- 使用
NativeAllocationRegistry.register()
告知 ART 有 native 記憶體 - 使用 direct
ByteBuffer
(ART 可追蹤其 native heap) - 不要讓 Java wrapper 長期活著(會延遲 cleaner 觸發)
寫到這邊,上面的叮囑根本就像是個老鳥在跟新來的RD尊尊叫毀,但這卻是很實際的,有關系統面的調整已經沒啥好再多談了,接下來要回到實際 APP 端能做的事情,怎麼寫出好的程式碼。尤其是在 JNI 的部分