背景
随着性能优化逐渐步入深水区,我们也很容易发现,越来越多大厂开始往更底层的方向去进行性能优化的切入。内存相关一直是性能优化中一个比较重要的指标,移动端应用的内存默认是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虚拟机堆内存模型
上图我们可以看到,它其实是我们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方案的思路
我们来看一下字节大佬给出的流程图
- 第一步,我们需要做到,在oom的时候进行监听,当oom发生的时候,进行堆中LargeObjectSpace的内存进行隐藏,并拦截本次oom
- 进行LargeObjectSpace内存隐藏,内存的大小等于当前LargeObjectSpace
- 重新发起内存申请
主要的流程如上,当然,我们还需要兼顾一些额外的副作用,比如我们需要屏蔽虚拟机中对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/沙龙等活动能不定期看到我的身影,如果你有疑问,抓住我就问吧哈哈哈哈!逃!
本文正在参加「金石计划」