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

Java 大对象的内存管理比较简单,主要集中在 LargeObjectSpace 模块,主要负责 Java 大对象的内存申请和释放。

3.2.1 内存申请

结合源码,接下来简单介绍一下 Java 大对象申请流程,整理流程图如下。在此过程中,重点关注 Heap 已申请内存大小的更新过程和内存不足时抛出 OOM 的过程

a6a75cb7879cb9e2d2f45741baad49d7.png

在上图我们可以看到,在内存实际申请过程,会首先检测当前对象是否满足大对象,具体条件是:申请对象类型必须是原子类型或者 String 类型,并且要大于 3 个物理页

inline bool Heap::ShouldAllocLargeObject(ObjPtrmirror::Class c, size_t byte_count) const {

return byte_count >= large_object_threshold_ && (c->IsPrimitiveArray() || c->IsStringClass());

}

如果满足以上条件,则会执行大对象申请流程,从 LargeObjectMapSpace 进行申请。但是在正式申请之前,会再次判断是否内存触顶,计算规则是将 Heap 中已经申请的内存和将要申请的内存进行相加,判断是否超过虚拟机的内存上限(growth_limit_),如果没有超过则说明不会触顶,然后就会直接从 LargeObjectMapSpace 申请一块内存;如果大于 growth_limit_,则说明可能会触顶,此时要先进行 GC,争取释放一些内存,如果多轮 GC 之后仍然满足不了,则抛出 OOM

在通过以上检测之后,将会通过LargeObjectMapSpace::Alloc 实例化一个 Object 对象,如下图:

de25d421c333adb148181f6cd007a853.png

结合上图,可以看到 LargeObjectMapSpace 在内存申请过程中如要完成以下工作:

  1. 根据申请的内存大小,利用 mem_map 映射与之对应的一块内存;

  2. 将映射的内存转换为 mirror::Object 对象;

  3. 将该 Object 对象与 mem_map 实例进行关联,并存储到 large_objects 集合;

  4. 更新 largeObjectMapSpace 当前内存占用和对象数量,以及累计占用大小和对象数量;

3.2.2 内存释放

内存释放逻辑:根据传入的对象,先检查是否在 large_objects___集合中,如果不存在则抛出异常;否则同步更新(释放)当前内存状态,并从该集合移除该对象。

22d7e124ffd071fa4b4dbf14624c6f28.png

在介绍完虚拟机内存空间管理以及 Large Object Space 内存管理方式的相关背景知识后,接下来我们回归到正题,看看我们针对虚拟机是如何改造 Large Object Space 的内存管理策略的。

4. mSponge 实现原理


4.1 方案简介

为了便于更好地理解,我们将整个方案分为 2 个部分进行介绍。

一期方案:主要介绍在 Java 大对象通过 LargeObjectSpace 的内存申请和释放过程中,如何在内存申请和释放过程对其进行改造,以脱离虚拟机对这些对象的内存管理,最后实现 LargeObjectSpace 占用的内存完全脱离虚拟机内存统计。

二期方案:针对一期方案需要在应用运行过程中提前开启,但是线上 99%以上运行过程中可能不会发生 OOM,因此一期方案对系统的侵入有点高。为了优化这个现象,二期方案主要通过监听应用是否发生 OOM,如监测到 OOM 则拦截并同开启一期方案“释放更多可用内存”,然后重试内存申请,以挽救本次 OOM;如果没有发生 OOM,则说明内存状态良好,该方案就不需要开启。显然,这种智能式的开启方式和最大化的内存保障效果将是更加极致的解决方案。

4.2 命名由来

在运行过程中,该方案会随着 LargeObjectSpace 的使用情况动态“吸收”和“释放”虚拟机 Heap 统计内存——“吸收”不希望被虚拟机统计的 LargeObjectSpace 的内存,“释放”已经通过 GC 回收的 Large Object 内存。整个运行过程犹如海绵吸水,故将该方案命名为:Memory Sponge,寓意:内存海绵,简称mSponge

4.3 mSponge 一期

从上面的大内存申请流程图中可以看到,如果当前内存申请满足 Java 大对象的条件(大于 12K),并在内存申请过程检测是否内存触顶时,“一直”返回 False,则可以通过 LargeObjectMapSpace 直接申请并返回对象实例,则可以绕过这里的内存触顶 OOM 问题。

同时,我们知道LargeObjectMapSpace 内部管理的对象是离散的,不支持虚拟机 GC 过程中连续内存空间的拷贝压缩的特性,因此即使是该空间内存占用过多,导致总内存超过了上限(512M?),但是其它连续内存 Space 的内存阈值仍然保持正常范围,因此不会影响到其他内存空间 GC 同构拷贝压缩能力,也就不会破坏虚拟机的内存管理。

4.3.1 方案思路

那么如何才能在大对象内存触顶检测过程中绕开现有的检测机制呢?通过调研发现判断内存触顶的关键条件在于虚拟机中管理当前已申请内存 Heap::num_bytes_allocated_对象,即每次内存申请成功和 GC 释放时,都会同步更新该值:

inline mirror::Object* Heap::AllocObjectWithAllocator(Thread* self,

ObjPtrmirror::Class klass, size*t byte_count, AllocatorType allocator, const PreFenceVisitor& pre_fence_visitor) {

//实际申请内存过程

if (bytes_tl_bulk_allocated > 0) {

size_t num_bytes_allocated_before =

//成功申请之后,需要同步更新虚拟机整体 Heap 内存使用

num_bytes_allocated* . fetch_add ( bytes_tl_bulk_allocated , std :: memory_order_relaxed );

}

}

GC 过程中,当每个 Space 释放一定对象和内存之后,会进一步同步到虚拟机的 Heap 对象,同步更新虚拟机整体内存使用,接口如下:

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 );

}

通过上面这个接口我们可以看到,每个 Space 在内存回收后都会更新虚拟机更新整体内存使用情况,那么我们是不是可以在合适的时机人为主动调用该接口,减去 LargeObjectMapSpace 管理的内存值,那么 Heap::num_bytes_allocated_统计的就全部是其他内存 Space 的内存使用了;换而言之通过 LargeObjectMapSpace 申请的内存将会“脱离虚拟机”Heap::num_bytes_allocated_的统计,纳入 Native 层的内存管理。但是这些对象的引用和内存回收机制仍然由虚拟机管理,因此并不会存在内存泄漏的隐患。

4.3.2 流程示意

b0206e19441ac40ec87854896e1a4276.png

LargeObjectMapSpace申请的内存,直接通过 Map 映射到虚拟内存,因此对于 32 位环境应用空间可映射内存在 3G 左右,但虚拟机本身会抢先占用 1G+的地址空间用于管理 Java 内存,因此应用侧实际使用范围在 2G 左右,极端情况下调整后的虚拟机内存理论范围将在 512M~2.5G,至于下限为何是 512M?理论上如果发生 OOM 时虚拟机没有任何大对象,这种情况下,则虚拟机可用内存范围将保持不变,因为我们改变的 Java 大对象的内存可用空间;示意图如下:

f34f680a547ea7e617af1866004501a8.png

4.3.3 关键实现

上面介绍了该方案的背景知识和实现思路,接下来就要从技术层面考虑如何去实现了。如果在系统层面,直接从源码层面定制,相关改动会轻松很多,但是对应用侧来说,要想兼容不同 Android 版本,只有一条路可走——通过 InlineHook 代理相关接口,在执行过程中魔改相关参数以达到目的。在解决完接口代理问题之后,接下来还有下面几件事情要解决:

  • 虚拟机并没有对外暴露获取 LargeObjectMapSpace 内存的接口,如何才能实时获取当前 Space 已申请的内存大小?

  • 如何在合适的时机同步 Heap::num_bytes_allocated_内存统计,以便于让 LargeObjectMapSpace 的内存"脱离"虚拟机的统计?

  • 如何"跳过"虚拟机在内存释放过程对内存大小一致性校验的问题?

4.3.3.1 获取 LargeObjectMapSpace 当前内存

针对第一个问题,尽管 LargeObjectSpace 中提供了获取当前内存大小的接口(LargeObjectSpace::GetBytesAllocated),但是这个接口并没有对外暴露,因此需要通过解析 Libart.so 中的"GetBytesAllocated"符号,以 Android Q 为例,该函数签名符号为:_ZN3art2gc5space16LargeObjectSpace17GetBytesAllocatedEv;并在运行过程中动态获取该符号在内存中的地址。

由于 GetBytesAllocated 是非静态函数,因此在实际调用该接口时,需要知道当前对象的实例化对象,然后通过实例化对象调用该接口即可,在这里,我们通过 inlineHook 代理"LargeObjectMapSpace::Alloc"获取 LargeObjectMapSpace 的实例,LargeObjectMapSpace::Alloc 接口部分源码如下:

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;

MemMap mem_map = MemMap::MapAnonymous(“large object space allocation”,

num_bytes,

PROT_READ | PROT_WRITE,

/low_4gb=/ true,

&error_msg);

//申请成功后将当前内存占用+ allocation_size

num_bytes_allocated_  += allocation_size ;

total_bytes_allocated_ += allocation_size;

++ num_objects_allocated_  ;  //申请成功后将当前内存数量+1

++total_objects_allocated_;

return obj;

}

在获取 LargeObjectMapSpace 的实例化对象之后,再通过该对象直接调用 GetBytesAllocated即可实时获取当前 LargeObjectMapSpace 的内存大小

4.3.3.2"移除"LargeObjectSpace 内存

当我们可以实时获取 LargeObjectSpace 的内存使用之后,接下来便是如何从虚拟机 Heap 中“移除”LargeObjectSpace 实际占用的内存了,通过调研发现可以通过解析"Heap::RecordFree"函数符号,并调用Heap::RecordFree 的函数接口,增加或减少 Heap 中我们想要更新的内存大小

void Heap::RecordFree(uint64_t freed_objects, int64_t freed_bytes) {

// Note: This relies on 2s complement for handling negative freed_bytes.

num_bytes_allocated_.fetch_sub (static_cast< ssize_t >( freed_bytes ), std :: memory_order_relaxed );

}

当上述条件满足我们可以灵活更新虚拟机 Heap 内存之后,接下来要处理的就是选择一个合适的时机直接“移除”LargeObjectSpace 的内存,并且需要更新记录当前 Heap“移除”的内存大小,经过调研并考虑到及时性和精准性,最终选择了在 LargeObjectSpace 的内存申请和回收过程,对虚拟机 Heap 内存进行动强制更新,以移除虚拟机对 LargeObjectSpace 的内存统计。

在 Large Object 申请过程,如果内存申请成功,则在该 Object 实例化对象返回之前,先强制从虚拟机内存统计中减去该部分内存,接下来虚拟机内部会在返回实例化对象之后,并统计本次新增内存,在这里我们通过先减后加的方式,维持了整个内存水位不变,从而间接地实现了虚拟机“忽略”了本次内存开销。

c0150f2d4ef4ee61b643232c7a71c5ce.png

如果后续 GC 过程中释放了 LargeObjectSpace 中的部分或者全部对象,正常情况下释放的内存会同步同步到 Heap,以便于更新整体使用内存及可用内存,但是从上面的分析中我们知道,其实 LargeObjectSpace 的内存已经不在 Heap 统计之中了,如果从 Heap 中减去释放的这些内存,那么将会导致 Heap 统计的内存偏少,因此需要主动将该部分释放的内存"补偿"回来,避免统计错乱。

1aa55a999e2a380454f781d8fa869592.png

通过上述步骤实现了在内存回收过程中对大对象内存管理的改造,改造之后 Heap 统计的内存将不再包含 LargeObjectSpace 管理的内存,从而间接地扩大了其他内存 Space 使用上限;对于 LargeObjectSpace 来说,虚拟机统计到的该内存 Space 一直为 0,但是 LargeObjectSpace 内部并没有内存限制,异常该 Space 的内存使用上限将会显著提升,针对 Android O 以下系统来说,这部分内存 Space 不仅包含 Bitmap,还包含其他大对象。

4.3.3.3 内存校验

在适配过程中,发现 Android L 版本之后,虚拟机会在 GC 过程中对释放内存和使用内存进行一次校验。如果发现当前使用内存加上释放内存小于 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 过程中,动态调整了 Heap 当前使用内存大小,这可能会导致 gc 结束后再次获取的 Heap 当前使用内存小于实际值,为了不影响校验逻辑,需要代理 Heap::GrowForUtilization 接口,强制将bytes_allocated_before_gc 参数设置为 0,以保证校验恒成立(针对该处调整从后续逻辑和实际测试来看,对后续内存 GC 并无明显影响)

4.3.4 小结

至此,通过上述思路和技术方案完成了 Android 虚拟机内存统计策略的改造,该方案不仅间接提升了虚拟机其它内存空间运行时的使用上限,也将 LargeObjectSpace 的内存使用上限完全脱离了虚拟机的限制,完全等同于 Native 内存属性进行管理。因此该方案相比 Android 系统 Bitmap 内存管理改造更加彻底,给应用的内存环境带来了极大的改善。

4.4 mSponge 方案二期

在上文,对内存统计策略改造之后可以很大程度释放 LargeObjectSpace 内存空间以优化 Java OOM 问题,但是进一步思考之后,发现一期方案并不是最优解,因为应用运行过程中很大概率不会发生 OOM,如果能监听到 OOM 时,再启动优化方案,同时再救活本次 OOM,那么这种智能化的按需开启将是极致化的解决方案

4.4.1 方案思路

针对上述思考,基于 mSponge 方案一期的设计,决定采用“OOM 探测+按需开启”的策略来完成对内存的按需扩展。即:对内存申请过程进行定向监控,当监听到内存不足即将抛出 OOM 异常时,进行拦截,并激活 mSponge 方案一期内存优化方案(从 Heap 内存统计中移除当前 LargeObjectSpace 使用内存);然后再触发一次内存申请,以保证内存成功申请。按照上述思路,整理方案优化前后对比示意图:

953753ae037d5406941ce85dbe7fe150.png

4.4.2 流程示意

基于上面的思路,我们需要在虚拟机内部监听并拦截 OOM,当监听到第一次 OOM 时主动将 LargeObjectSpace 的内存从 Heap 统计中移除,以增加空闲内存,同时再开启 mSponge 一期优化策略,以保证后续 LargeObjectSpace 的内存变化不会影响 Heap 内存统计;按照这种思路,整体二期方案示意图如下:

4a8b38cc643ffd6ce8330fc7d42242c1.png

4.4.3 关键实现

二期技术实现主要涉及下面几个流程:监听并判断是否需要拦截 OOM 异常;监听内存分配结果;重试内存申请。具体如下:

  • 监听 OOM 异常:代理 Heap::ThrowOutOfMemoryError,监听内存分配失败时抛出 OOM 的过程,并判断是否需要拦截,如果可拦截。

  • 监听内存分配结果:代理 Heap::AllocateInternalWithGc ,监听本次内存申请过程中,是否发生了 OOM 并被拦截,如果发生 OOM 并被拦截,则再次触发内存申请,以保证内存申请成功。

  • 重试内存申请:通过 AllocateInternalWithGc 再次触发一次内存申请,并在此之前禁止拦截本次内存申请过程中可能抛出的 OOM,如果成功申请,则返回该对象。

4.4.3.1 监听 ThrowOutOfMemoryError

通过 inlineHook 代理"Heap::ThrowOutOfMemoryError"并监听该接口,如果该接口被调用则说明,当前内存不足或者没有连续内存,无法满足本次内存需求;并根据调用 AllocateInternalWithGc 代理接口设置的标识"sAllowsSkipThrowOutOfMemoryError",判断是否拦截本次 OOM 异常;实现如下:

void ThrowOutOfMemoryErrorProxy(void* heap, void* self, size_t byte_count, AllocatorType allocator_type){

if(isAllowWork()) {

sFindThrowOutOfMemoryError = true;

if (sAllowsSkipThrowOutOfMemoryError

&& !sForceAllocateInternalWithGc) {

//拦截并跳过本次OutOfMemory,并置标记位

sSkipThrowOutOfMemoryError = true;

//TODO:将LargeObjectSpace内存从Heap中移除

return;

}

sSkipThrowOutOfMemoryError = false;

//如果不允许拦截,则直接调用原函数,抛出OOM异常

ThrowOutOfMemoryErrorOrigin(heap, self, byte_count, allocator_type);

} else{

ThrowOutOfMemoryErrorOrigin(heap, self, byte_count, allocator_type);

}

}

4.4.3.2 代理 AllocateInternalWithGc

通过 inlineHook 代理"Heap::AllocateInternalWithGc"代理并监听该接口,因为在该接口执行过程中会触发一次或多次 GC,如果依然满足不了本次内存申请,则会抛出 OOM 并返回 NULL;因此可以通过代理该接口可以知道本次内存申请是否成功,以及本次申请过程中是否抛出 OOM 异常;如果返回对象为 NULL,并拦截了 OOM 异常,则设置禁止拦截 OOM 标记之后,调用 Heap::AllocateInternalWithGc 原接口再次进行内存申请,以保证成功申请内存。

原则上通过 mSpnge 方案将 LargeObjectSpace 的内存从 Heap 移除之后,理论上虚拟机可用内存会增加很多,基本能保证本次内存成功申请(极端情况仍会出现内存不足,正常抛出 OOM 即可)。

最后

如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

欢迎大家一起交流讨论啊~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
依然满足不了本次内存申请,则会抛出 OOM 并返回 NULL;因此可以通过代理该接口可以知道本次内存申请是否成功,以及本次申请过程中是否抛出 OOM 异常;如果返回对象为 NULL,并拦截了 OOM 异常,则设置禁止拦截 OOM 标记之后,调用 Heap::AllocateInternalWithGc 原接口再次进行内存申请,以保证成功申请内存。

原则上通过 mSpnge 方案将 LargeObjectSpace 的内存从 Heap 移除之后,理论上虚拟机可用内存会增加很多,基本能保证本次内存成功申请(极端情况仍会出现内存不足,正常抛出 OOM 即可)。

最后

如果你看到了这里,觉得文章写得不错就给个赞呗?如果你觉得那里值得改进的,请给我留言。一定会认真查询,修正不足。谢谢。

[外链图片转存中…(img-oSzUXRHR-1715314820557)]

欢迎大家一起交流讨论啊~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值