ART視角 | 如何在native內存增長過多時自動觸發GC?如何在Java對象回收時觸發native內存回收?

本文分析基於Android R(11)

前言

GC用於Java堆內存的回收,這是人盡皆知的事實。然而現在有些Java類被設計成牽線木偶,Java對象只存儲一些“線”,其真實的內存消耗全都放到了native內存中。譬如Bitmap。對它們而言,如何自動回收操縱的native內存成爲一個亟須解決的問題。

想要自動回收,必須依賴GC機制。但僅僅依靠現有的GC機制還不夠。我們還需要考慮以下兩點:

  1. 如何在native內存增長過多的時候 自動 觸發GC
  2. 如何在GC回收Java對象時 同步回收 native資源

Android從N開始引入了NativeAllocationRegistry類。早期的版本可以保證在GC回收Java對象時同步回收native資源(上述第2點),其內部用到的正是上一篇博客介紹過的Cleaner機制

利用早期版本的NativeAllocationRegistry,native資源雖然可以回收,但仍然有些缺陷。譬如被設計成牽線木偶的Java類所佔空間很小,但其間接引用的native資源佔用很大。因此就會導致Java堆的增長很慢,而native堆的增長很快。在某些場景下,Java堆的增長還沒有達到下一次GC觸發的水位,而native堆中的垃圾已經堆積成山。由程序主動調用System.gc()當然可以緩解這個問題,但開發者如何控制這個頻率?頻繁的話就會降低運行性能,稀疏的話就會導致native垃圾無法及時釋放。因此新版本的NativeAllocationRegistry連同GC一起做了調整,使得進程在native內存增長過多的時候可以自動觸發GC,也即上述的第1點。相當於以前的GC觸發只考慮Java堆的使用大小,現在連同native堆一起考慮進去了。

native垃圾堆積成山的問題會導致一些嚴重的問題,譬如最近國內很多32位APK上碰到過的native內存OOM問題,其中字節跳動專門發過博客介紹他們的解決方案。在鏈接的博客裏,字節跳動團隊提供了應用層的解決方案,由應用層來主動釋放native資源。但這個問題的根本解決還得依賴底層設計的修改。看完字節跳動的博客後,我專門聯繫過Android團隊,建議他們在CameraMetadataNative類中使用NativeAllocationRegistry。他們很快接受了這個提議,並提供了新的實現。相信字節跳動遇到的這個問題在S上將不會存在。

目錄

1. 如何在native內存增長過多時自動觸發GC

當Java類被設計成牽線木偶時,其native內存的分配通常有兩種方式。一種是malloc(new的內部通常也是調用malloc)分配堆內存,另一種是mmap分配匿名頁。二者最大的區別是malloc通常用於小內存分配,而mmap通常用於大內存分配。

當我們使用NativeAllocationRegistry爲該Java對象自動釋放native內存時,首先需要調用registerNativeAllocation,一方面告知GC本次native分配的資源大小,另一方面檢測是否達到GC的觸發條件。根據內存分配方式的不同,處理方式也不太一樣。

libcore/luni/src/main/java/libcore/util/NativeAllocationRegistry.java

     // Inform the garbage collector of the allocation. We do this differently for
     // malloc-based allocations.
     private static void registerNativeAllocation(long size) {
         VMRuntime runtime = VMRuntime.getRuntime();
         if ((size & IS_MALLOCED) != 0) {     <==================如果native內存是通過malloc方式分配的,則走這個if分支
             final long notifyImmediateThreshold = 300000;
             if (size >= notifyImmediateThreshold) {   <=========如果native內存大於等於300000bytes(~300KB),則走這個分支
                 runtime.notifyNativeAllocationsInternal();
             } else {                         <==================如果native內存小於300000bytes,則走這個分支
                 runtime.notifyNativeAllocation();
             }
         } else {
             runtime.registerNativeAllocation(size);
         }
     }

1.1 Malloc內存

Malloc分配的內存會有兩個判斷條件。

  1. 此次分配是否大於等於300,000bytes。大於的話則走VIP通道直接執行CheckGCForNative函數。該函數內部會統計native內存分配的總量,判斷其是否達到GC觸發的閾值。如果達到的話則觸發一次GC。
  2. 此次分配是否是300次分配的整數倍。這個判定條件用於限定CheckGCForNative的執行次數,每300次malloc纔去執行一次檢測。

接下來看看CheckGCForNative函數內部的邏輯。

首先計算當前native內存的總大小,然後計算當前內存大小和閾值之間的比值,如果比值≥1,則請求一次新的GC。

art/runtime/gc/heap.cc

 inline void Heap::CheckGCForNative(Thread* self) {
   bool is_gc_concurrent = IsGcConcurrent();
   size_t current_native_bytes = GetNativeBytes();    <================獲取native內存的總大小
   float gc_urgency = NativeMemoryOverTarget(current_native_bytes, is_gc_concurrent); <============計算當前內存大小和閾值之間的比值,大於等於1則表明需要一次新的GC
   if (UNLIKELY(gc_urgency >= 1.0)) {
     if (is_gc_concurrent) {
       RequestConcurrentGC(self, kGcCauseForNativeAlloc, /*force_full=*/true);   <=================請求一次新的GC
       if (gc_urgency > kStopForNativeFactor
           && current_native_bytes > stop_for_native_allocs_) {
         // We're in danger of running out of memory due to rampant native allocation.
         if (VLOG_IS_ON(heap) || VLOG_IS_ON(startup)) {
           LOG(INFO) << "Stopping for native allocation, urgency: " << gc_urgency;
         }
         WaitForGcToComplete(kGcCauseForNativeAlloc, self);
       }
     } else {
       CollectGarbageInternal(NonStickyGcType(), kGcCauseForNativeAlloc, false);
     }
   }
 }

獲取當前native內存的總大小需要調用GetNativeBytes函數。其內部統計也分爲兩部分,一部分是通過mallinfo獲取的當前malloc的總大小。由於系統有專門的API獲取這個信息,所以在NativeAllocationRegistry.registerNativeAllocation的時候不需要專門去存儲單次malloc的大小。另一部分是native_bytes_registered_字段記錄的所有註冊過的mmap大小。二者相加,基本上反映了當前進程native內存的整體消耗。

art/runtime/gc/heap.cc

 size_t Heap::GetNativeBytes() {
   size_t malloc_bytes;
 #if defined(__BIONIC__) || defined(__GLIBC__)
   IF_GLIBC(size_t mmapped_bytes;)
   struct mallinfo mi = mallinfo();
   // In spite of the documentation, the jemalloc version of this call seems to do what we want,
   // and it is thread-safe.
   if (sizeof(size_t) > sizeof(mi.uordblks) && sizeof(size_t) > sizeof(mi.hblkhd)) {
     // Shouldn't happen, but glibc declares uordblks as int.
     // Avoiding sign extension gets us correct behavior for another 2 GB.
     malloc_bytes = (unsigned int)mi.uordblks;
     IF_GLIBC(mmapped_bytes = (unsigned int)mi.hblkhd;)
   } else {
     malloc_bytes = mi.uordblks;
     IF_GLIBC(mmapped_bytes = mi.hblkhd;)
   }
   // From the spec, it appeared mmapped_bytes <= malloc_bytes. Reality was sometimes
   // dramatically different. (b/119580449 was an early bug.) If so, we try to fudge it.
   // However, malloc implementations seem to interpret hblkhd differently, namely as
   // mapped blocks backing the entire heap (e.g. jemalloc) vs. large objects directly
   // allocated via mmap (e.g. glibc). Thus we now only do this for glibc, where it
   // previously helped, and which appears to use a reading of the spec compatible
   // with our adjustment.
 #if defined(__GLIBC__)
   if (mmapped_bytes > malloc_bytes) {
     malloc_bytes = mmapped_bytes;
   }
 #endif  // GLIBC
 #else  // Neither Bionic nor Glibc
   // We should hit this case only in contexts in which GC triggering is not critical. Effectively
   // disable GC triggering based on malloc().
   malloc_bytes = 1000;
 #endif
   return malloc_bytes + native_bytes_registered_.load(std::memory_order_relaxed);
   // An alternative would be to get RSS from /proc/self/statm. Empirically, that's no
   // more expensive, and it would allow us to count memory allocated by means other than malloc.
   // However it would change as pages are unmapped and remapped due to memory pressure, among
   // other things. It seems risky to trigger GCs as a result of such changes.
 }

得到當前進程native內存的總大小之後,便需要抉擇是否需要一次新的GC。

決策的過程如下,源碼下面是詳細解釋。

art/runtime/gc/heap.cc

 // Return the ratio of the weighted native + java allocated bytes to its target value.
 // A return value > 1.0 means we should collect. Significantly larger values mean we're falling
 // behind.
 inline float Heap::NativeMemoryOverTarget(size_t current_native_bytes, bool is_gc_concurrent) {
   // Collection check for native allocation. Does not enforce Java heap bounds.
   // With adj_start_bytes defined below, effectively checks
   // <java bytes allocd> + c1*<old native allocd> + c2*<new native allocd) >= adj_start_bytes,
   // where c3 > 1, and currently c1 and c2 are 1 divided by the values defined above.
   size_t old_native_bytes = old_native_bytes_allocated_.load(std::memory_order_relaxed);
   if (old_native_bytes > current_native_bytes) {
     // Net decrease; skip the check, but update old value.
     // It's OK to lose an update if two stores race.
     old_native_bytes_allocated_.store(current_native_bytes, std::memory_order_relaxed);
     return 0.0;
   } else {
     size_t new_native_bytes = UnsignedDifference(current_native_bytes, old_native_bytes);   <=======(1)
     size_t weighted_native_bytes = new_native_bytes / kNewNativeDiscountFactor              <=======(2)
         + old_native_bytes / kOldNativeDiscountFactor;
     size_t add_bytes_allowed = static_cast<size_t>(                                         <=======(3)
         NativeAllocationGcWatermark() * HeapGrowthMultiplier());
     size_t java_gc_start_bytes = is_gc_concurrent                                           <=======(4)
         ? concurrent_start_bytes_
         : target_footprint_.load(std::memory_order_relaxed);
     size_t adj_start_bytes = UnsignedSum(java_gc_start_bytes,                               <=======(5)
                                          add_bytes_allowed / kNewNativeDiscountFactor);
     return static_cast<float>(GetBytesAllocated() + weighted_native_bytes)                  <=======(6)
          / static_cast<float>(adj_start_bytes);
   }
 }

首先將本次native內存總大小和上一次GC完成後的native內存總大小進行比較。如果小於上次的總大小,則表明native內存的使用水平降低了,因此完全沒有必要進行一次新的GC。

但如果這次native內存使用增長的話,則需要進一步計算當前值和閾值之間的比例關係,大於等於1的話就需要進行GC。下面詳細介紹源碼中的(1)~(6)。

(1)計算本次native內存和上次之間的差值,這個差值反映了native內存中新增長部分的大小。

(2)給不同部分的native內存以不同的權重,新增長部分除以2,舊的部分除以65536。之所以給舊的部分權重如此之低,是因爲native堆本身是沒有上限的。這套機制的初衷並不是限制native堆的大小,而只是防止兩次GC間native內存垃圾積累過多。

(3)所謂的閾值並不是爲native內存單獨設立的,而是爲(Java堆大小+native內存大小)整體設立的。add_bytes_allowed表示在原有Java堆閾值的基礎上,還可以允許的native內存大小。NativeAllocationGcWatermark根據Java堆閾值計算出允許的native內存大小,Java堆閾值越大,允許的值也越大。HeapGrowthMultipiler對於前臺應用是2,表明前臺應用的內存管控更松,GC觸發頻率更低。

(4)同等條件下,同步GC的觸發水位要低於非同步GC,原因是同步GC在垃圾回收時也會有新的對象分配,因此加上這些新分配的對象最好也不要超過閾值。

(5)將Java堆閾值和允許的native內存相加,作爲新的閾值。

(6)將Java堆已分配的大小和調整權重後的native內存大小相加,並將相加後的結果除以閾值,得到一個比值來判定是否需要GC。

通過如下代碼可知,當比值≥1時,將請求一次新的GC。

art/runtime/gc/heap.cc

   if (UNLIKELY(gc_urgency >= 1.0)) {
     if (is_gc_concurrent) {
       RequestConcurrentGC(self, kGcCauseForNativeAlloc, /*force_full=*/true);   <=================請求一次新的GC

1.2 MMap內存

mmap的處理方式和malloc基本相當,大於300,000 bytes或mmap三百次都執行CheckGCForNative。唯一的區別在於mmap需要將每一次的大小都計入native_bytes_registered中,因爲mallinfo中並不會記錄這個信息(針對bionic庫而言)。

art/runtime/gc/heap.cc

 void Heap::RegisterNativeAllocation(JNIEnv* env, size_t bytes) {
   // Cautiously check for a wrapped negative bytes argument.
   DCHECK(sizeof(size_t) < 8 || bytes < (std::numeric_limits<size_t>::max() / 2));
   native_bytes_registered_.fetch_add(bytes, std::memory_order_relaxed);
   uint32_t objects_notified =
       native_objects_notified_.fetch_add(1, std::memory_order_relaxed);
   if (objects_notified % kNotifyNativeInterval == kNotifyNativeInterval - 1
       || bytes > kCheckImmediatelyThreshold) {
     CheckGCForNative(ThreadForEnv(env));
   }
 }

2. 如何在Java對象回收時觸發native內存回收

NativeAllocationRegistry中主要依靠Cleaner機制完成了這個過程。關於Cleaner的細節,可以參考我的上篇博客

3. 實際案例

Bitmap類就是通過NativeAllocationRegistry來實現native資源自動釋放的。以下是Bitmap構造方法的一部分。

frameworks/base/graphics/java/android/graphics/Bitmap.java

         mNativePtr = nativeBitmap;         <=========================== 通過指針值間接持有native資源
 
         final int allocationByteCount = getAllocationByteCount(); <==== 獲取native資源的大小,如果是mmap方式,這個大小最終會計入native_bytes_registered中
         NativeAllocationRegistry registry;
         if (fromMalloc) {
             registry = NativeAllocationRegistry.createMalloced(   <==== 根據native資源分配方式的不同,構造不同的NativeAllocationRegistry對象,nativeGetNativeFinalizer()返回的是native資源釋放函數的函數指針
                     Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), allocationByteCount);
         } else {
             registry = NativeAllocationRegistry.createNonmalloced(
                     Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), allocationByteCount);
         }
         registry.registerNativeAllocation(this, nativeBitmap);   <===== 檢測是否需要GC

通過上述案例可知,當我們使用NativeAllocationRegistry來爲Java類自動釋放native內存資源時,首先需要創建NativeAllocationRegistry對象,接着調用registerNativeAllocation方法。只此兩步,便可實現native資源的自動釋放。

既然需要兩步,那爲什麼registerNativeAllocation不放進NativeAllocationRegistry的構造方法,這樣一步豈不是更好?原因是registerNativeAllocation獨立出來,便可以在native資源真正申請後再去告知GC,靈活性更高。此外,NativeAllocationRegistry中還有一個registerNativeFree方法與之對應,可以讓應用層在自己提前釋放native資源後告知GC。

作者:蘆半山
鏈接:https://juejin.im/post/6894153239907237902

文末

感謝大家關注我,分享Android乾貨,交流Android技術。
對文章有何見解,或者有何技術問題,都可以在評論區一起留言討論,我會虔誠爲你解答。
也歡迎大家來我的B站找我玩,有各類Android架構師進階技術難點的視頻講解,助你早日升職加薪。
B站直通車:https://space.bilibili.com/544650554

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章