
前兩篇我們像泥水匠一樣,用 ZRAM 補漏洞,用 LMKD 築高牆,終於保證了人臉辨識主程式不會被系統外部的死神斬殺。
但工位上的真實殘酷在於:外面的人殺不進來,內部的人卻隨時能把船鑿沉。
當我們把雙相機(RGB 1080p + ToF 480p)串流全速接上人臉辨識演算法後,真機跑了不到十分鐘,畫面開始肉眼可見地卡頓、撕裂,原本順暢的 30fps 掉到個位數。抓出 Logcat 一看,螢幕被密密麻麻的黃字洗板:
Explicit concurrent mark sweep GC freed 104857(3MB) AllocSpace objects, 0(0B) LOS objects, 39% free, 54MB/90MB, paused 12.345ms total 45.678ms
平均每 200 毫秒觸發一次 GC,每次暫停(Pause)十幾毫秒。相機的一幀時間只有 33.3ms,GC 只要停頓個兩次,相機硬體緩衝區立刻溢出(Buffer Drop),整個畫面當場定格。
很多 Android 工程師天天抱怨 GC 停頓害死人。
今天我要說句大實話:GC 是整台機器裡唯一關心你會不會 OOM 的好人。要不是它在後面拼命幫你擦屁股,你的 App 早在開機第三秒就炸死了。
問題從來不是 GC,而是你在相機管線裡瘋狂 new 物件的愚蠢行為。
1. 兇案現場:每秒 60 次的 Bitmap 死亡狂歡
翻開當時團隊寫的相機預覽回呼代碼,我差點把剛喝的拿鐵噴在螢幕上:
// 犯罪現場:每秒執行 30 次的相機回呼
override fun onImageAvailable(reader: ImageReader) {
val image = reader.acquireLatestImage() ?: return
// 罪行一:每幀申請一個幾百 KB 的 byte 陣列
val nv21Bytes = YuvUtils.imageToNv21(image)
// 罪行二:每幀宣告一個全新的 Bitmap 容器(狂吃 6.2MB Java Heap)
val bitmap = BitmapFactory.decodeByteArray(nv21Bytes, 0, nv21Bytes.size)
// 罪行三:旋轉畫面又 new 一個新 Bitmap!
val rotatedBitmap = Bitmap.createBitmap(bitmap, 0, 0, w, h, matrix, true)
faceDetector.process(rotatedBitmap)
image.close()
}
我們來撥一下算盤:
1080p RGB 影像,轉成 ARGB_8888 的 Bitmap,單張佔用記憶體是 1920 * 1080 * 4 = 8.29 MB。
每秒跑 30 幀:
8.29 MB * 30 = 248.7 MB / 秒!
你的 Java 虛擬機堆記憶體上限(heapgrowthlimit)只有 192MB。你在一秒之內向系統索要 250MB 的新記憶體,ART 虛擬機為了不讓記憶體被撐爆,只能瘋狂發動 Concurrent Mark Sweep (CMS),把所有執行緒按在地上摩擦,逼大家停下來等它清理垃圾。
這不叫效能調校,這叫人為製造記憶體風暴。
2. 鐵碗哲學:Zero-Allocation(零記憶體分配)
在低階晶片的高頻影像管線裡,唯一的生存法則叫做 「鐵碗原則」:
開工前發你一個鐵碗,吃飯洗碗都在這個碗裡,絕不買免洗紙碗,用完就丟。
整套管線從相機接收、格式轉換到演算法推論,在執行期間(Runtime)記憶體分配必須精確為零(Zero-Allocation)!
1. 預先分配單一 Bitmap,用 copyPixelsFromBuffer 原地重寫
不要再呼叫 BitmapFactory.decode 或 Bitmap.createBitmap。在系統初始化時,只建立唯一的一個全域複用 Bitmap:
class ZeroAllocRenderer(val width: Int, val height: Int) {
// 全生命週期只有這一個 Bitmap,永遠不被 GC 回收
val reusableBitmap: Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
// 預先配置連續 Direct ByteBuffer,規避 JVM Heap
val directBuffer: ByteBuffer = ByteBuffer.allocateDirect(width * height * 4)
fun updatePixels(nativeRgbaPtr: Long) {
directBuffer.clear()
// 底層 JNI 直接透過 memcpy 灌入像素資料,零 JNI 跨邊界複製
NativeBridge.copyFrameToBuffer(nativeRgbaPtr, directBuffer, width * height * 4)
directBuffer.position(0)
// 原地替換像素,完全不產生任何 Java 臨時物件!
reusableBitmap.copyPixelsFromBuffer(directBuffer)
}
}
2. 物件池與 ByteBuffer 循環借還
演算法輸出的人臉邊界框(Rect)、特徵值陣列(float[]),全面使用靜態物件池(Object Pool):
// 借出與歸還機制:系統啟動時預先溫池(Pre-warm),確保執行期零分配
val faceBoxPool = Pools.SynchronizedPool<FaceResult>(10).apply {
for (i in 0 until 10) {
release(FaceResult()) // 預先注滿 10 個鐵碗
}
}
fun processFrame() {
// 從池中借出,保證命中既有實例
val result = faceBoxPool.acquire() ?: return // 極限保護:池滿即降級,絕不臨時 new
try {
algorithm.detect(result)
render(result)
} finally {
result.reset()
faceBoxPool.release(result) // 歸還池子,GC 連影子都看不到
}
}
3. 戰果:從風暴到死水
重構完成後,再次打開 Android Studio Profiler 觀察記憶體監控曲線:
- Java Heap 分配曲線:從原本鋸齒狀的劇烈心電圖(一秒震盪十幾次),變成了一條絕對筆直的平直死線。
- GC 觸發頻率:從原本每秒 5 次,降為連續運作 2 小時,0 次 GC 停頓。
- 畫面幀率:相機 1080p 串流穩定鎖死在 30.0 fps,影格渲染延遲從 45ms 驟降至 4.2ms。
不要怪 GC 卡頓,先看自己有沒有在相機高頻管線裡瘋狂 new 物件。
當你做到了執行期 Zero-Allocation,GC 就從你的世界裡徹底退場了。
下一篇,我們摸進相機與硬體的更深水區:三路高頻影像流零複製(Zero-Copy)。看看如何在相機 HAL3、GPU 紋理與 NDK 演算法之間,利用 Linux ION 實體指標直接穿透,徹底終結記憶體複製的頻寬浩劫。
感謝觀看,收工。

發佈留言