大型32位Android应用存在一个众所周知的问题及解决办法

图片[1]-大型32位Android应用存在一个众所周知的问题及解决办法-唐朝资源网

作者 |刘志龙(花名正味)

阿里巴巴高级无线开发专家

10×04 背景

截至目前,国内大部分Android应用仍为32位架构,其特点是仅提供armeabi/armeabi-v7a架构的动态库。 Android系统在启动这样的应用程序时,会使用32位的Zygote进程来孵化应用程序,让整个应用程序运行在32位兼容模式下。虽然 Android 早在 5.0 版本就已经支持 64 位 CPU,但多年来国内大部分应用仍以 32 位兼容模式运行。早在 2019 年 1 月,Google Play 就开始强制要求开发者上传包含 64 位架构支持的应用,确保应用在 64 位模式下运行。国内主要应用市场也纷纷出台要求,希望2021年底国内应用能提供双架构版本。

大规模的32位Android应用有个众所周知的问题,就是虚拟内存寻址的上限只有2^32=4GB,而国内各个厂商的头部应用一般都有各种功能,包括各种容器、框架、SDK以及多媒体能力等,应用启动后内存级别保持高位。在用户高强度使用下,会出现虚拟内存不足触发的crash(libc:abort)。根据网上观察到的Crash情况,可以发现Native Crash的Top 10中有大量的libc aborts,也就是信号6。典型的特点是总地址空间可以在Crash中找到堆。 (Out of memory)、malloc(xxxx)失败、返回空指针等。随着业务的发展和时间的推移,这个问题逐渐凸显出来,成为了稳定性保障的绊脚石。

要解决这个问题,关键是要解决虚拟内存不足的问题,而64位应用程序的虚拟内存地址空间上限是2^39=512GB,所以解决这个问题的唯一办法就是升级到 64 位,因为 64 位带来的巨大地址空间,一般除非有 bug 到不了顶端。

升级到 64 位一般有两种方法:捆绑和取消捆绑。其中,联包将给封装尺寸带来巨大压力。根据 Google Play 的研究:包大小每增加 6MB,下载量就会减少 1%。拆包需要一定的改造成本和维护成本。诚然,最终状态一定是64位单架构,但无论是技术探索还是用户体验考虑安卓应用市场网站源码,都需要研究这个问题,让64位升级能够顺利过渡,而不牺牲包大小。本文将重点分享Patrons,阿里巴巴开源缓解虚拟内存地址空间不足的“黑科技”,以及解决问题的整个探索过程,供大家参考。

20×08 发现过程

虚拟内存地址空间不足的问题在去年双十一前后受到大家的重视,大家都投入研究,有很多阶段性的结论,其中典型的观点有两种:

Android 10 的内存分配器存在导致内存泄漏的错误;

Jemalloc5.1(Android 10的内存分配器)相比上一版本修改了脏页释放条件,导致内存释放延迟,最终导致虚拟内存水位居高不下。

这里简单介绍一下内存分配器:在大多数情况下,我们在编写Native代码时,并不是直接调用内核的API来申请物理内存,而是使用malloc家族的函数来申请内存。这时返回的指针会指向虚拟内存中的地址空间。那么,当这部分地址空间真正被使用时,缺页中断会触发真正的物理内存分配,所以通常是两层分配结构。用户模式代码请求的内存来自内存分配器的二次分配。常见的内存分配器有 JeMalloc、TcMalloc、PtMalloc 等。当然,我们在写业务的时候不需要关心是哪个内存分配器分配内存的。 Android9、10使用的内存分配器都是JeMalloc,在libc中是静态链接的。 Android 11 使用更高效、更安全的 Scudo 分配器。

针对以上几点,我查阅了一些资料,Jemalloc延迟发布的解决方案5.1包括:

编译时修改编译参数(不可行,无法确定用户手机中Android系统的编译参数);

设置dirty_decay_ms为0,修改Jemalloc的脏页释放方式。不过查了Android 10源码后发现,谷歌在Android 10中已经将dirty_decay_ms设置为0,可见不存在脏页释放延迟的问题。

图片[2]-大型32位Android应用存在一个众所周知的问题及解决办法-唐朝资源网

从目前的数据来看,JeMalloc 可能没有任何问题,但是它在崩溃数据中表现出一个奇怪的特征,那就是在 Top 1 崩溃中,Android 10 和 11 的比例达到了 4:1 左右。难免让人觉得这个问题是Android 10突然出现的问题,Android 11可以换成Scudo了。这个结论是否正确,暂不列出。这里我做了第一次尝试:在Android 10中使用Android 11标配的Scudo分配器代替JeMalloc,这个问题能解决吗?

30x0c 尝试:将 Scudo 移植到 Android 10

这个过程比较难,主要分为两部分:

将Android 11中Scudo分配器的源码拉出来,编译成动态库依赖于我们的应用;

通过一系列Hook方法,将应用程序native代码中涉及内存分配的函数替换为Scudo分配器中的分配函数。

在实施这个方案时存在一些问题:

不仅malloc族函数会申请内存,strdup和strndup这两个函数在内部都会自己malloc,还要注意Hook他们;

Android应用程序的原生内存申请,不仅仅是我们自己的原生代码,也是Android自带的系统库的相当一部分。虽然 Hook 系统库也不是不可能,但是会出现以下致命问题。我们自己提供内存分配。安装设备时会出现两种情况:

一个。系统JeMalloc申请的内存会在我们自定义的allocator中尝试释放:可以判断如果不是我们申请的内存,可以调用libc的free释放。如何判断我们的分配器是否申请了内存,可以学习Scudo的源码,写的很好;湾。如果你使用我们提供的allocator来分配内存,那么尝试使用系统的JeMalloc来释放:没办法,JeMalloc不要考虑这个,你会得到一个信号11,当然你可以处理segfault你自己,但它有点不必要。

由于2.b的可能性,这个方案充满了不确定性,因为无法预测用户如何申请内存,也无法从理论上证明100%的内存操作都可以覆盖。达不到网上标准。所以替换 JeMalloc 是行不通的。

40×10 最终解决方案赞助人

由于本机内存无法分配给art虚拟机,所以最好让这部分地址空间出去。阻塞不如稀疏,而libcmalloc可以使用这部分地址空间而无需对本机内存分配进行任何修改和挂钩,可以大大缓解虚拟内存不足的问题。让我们简单回顾一下应用刚启动时的一般内存布局:

图片[3]-大型32位Android应用存在一个众所周知的问题及解决办法-唐朝资源网

Patrons,一个提高Android稳定性的解决方案,已经开源到GitHub:,点击阅读文末原文跳转。

本方案的重点是如何让ART释放Heap预分配的部分地址空间。

首先说一下为什么这部分地址空间会被压缩。通常,我们的应用会开启 largeHeap 以获得更大的内存限制,因为默认配置只有 192M。这是由参数dalvik.vm.heapgrowthlimit决定的,这对于大部分阿里应用来说显然是不够的。但是启用 largeHeap 后安卓应用市场网站源码,应用启动时会申请 1GB 的地址空间。同样对于大多数应用程序来说,在中止或终止应用程序之前不会使用那么多地址空间。

我们真正需要的是一个可以动态调整堆的地址空间。要操作这部分地址空间,自然要研究一下Android是如何管理这部分地址空间的。如前所述,Android其实有多种地址空间管理方式。目前Android 8(90%+用户)及以上版本都是通过Region Space。详情可自行查看Android源码。

那么问题就清楚了:如何动态调整Region Space的大小?这里有一些先决条件需要了解。 Region Space之所以一启动就占用地址空间,显然是通过mmap获得的,分配结果自然会保存在一个成员变量中。如何找到它?还在看源码找到答案:region_space_存放在Heap实例中,而Heap实例存放在Runtime中的heap_中,这些字段最终编译在libart.so中,所以要操作这些,前提是至少libart可以手动加载.so。最后需要形成如下依赖:

libart ——> runtime_ ——> heap_ ——> region_space_

这里会遇到第一个问题,命名空间隔离机制保证其他so不能跨命名空间加载。您可以阅读源代码以了解如何在 Android 中加载 so。最后,将使用 libdl 的 __loader_dlopen。这个 dlopen 似乎和我们常见的 dlopen 有点不同。它多了一个参数,即caller_addr,它是一个函数指针。再往下看,你会发现它获取到调用者后查表,找到命名空间,然后和目标的命名空间进行比较以便被加载,看是否是相互可见的命名空间,判断是否允许加载。

这样很容易打破隔离机制,需要做两件事:

获取一个有3个参数的方法:__loader_dlopen指针;

获取与目标so具有相同命名空间的函数指针。

这个函数的获取方法和伪造的caller_addr不在本文讨论范围内。你可以自己研究。核心思想是解析ELF(这里使用爱奇艺的开源项目xhook:生产级PLT Hook解决方案)。通过同样的套路,可以破解dlsym,加载libart.so。

第二个问题是如何找到每个属性相对于实例的偏移量?我们以求region_space_相对于heap_的偏移量为例:首先,我们需要找到Heap的一个实例方法,该方法需要包含对region_space_的操作,比如函数art::gc::Heap::TrimSpaces() ,因为这是一个实例方法,所以r0就是实例本身(heap_),那么在反编译libart.so后,观察这个TrimSpace方法,我们可以发现region_space_用在了连续三个if的最后一个if的条件下中,那么我们得到region_space_相对于r0的偏移量,也就是heap_:

图片[4]-大型32位Android应用存在一个众所周知的问题及解决办法-唐朝资源网

至此,调整Region Space的前提条件已经满足。下一步是通过查找 region_space_ 相对于 heap_ 的偏移量来找到所有必要条件。

可能有同学会问,为什么没有提到如何调整region_space_?其实也很简单。您可以通过RegionSpace的实例方法来调整region_space_:RegionSpace::ClampGrowthLimit(size_t new_capacity)。您可以自行查看源代码。逻辑比较简单,因为Region Space管理的内存是线性分配的,只要还是没有用到的Region都可以释放。所以前面的一系列准备都是为了能调用这个方法。也就是说,Android版本>=9,找到runtime_、heap_、region_space_后,直接调用ClampGrowthLimit来调整Region Space的大小。在

© 版权声明
THE END
喜欢就支持一下吧
点赞246 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片