拯救OOM!突破堆内存大小上限! mSponge方案实践

2023-05-16

背景

随着性能优化逐渐步入深水区,我们也很容易发现,越来越多大厂开始往更底层的方向去进行性能优化的切入。内存相关一直是性能优化中一个比较重要的指标,移动端应用的内存默认是256M/512M,对于常驻的应用来说,内存遇到的挑战会更多,因此,像字节等大厂,针对内存也出了不少“黑科技”方案,比如在android o一些,把bitmap的内存放到native层(android o 之后也官方也确实这么做),还有就是突破堆内存限制,扩大堆内存,比如拯救OOM!字节自研 Android 虚拟机内存管理优化黑科技 mSponge。

mSponge的方案最牛逼的是,不但能在android o以下有着【bitmap放在native层的方案】的效果(因为android o以下的bitmap对象,其实真正占用内存的是java层的byte数组,而这个byte数组,其实也是符合大对象的定义的),同时也能把非Bitmap的大对象内存在虚拟机堆大小计算时进行隐藏,从而达到突破堆内存上限的目的(增量取决于大对象内存LargeObjectSpace的大小),可惜的是,mSponge的方案并未开源,但是!不要紧,我们今天就来复刻一个我们自己的mSponge!!

相关代码已经放在我的mooner项目里面,作为一个子功能,求star

理论部分

art虚拟机堆内存模型

拯救OOM!字节自研 Android 虚拟机内存管理优化黑科技 mSponge

上图我们可以看到,它其实是我们art虚拟机关于堆内存的模型,里面堆模型相关的内容,我也在这篇文章中介绍过Art 虚拟机系列 - Heap内存模型 1,上面涉及到各个Space,我们就不再介绍了

mSponge的方案,其实就是把堆中属于LargeObjectSpace的内存进行计算时的隐藏,从而达到提高堆上限的目的。

可能有的小伙伴会问,为什么LargeObjectSpace的内存可以做到计算隐藏,其他Space不可以嘛?其实这是LargeObjectSpace本身的特性决定的,LargeObjectSpace继承自DiscontinuousSpace,它有着一个非常重要的特性,就是在Space内存布局上,不像其他Space是地址紧密联系的,我们从上图堆内存示例图也可以看到,因此就避免了gc时或者分配时内存时,存在的内存错误的影响。而其他Space,由于存在地址相关等特性,如果隐藏就很容易触发内存访问错误的异常。

我们再回来,LargeObjectSpace,在Art中,其实有两种实现,一种是FreeList的方式[FreeListSpace],另一种是Map的方式[LargeObjectMapSpace]

FreeListSpace 通过找到list中空闲单位进行分配,找到符合单位,对象放进去即可
mirror::Object* FreeListSpace::Alloc(Thread* self, size_t num_bytes, size_t* bytes_allocated,
        size_t* usable_size, size_t* bytes_tl_bulk_allocated) {
    MutexLock mu(self, lock_);
    const size_t allocation_size = RoundUp(num_bytes, kAlignment);
    AllocationInfo temp_info;
    temp_info.SetPrevFreeBytes(allocation_size);
    temp_info.SetByteSize(0, false);
    AllocationInfo* new_info;
    // Find the smallest chunk at least num_bytes in size.
    auto it = free_blocks_.lower_bound(&temp_info);
    if (it != free_blocks_.end()) {
        AllocationInfo* info = *it;
        free_blocks_.erase(it);
        // Fit our object in the previous allocation info free space.
        new_info = info->GetPrevFreeInfo();
        // Remove the newly allocated block from the info and update the prev_free_.
        info->SetPrevFreeBytes(info->GetPrevFreeBytes() - allocation_size);
        if (info->GetPrevFreeBytes() > 0) {
            AllocationInfo* new_free = info - info->GetPrevFree();
            new_free->SetPrevFreeBytes(0);
            new_free->SetByteSize(info->GetPrevFreeBytes(), true);
            // If there is remaining space, insert back into the free set.
            free_blocks_.insert(info);
        }
    } else {
        // Try to steal some memory from the free space at the end of the space.
        if (LIKELY(free_end_ >= allocation_size)) {
            // Fit our object at the start of the end free block.
            new_info = GetAllocationInfoForAddress(reinterpret_cast<uintptr_t>(End()) - free_end_);
            free_end_ -= allocation_size;
        } else {
            return nullptr;
        }
    }
    DCHECK(bytes_allocated != nullptr);
    *bytes_allocated = allocation_size;
    if (usable_size != nullptr) {
        *usable_size = allocation_size;
    }
    DCHECK(bytes_tl_bulk_allocated != nullptr);
    *bytes_tl_bulk_allocated = allocation_size;
    // Need to do these inside of the lock.
    ++num_objects_allocated_;
    ++total_objects_allocated_;
    num_bytes_allocated_ += allocation_size;
    total_bytes_allocated_ += allocation_size;
    mirror::Object* obj = reinterpret_cast<mirror::Object*>(GetAddressForAllocationInfo(new_info));
    // We always put our object at the start of the free block, there cannot be another free block
    // before it.
    if (kIsDebugBuild) {
        CheckedCall(mprotect, __FUNCTION__, obj, allocation_size, PROT_READ | PROT_WRITE);
    }
    new_info->SetPrevFreeBytes(0);
    new_info->SetByteSize(allocation_size, false);
    return obj;
}
复制代码
LargeObjectMapSpace MemMap 内存映射分配 根据分配对象大小,然后对齐,分配即可
mirror::Object* LargeObjectMapSpace::Alloc(Thread* self, size_t num_bytes,
                                           size_t* bytes_allocated, size_t* usable_size,
                                           size_t* bytes_tl_bulk_allocated) {
  std::string error_msg;
  每次都调用MapAnonymous,其实它最终调用的就是mmap
  MemMap mem_map = MemMap::MapAnonymous("large object space allocation",
                                        num_bytes,
                                        PROT_READ | PROT_WRITE,
                                        /*low_4gb=*/ true,
                                        &error_msg);
  if (UNLIKELY(!mem_map.IsValid())) {
    LOG(WARNING) << "Large object allocation failed: " << error_msg;
    return nullptr;
  }
  mirror::Object* const obj = reinterpret_cast<mirror::Object*>(mem_map.Begin());
  const size_t allocation_size = mem_map.BaseSize();
  MutexLock mu(self, lock_);
  large_objects_.Put(obj, LargeObject {std::move(mem_map), false /* not zygote */});
  DCHECK(bytes_allocated != nullptr);

  if (begin_ == nullptr || begin_ > reinterpret_cast<uint8_t*>(obj)) {
    begin_ = reinterpret_cast<uint8_t*>(obj);
  }
  end_ = std::max(end_, reinterpret_cast<uint8_t*>(obj) + allocation_size);

  *bytes_allocated = allocation_size;
  if (usable_size != nullptr) {
    *usable_size = allocation_size;
  }
  DCHECK(bytes_tl_bulk_allocated != nullptr);
  *bytes_tl_bulk_allocated = allocation_size;
  num_bytes_allocated_ += allocation_size;
  total_bytes_allocated_ += allocation_size;
  ++num_objects_allocated_;
  ++total_objects_allocated_;
  return obj;
}
复制代码

而在Art中,默认的LargeObjectSpace的实现是FreeListSpace,因此如果我们按照文章中拯救OOM!字节自研 Android 虚拟机内存管理优化黑科技 mSponge的实现,去hook LargeObjectMapSpace相关的符号的时候,其实对大部分手机是不生效的,需要注意噢!!

这里就就跟Heap处理选项有关了

https://cs.android.com/android/platform/superproject/+/refs/heads/master:art/runtime/gc/heap.cc;l=678;drc=7346c436e5a11ce08f6a80dcfeb8ef941ca30176
根据large_object_space_type决定选择分配,要么是FreeListSpace,要么是LargeObjectMapSpace,默认ARM架构上large_object_space_type是FreeListSpace

if (large_object_space_type == space::LargeObjectSpaceType::kFreeList) {
    large_object_space_ = space::FreeListSpace::Create("free list large object space", capacity_);
    CHECK(large_object_space_ != nullptr) << "Failed to create large object space";
} else if (large_object_space_type == space::LargeObjectSpaceType::kMap) {
    large_object_space_ = space::LargeObjectMapSpace::Create("mem map large object space");
    CHECK(large_object_space_ != nullptr) << "Failed to create large object space";
} else {
    // Disable the large object space by making the cutoff excessively large.
    large_object_threshold_ = std::numeric_limits<size_t>::max();
    large_object_space_ = nullptr;
}
if (large_object_space_ != nullptr) {
    AddSpace(large_object_space_);
}
复制代码

那么我们再回过头来看,既然默认实现不是LargeObjectMapSpace,那么FreeListSpace能进行内存隐藏吗?虽然FreeListSpace内部管理内存是freelist这种有内存相关性的分配方案,但是对于FreeListSpace本身与外部Space的地址,是存在隔离的,因此mSponge的方案依旧可以作用在FreeListSpace上,对FreeListSpace的内存进行隐藏计算,而不破坏FreeListSpace本身的内存管理!更多FreeListSpace细节,可以看一下@半山 大佬的这篇文章ART虚拟机 | Large Object Space

大对象定义

我们刚刚也吧啦吧啦一大堆,还有个重要的前提,就是什么是大对象?虚拟机对于大对象的定义是啥?因为只有大对象才会落到LargeObjectSpace区域进行堆内存分配。

art/runtime/gc/heap-inl.h

inline bool Heap::ShouldAllocLargeObject(ObjPtr<mirror::Class> c, size_t byte_count) const {
  // We need to have a zygote space or else our newly allocated large object can end up in the
  // Zygote resulting in it being prematurely freed.
  // We can only do this for primitive objects since large objects will not be within the card table
  // range. This also means that we rely on SetClass not dirtying the object's card.
  return byte_count >= large_object_threshold_ && (c->IsPrimitiveArray() || c->IsStringClass());
}
复制代码

可以看到,当该次内存分配的对象大于large_object_threshold,且类型为基础类型数组或字符串时,就会在LargeObjectSpace进行分配

large_object_threshold 默认为12kb,3 * kPageSize,3个页的大小

static constexpr size_t kMinLargeObjectThreshold = 3 * kPageSize;
static constexpr size_t kDefaultLargeObjectThreshold = kMinLargeObjectThreshold;
复制代码

具体内存分配过程在Heap::AllocObjectWithAllocator 中,我就不在本篇介绍,后续会有更多堆相关的文章噢!

mSponge方案的思路

我们来看一下字节大佬给出的流程图

 

  1. 第一步,我们需要做到,在oom的时候进行监听,当oom发生的时候,进行堆中LargeObjectSpace的内存进行隐藏,并拦截本次oom
  2. 进行LargeObjectSpace内存隐藏,内存的大小等于当前LargeObjectSpace
  3. 重新发起内存申请

主要的流程如上,当然,我们还需要兼顾一些额外的副作用,比如我们需要屏蔽虚拟机中对gc内存的校验(看提交记录,这个主要是为了验证虚拟机gc的正确性)但是对于虚拟机gc发展了这么多年,其内部错误的概率可忽略不计了,还有就是gc完成之后,如果释放了属于LargeObjectSpace的内存,我们在额外条件下需要进行堆补偿(因为上面第2步,其实我们已经删除了堆中属于LargeObjectSpace的内存了)。

好了,我们直接进入实战环节!!

实战环节

我们要完成上述的方案,需要完成以下几个小步骤,当几个步骤完成后,其实我们的方案就已经完成了,我的测试手机是android11,以下符号的hook也针对android11噢~ 里面会涉及到inlinehook【采用子节的shadowhook方案,原汁原味噢】的使用,如果对inlinehook还不太清晰的小伙伴,可以先预习一下噢

获取当前LargeObjectSpace的大小

LargeObjectSpace类中,提供了获取当前Space所占据的内存


uint64_t GetBytesAllocated() override {
    MutexLock mu(Thread::Current(), lock_);
    return num_bytes_allocated_;
}
复制代码

因此,我们可以通过符号解析的方式调用该方案,这里符号,就是通过dlopen打开某个so获取到so的句柄,同时通过dlsym去寻找so中的特定符号,从而找到函数本身,但是dlopen已经被谷歌保护起来了,我们不能够直接调用(之前我们在这篇文章有说过,同时破解手段也有提到过噢!),不过这里我们可以直接用shadowhook提供的dlopen即可,如

void *handle = shadowhook_dlopen("libart.so");
void *func = shadowhook_dlsym(handle,
                              "_ZN3art2gc5space16LargeObjectSpace17GetBytesAllocatedEv");
复制代码

_ZN3art2gc5space16LargeObjectSpace17GetBytesAllocatedEv 是GetBytesAllocated在libart中的符号

同时我们也发现这是一个实例方法

((int (*)(void *)) func)
复制代码

它的函数定义是这个,即需要一个LargeObjectSpace的对象的入参

获取LargeObjectSpace对象

我们刚刚也说过,LargeObjectSpace在art中,其实是由它的子类实现,默认的是FreeListSpace,因此我们可以在FreeListSpace进行内存分配的时候,即调用Alloc方法的时候,进行hook即可获取到FreeListSpace指针。

FreeListSpace::Alloc方法的符号是这个

_ZN3art2gc5space13FreeListSpace5AllocEPNS_6ThreadEmPmS5_S5_
复制代码

因此我们hook后,即可获得FreeListSpace的指针,方便后续调用GetBytesAllocated方法


void *los_alloc_proxy(void *thiz, void *self, size_t num_bytes, size_t *bytes_allocated,
                      size_t *usable_size,
                      size_t *bytes_tl_bulk_allocated) {

    void *largeObjectMap = ((los_alloc) los_alloc_orig)(thiz, self, num_bytes, bytes_allocated,
                                                        usable_size,
                                                        bytes_tl_bulk_allocated);
    los = thiz;
    return largeObjectMap;
}
复制代码

删除堆中LargeObjectSpace的大小

void Heap::RecordFree(uint64_t freed_objects, int64_t freed_bytes) {
  ......
  // Note: This relies on 2s complement for handling negative freed_bytes.
  //释放之后,需要同步更新虚拟机整体Heap内存使用
  num_bytes_allocated_  . fetch_sub (static_cast< ssize_t >( freed_bytes ), std :: memory_order_relaxed );
  ......
}
复制代码

RecordFree方法可以删除heap中的堆大小,freed_bytes是释放的大小,freed_objects是某一个对象的地址,这里我们要注意把其设置为一个无效数值,比如-1,因为我们其实没有真正释放某个对象,其大小也是我们LargeObjectSpace中的大小。

//拦截并跳过本次OutOfMemory,并置标记位
void *handle = shadowhook_dlopen("libart.so");
void *func = shadowhook_dlsym(handle, "_ZN3art2gc4Heap10RecordFreeEml");
((void (*)(void *, uint64_t, int64_t)) func)(heap, -1, freeSize);
复制代码

监听oom

我们方案中,还需要监听oom的发生,且把该次oom给拦截掉,去触发一次gc回收。这里的流程是左边是正常OOM流程,右图是我们方案的流程

这里判断oom是否发生,我们可以通过inline hook 该符号即可

_ZN3art2gc4Heap21ThrowOutOfMemoryErrorEPNS_6ThreadEmNS0_13AllocatorTypeE
复制代码
void throw_out_of_memory_error_proxy(void *heap, void *self, size_t byte_count,
                                     enum AllocatorType allocator_type) {
    __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s,%d,%d", "发生了oom ",pthread_gettid_np(pthread_self()),sForceAllocateInternalWithGc);
    // 发生了oom,把oom的标志位设置为true
    sFindThrowOutOfMemoryError = true;
    // 如果当前不是清除堆空间后再引发的oom,则进行堆清除,否则直接oom
    if (!sForceAllocateInternalWithGc) {
        __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s", "发生了oom,进行gc拦截");

        if (los != NULL){
            uint64_t currentAlloc = get_num_bytes_allocated(los);
            if (currentAlloc > lastAllocLOS){
                call_record_free(heap,currentAlloc - lastAllocLOS);
                __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s,%d", "本次增量:",currentAlloc - lastAllocLOS);
                lastAllocLOS = currentAlloc;
                return;
            }
        }
       .....
 
      
    }
    //如果不允许拦截,则直接调用原函数,抛出OOM异常
    __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s", "oom拦截失效");
    ((out_of_memory) throw_out_of_memory_error_orig)(heap, self, byte_count, allocator_type);
}
复制代码

AllocateInternalWithGc

我们也注意到,ThrowOutOfMemoryError被调用的时候,并不一定会发生OOM,而是会尝试用AllocateInternalWithGc,对各个Space进行一次gc,如果gc后有空闲内存得以分配,就不会触发真正的oom异常。因此我们需要hook AllocateInternalWithGc方法,判断分配的对象是否为null,如果为null证明之后又会触发到ThrowOutOfMemoryError方法真正抛出oom。

该方法的符号是

_ZN3art2gc4Heap22AllocateInternalWithGcEPNS_6ThreadENS0_13AllocatorTypeEbmPmS5_S5_PNS_6ObjPtrINS_6mirror5ClassEEE
复制代码
void *allocate_internal_with_gc_proxy(void *heap, void *self,
                                      enum AllocatorType allocator,
                                      bool instrumented,
                                      size_t alloc_size,
                                      size_t *bytes_allocated,
                                      size_t *usable_size,
                                      size_t *bytes_tl_bulk_allocated,
                                      void *klass) {
    __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s", "gc 后分配");
    sForceAllocateInternalWithGc = false;
    void *object = ((alloc_internal_with_gc_type) alloc_internal_with_gc_orig)(heap, self,
                                                                               allocator,
                                                                               instrumented,
                                                                               alloc_size,
                                                                               bytes_allocated,
                                                                               usable_size,
                                                                               bytes_tl_bulk_allocated,
                                                                               klass);

    // 分配内存为null,且发生了oom
    if (object == NULL && sFindThrowOutOfMemoryError) {
        // 证明oom后系统进行gc依旧没能找到合适的内存,所以要尝试进行堆清除
        __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s", "分配内存不足,采取堆清除策略");
        sForceAllocateInternalWithGc = true;
        object = ((alloc_internal_with_gc_type) alloc_internal_with_gc_orig)(heap, self, allocator,
                                                                             instrumented,
                                                                             alloc_size,
                                                                             bytes_allocated,
                                                                             usable_size,
                                                                             bytes_tl_bulk_allocated,
                                                                             klass);
        // 如果当前heap 通过gc后释放了属于largeobjectspace 的空间,此时要进行heap补偿
        if (los != NULL){
            uint64_t currentAllocLOS = get_num_bytes_allocated(los);
            __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s %lu : %lu", "当前数值",currentAllocLOS, lastAllocLOS);
            if (currentAllocLOS < lastAllocLOS){
                call_record_free(heap,currentAllocLOS - lastAllocLOS);
                __android_log_print(ANDROID_LOG_ERROR, MSPONGE_TAG, "%s %lu", "los进行补偿",currentAllocLOS - lastAllocLOS);
            }
        }

        sForceAllocateInternalWithGc = false;
    }
    return object;
}
复制代码

gc后内存校验

因为我们屏蔽了LargeObjectSpace的内存,因此gc前后的大小会不一致,会走到这个判断

void Heap::GrowForUtilization(collector::GarbageCollector* collector_ran,

                              uint64_t bytes_allocated_before_gc) {

   //GC结束后,再次获取当前虚拟机内存大小

  const uint64_t bytes_allocated = GetBytesAllocated();
  ......
 if (!ignore_max_footprint_) {
        const uint64_t freed_bytes = current_gc_iteration_.GetFreedBytes() +
          current_gc_iteration_.GetFreedLargeObjectBytes() +
          current_gc_iteration_.GetFreedRevokeBytes();
    //GC之后虚拟机已使用内存加上本次GC释放内存理论上要大于等于GC之前虚拟机使用的内存,如果不满足,则抛出Fatel异常!!!
  CHECK_GE ( bytes_allocated + freed_bytes , bytes_allocated_before_gc );
 }
  ......

}

这里主要是为了验证相关gc后策略对内存是否存在异常,实际上gc方案已经出来多年,由gc引起的内存异常几乎可以忽略不计,同时根据头条的验证,将bytes_allocated_before_gc写死为0后也没有什么影响,所以我们之间hook GrowForUtilization符号调用,设置bytes_allocated_before_gc为0就不会调用到CHECK_GE之后的异常判断。

void
grow_for_utilization_proxy(void *heap, void *collector_ran, uint64_t bytes_allocated_before_gc) {
    ((grow_for_utilization) grow_for_utilization_orig)(heap, collector_ran, 0);
}
复制代码

总结

通过上面我们拆分的几个环节,我们就能够把mSponge方案给实现了,同时也根据我们的理论对方案进行了一定的调整。

看完实战部分后,如果还有小伙伴不清楚一些细节,同时也苦恼没有效果体验。没关系,我已经开源啦,放在了我们mooner项目里面,作为它的一个子功能,快去体验一下!!github.com/TestPlanB/m…

通过demo,你可以很直观的看到msponge方案的魅力,真的很强大【狗头】,别忘了star呀

最后,如果需要更多交流的小伙伴,也可能在一些分享组织,比如bagutree/沙龙等活动能不定期看到我的身影,如果你有疑问,抓住我就问吧哈哈哈哈!逃!

作者:Pika
链接:https://juejin.cn/post/7218379300505059365
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

拯救OOM!突破堆内存大小上限! mSponge方案实践 的相关文章

随机推荐