本文描述的虚拟机内存管理优化方案,是从应用侧视角对 Android 虚拟机内存管理进行改造,优化了虚拟机对 LargeObjectSpace 的内存管理策略,间接增加其它内存空间使用上限。改造后的方案,32 位运行环境 LargeObjectSpace 的内存使用上限可达到 2G 甚至更多(64 位环境使用上限理论上会趋于无限大)。通过本方案可以最大程度上从系统侧解决诸多应用都会遇到的内存瓶颈和 OOM 问题,一键接入,安全可靠。
1.背景
Java OOM对于 Android 开发者来说并不陌生,随着应用愈发复杂,部分业务在设计之初为了更好的产品体验,往往会考虑用空间换时间,长此以往,有限的内存资源将会不堪重负,尤其是在一些重大活动期间,内存挑战会更加严峻,稍有不慎就会暴雷。
为了应对这种情况,开发同学往往会从应用层面进行优化,减少缓存,但随着产品持续迭代,内存问题始终如达摩克利斯之剑,让应用处于崩溃的边缘。好在 Android 系统维护人员也意识到了这种问题,从 Android O 系统开始,对 Java 内存管理策略进行了调整,重点包括实例化 Bitmap 对象时,Bitmap 源数据的内存不再通过虚拟机申请,而是直接在 Native 层申请和管理,因此这部分内存不会纳入虚拟机 Heap 内存统计,以此来减少 LargeSpace 的占用,进而间接增加了其他内存空间的实际使用范围(此消彼长的关系),如下示意图:
通过调整 Bitmap 的内存管理策略以减少虚拟机整体内存使用,的确带来了立竿见影的效果。以字节公司内部诸多 App 为例,Android O 之后的移动设备,Java OOM 远远低于早期的系统版本。
2.思考与破局
对于上述 Android 内存管理策略优化,可以看到其实只有 Android N 以上版本受益,但是市面上仍有大量的低版本设备需要关注,为了带给用户更好的体验,我们开始思考,既然在 Android O 系统上可以通过调整 Bitmap 内存管理策略,以降低 Java 内存触顶压力,那么针对 Android 低版本是不是也可以考虑通过同样的方式去转移内存统计策略呢?
经过一番探索,最终找到了我们想要的答案:在应用侧,我们实现了对虚拟机内存管理策略的改造,将 LOS(Large Object Space)的整个内存使用,从虚拟机的内存统计之中进行移除,以保障其它内存 Space 可以更大范围地申请内存。
3.背景知识介绍
在正式介绍该方案之前,有必要先来简单了解一下 Android 虚拟机的内存空间管理、Java 大对象管理以及内存申请流程,以便于我们更加清晰地理解该方案的设计思想。
3.1 内存空间分类
众所周知,系统在创建 Zygote 过程中会初始化虚拟机配置,其中一个便是设置 Heap 内存。默认 HeapMax 是 256M、512M,后续应用进程通过 Zygote 进程孵化时,都会继承该配置且无法修改。但对于虚拟机来说,为了更好地管理内存并提升分配和回收性能,并没有将所有 Java 对象全部放在一块空间进行管理,而是按照不同的场景属性划分成若干个内存空间,这些内存空间将会共享虚拟机 512M 最大内存,因此它们之间是一个此消彼长的关系,如下图:
同时虚拟机在 GC 过程中,可以将部分 Object 对象进行移动以降低内存碎片,因此根据内存对象是否支持移动分为可移动对象、不可移动对象;按照连续性分为连续性空间(ContinuousMemMapAllocSpace)和非连续空间(DiscontinuousSpace);最终每个子类的 Space 都继承至 Space 和 ContinuousSpace/DisContinuousSpace,从上而下的继承关系,如下图:
这些内存空间的实例化对象存储在虚拟机 Heap 对象中,申请内存时根据内存属性选择不同的内存空间进行分配和管理。
针对此文,我们重点关注的是大内存管理,以 LargeObjectMapSpace 为例,从上图可以看到它继承至非连续内存空间,对于非连续内存的管理要简单得多,简单总结如下:虚拟机默认把大于 3 个物理页(12K)的原子性对象或 String 类型的对象通过该空间进行管理,该空间所有的对象都存储在一个 Map 容器中,每次 GC 时遍历这些对象是否被引用,如果没有被引用则直接释放即可。由于这些大对象之间是离散的,因此不会造成内存碎片问题,在 GC 时也就无需进行拷贝压缩以释放连续空间。下面我们再简单介绍一下 LargeObjectSpace 的角色和工作方式。
3.2 大对象内存管理
Java 大对象的内存管理比较简单,主要集中在 LargeObjectSpace 模块,主要负责 Java 大对象的内存申请和释放。
3.2.1 内存申请
结合源码,接下来简单介绍一下 Java 大对象申请流程,整理流程图如下。在此过程中,重点关注 Heap 已申请内存大小的更新过程和内存不足时抛出 OOM 的过程。
在上图我们可以看到,在内存实际申请过程,会首先检测当前对象是否满足大对象,具体条件是:申请对象类型必须是原子类型或者 String 类型,并且要大于 3 个物理页。
inline bool Heap::ShouldAllocLargeObject(ObjPtr<mirror::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