【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

阿里开源 Patrons:大型 32 位 Android 应用稳定性提升 50% 的“黑科技”

  • 2021-08-13
  • 本文字数:5060 字

    阅读完需:约 17 分钟

阿里开源 Patrons:大型 32 位 Android 应用稳定性提升50%的“黑科技”

0x04 背景


截止到目前,国内的大部分 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 abort,也就是信号 6,典型的特征就是在 Crash 堆栈中可以发现地址空间的总和接近 4GB、Console log 中有大量的 (Out of memory)、malloc (xxxx) failed、returning null pointer 等等。随着业务的发展和时间的推移,该问题逐渐突出,已经成为稳定性保障中的拦路虎。


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


通常升级 64 位有两种办法:合包和拆包。其中合包会带来巨大的包大小压力,据 Google Play 调研:包大小每增加 6MB 会减少 1% 的下载。而拆包则需要一定的改造成本和维护成本。诚然终态一定是 64 位单架构,但无论是出于技术探索还是用户体验的考虑,都有必要研究一下这个问题,让 64 位升级可以在不牺牲包大小的前提下平稳过渡。本文将重点分享阿里开源用于缓解虚拟内存地址空间不足的“黑科技”—— Patrons,以及整个解决问题的探索过程,供大家参考。

0x08 探索过程


虚拟内存地址空间不足的课题在去年双十一前后被大家重视起来,大家纷纷下场投入了研究,也有很多阶段性结论,其中比较典型的观点有两种:


  1. Android 10 的内存分配器存在 bug 导致内存泄漏;

  2. Jemalloc5.1(Android10 的内存分配器) 的脏页释放条件相比前代版本进行了修改,导致内存延迟释放,最终使得虚拟内存的水位居高不下。


这里简单科普一下内存分配器:大多数情况下,我们写 Native 代码的时候,并不会直接调用内核的 API 去申请物理内存,而是使用 malloc 族函数进行内存申请。这时候返回的指针会指向虚拟内存中的地址空间,之后在这部分地址空间真的被使用的时候,才会发生缺页中断触发真实的物理内存分配,所以通常是两层分配结构。用户态代码申请的内存来自于内存分配器的二次分配,常见的内存分配器有 JeMalloc、TcMalloc、PtMalloc 等等。当然,我们在写业务的时候并不需要关心内存是哪个内存分配器分配的,Android 9、10 使用的内存分配器均为 JeMalloc,静态链接在 libc 中。Android 11 使用的是更加高性能、更加安全的 Scudo 分配器。


针对以上观点,我查阅了一些资料,针对 Jemalloc5.1 延迟释放的解决方案包括:


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

  2. dirty_decay_ms 设置为 0,可以修改 Jemalloc 的脏页释放方式。但是经过查阅 Android 10 源码,发现 Google 在 Android 10 中,已经将 dirty_decay_ms 设置为 0 了,可知并不存在脏页释放延迟的问题。



结合目前的资料来看,JeMalloc 可能并没有什么问题,但是在崩溃数据上展现出了一个奇怪的特征,就是 Top 1 的 crash 中,Android 10 和 11 的比例达到了 4:1 左右。难免会让人觉得这个问题是 Android 10 才突然出现的问题,Android 11 换成 Scudo 就好了。这个结论是否正确暂且按下不表,这里我做了第一次尝试:在 Android 10 中使用 Android 11 标配的 Scudo 分配器,而不使用 JeMalloc ,是不是就能解决这个问题了?

0x0c 尝试:移植 Scudo 到 Android 10


这个过程比较艰辛,主要是两部分工作:


  1. 把 Android 11 中的 Scudo 分配器源码拉出来,单独编译成一个动态库依赖到我们的应用;

  2. 通过一系列 Hook 手段,将应用的 Native 代码中涉及内存分配的函数,替换成 Scudo 分配器中的分配函数。


在实现这个方案的时候发现存在一些问题:


  1. 不只是 malloc 族函数会申请内存,strdup 和 strndup 这两个函数会在内部自己 malloc ,也要注意 Hook 掉;

  2. 安卓应用的 Native 内存申请不只是我们自己的 Native 代码,还有相当一部分是安卓自己的系统库,虽然 Hook 系统库不是不行,但是会存在下面的一个致命问题,在我们自行提供内存分配器的时候,会出现两种 case:


a. 使用系统 JeMalloc 申请的内存会尝试在我们自定义的分配器中释放:可以判断一下如果不是我们申请的内存,可以调用 libc 的 free 进行释放。如何来判断内存是不是我们的分配器申请的,大家可以学习一下 Scudo 的源码,写得非常精致;


b. 如果使用我们提供的分配器分配内存,再尝试使用系统的 JeMalloc 释放:无解,JeMalloc 不会考虑这种情况,你会得到一个信号 11,当然可以自己去处理 段错误,但这么费劲有点没必要了。


由于可能出现 2.b 这种情况,所以这种方案充满了不确定性,因为不能预料用户是怎么申请内存的,就无法从理论上证明可以 100% 覆盖所有内存操作,达不到上线标准。因此替换掉 JeMalloc 这条路行不通。

0x10 最终解决方案 Patrons


既然不能把 native 的内存分配到 art 虚拟机中去,那么干脆把这部分地址空间让出来好了。堵不如疏,不需要对 native 的内存分配进行任何修改和 hook,即可让 libcmalloc 使用这部分地址空间,可以极大缓解虚拟内存不足的问题。我们简单的回顾一下应用刚启动的时候,大致的内存布局:



提升 Android 稳定性的方案 Patrons,目前已经开源到 GitHub 上 :


https://github.com/alibaba/Patrons


这个方案的重点在于如何让 ART 把 Heap 预分配的地址空间释放出一部分


首先说一下为什么要压缩这部分地址空间。通常情况下,我们的应用都会开启 largeHeap 来获得更大的内存上限,因为默认配置下只有 192M。这是由参数 dalvik.vm.heapgrowthlimit 决定的,对于大部分阿里系应用来说显然是不够的。但是开启 largeHeap 之后,应用启动的时候就会申请 1GB 的地址空间。同样对于大部分应用来说,直到 abort 或者应用被杀死,都不会使用如此多的地址空间。


实际上我们需要的是可以动态调整堆的地址空间。要想操作这部分地址空间,自然要研究一下 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_


这里会遇到第一个问题,namespace 隔离机制,用以保证无法跨命名空间加载其他 so,可以通过阅读源码了解 Android 中 so 是怎么加载的。最终会使用到 libdl 的 __loader_dlopen,这个 dlopen 和我们常见的 dlopen 似乎有点区别,它多了一个参数,也就是 caller_addr,这是一个函数指针。再往下看就会发现它是通过拿到调用者之后去查表,找到 namespace,并和要加载的目标 so 的 namespace 进行对比,看看是否是互相可见的 namespace,来确定是否允许加载的。


这样一来想要破解这个隔离机制就易如反掌了,需要两样东西:


  1. 拿到包含 3 个参数的方法:__loader_dlopen 的指针;

  2. 拿到一个和目标 so 相同 namespace 的函数指针。


怎么拿到这个函数以及虚假的 caller_addr 不在这篇文章的讨论范围内,大家可以自行研究,核心思路是解析 ELF 即可(这里用到了 iqiyi 的开源项目 xhook:生产级的 PLT Hook 方案)。通过同样的套路,就可以破解 dlsym 了,也就能够完成 libart.so 的加载。


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



到此为止,调整 Region Space 所需要的前提都已经具备了,接下来就是通过找到 region_space_ 相对 heap_ 的偏移,一环套一环找到所有需要的条件。


可能有同学会问,为什么没有提到怎么调整 region_space_ 呢?其实也非常简单,可以通过 RegionSpace 的实例方法:RegionSpace::ClampGrowthLimit(size_t new_capacity) 来对 region_space_ 进行调整,大家可以自行查阅源码,逻辑比较简单,因为 Region Space 管理的内存是线性分配的,只要是还没用到 region 都是可以释放掉的。所以前面一系列的准备,就是为了能调用到这个方法。也就是说,在 >= 9 的 Android 版本中,找到 runtime_、heap_、region_space_ 之后,直接调用 ClampGrowthLimit 就可以调整 Region Space 大小了。在 <= 8 的版本中并没有这个方法,不过这也难不倒大家,自己参照 Android 9 中的源码实现一下就好了,没有太大的难度。



可能还有同学会问:如果依赖偏移查找会不会需要做大量适配工作?就实际情况来看,国内厂商对 ART 内存管理部分的修改是比较小的,参照 aosp 的版本就能做的差不多了。因为本来也不需要保证 100% 都能对的上,对不上就对不上好了,自己做好校验和保护,通过 longjump 等方式做一个 try catch 即可。


这里简单的提一下如何对前述通过偏移量拿到的各个实例进行校验:因为 Region Space 是按 Region 管理内存分配的,所以有以下公式:


num_regions_ * kRegionSize = *limit_ - *begin_;


剩下的就比较简单了,上层业务做一个定时轮询,设定一个阈值,每当虚拟内存达到 n% 的时候,就压缩一下 Region Space ,设置好步长,保证好下限即可。把以上逻辑封装起来,辅以相关的检查和调度,就是 Patrons 的全部内容了。

0x14 Patrons 实战效果


Patrons 方案在阿里集团内部推出之后,某航旅类应用首先完成了接入,新版本上线后,Native Crash 率下降了 80%。后续其他头部应用也陆续完成了接入,某电商类应用 Native Crash 下降了 78.6%。经过一段时间的统计,接入 Patrons 的集团头部应用,Native Crash 平均下降了接近 50%,大幅度提升了应用的稳定性,让客户用起来更加稳定放心。

0x18 附录 1:使用方法


工程中添加 gradle 依赖:


# build.gradle


implementation “com.alibaba:patrons:1.0.6.3”


# java


int code = Patrons.init(null); // 非 0 则初始化失败,可以埋点上报便于统计


默认情况下只需要在内存不足之前进行 Patrons 的初始化即可,兼容 Android 8、8.1、9、10、11,不兼容版本中不会生效,也没有副作用。

0x1c 附录 2:我的应用是否可以接入?


Patrons 方案已经开源到阿里集团的 GitHub Group 下,应用 MIT 授权协议,遵照该协议即可使用。


但是需要注意的是,该方案仅对大型 32 位 Android 应用有效,因为中小型应用因为业务规模有限,一般不会遇到虚拟内存不足的问题。当然这是建立在没有 or 较少内存泄漏的前提下,由于大量内存泄漏导致的虚拟内存不足不在本文讨论范围内。


作者介绍:


刘志龙,花名正纬,阿里巴巴高级无线开发专家,手机天猫端侧交易链路负责人。


2015 年毕业于哈尔滨理工大学,同年加入阿里云,负责手机阿里云从 0 到 1 建设,以及部分 Emas 移动服务相关业务。2019 年来到手机天猫,负责手猫端侧详情、交易链路,以及 Android 基础架构、端侧动态化搭建平台等相关技术创新、服务于千万天猫用户。打造并开源了 Android 组件化通信方案 ARouter、Android Native 稳定性保障方案 Patrons,在国内 Android 领域有着广泛的应用。

2021-08-13 15:207965

评论

发布
暂无评论
发现更多内容

使用Local Persistent Volume 部署有状态工作负载

华为云开发者联盟

Kubernetes 开发 华为云 华为云开发者联盟

从前端角度浅谈性能 | 京东物流技术团队

京东科技开发者

如何自定义Safari的起始页

Rose

用二维码介绍产品详情,扫码查看图文并茂的宣传册

草料二维码

ElasticSearch降本增效常见的方法 | 京东云技术团队

京东科技开发者

Prometheus配置Basic Auth进行安全防护,实现登录控制

华为云开发者联盟

开发 华为云 华为云开发者联盟

京东ES支持ZSTD压缩算法上线了:高性能,低成本 | 京东云技术团队

京东科技开发者

让 K8s 更简单!8款你不得不知的 AI 工具-Part 1

SEAL安全

开源 AI Kubernetes

奇遇未来新动态!AIGC产品经理教学终于来了!

Geek_2d6073

“无法打开应用,因为Apple无法检查其是否包含恶意软件“解决方法

Rose

什么是互动式数字人?

青否数字人

数字人

APP加固原理与作用

AI遇上传统文化,文心一言上央视带来跨时空访古体验

飞桨PaddlePaddle

人工智能 深度学习

Parallels Desktop 17 安装Windows 11 教程 附激活工具

Rose

替代关系型数据库 MAX 聚合函数的思路

alexgaoyh

MySQL 替代 聚合函数 最新数据 自关联

苹果电脑应用程序无法打开提示不明开发者或文件损坏的处理方法

Rose

JavaScript面向对象之实践项目

伤感汤姆布利柏

面试官:如何保证本地缓存的一致性?

王磊

Java 面试

解决苹果无线鼠标、键盘或触控板无法被 Mac 识别的方法

Rose

软件测试/测试开发/全日制/测试管理丨Appium Inspector

测试人

软件测试

FCPX 插件无法使用?|Final Cut Pro X 插件不能使用出现叹号的解决办法

Rose

使用数字人系统源码进行实时互动直播!

青否数字人

数字人

第三方 Cookie 被禁用?企业该如何实现用户精准运营和管理

Authing

Cookie Authing 用户运营

Acrobat Pro DC 2023如何插入附件?Acrobat Pro DC添加附件方法

Rose

苹果电脑Mac教程:如何开启任何来源选项

Rose

【年后跳槽必看篇-非广告】老生常态之Spring AOP/IOC 实现原理

派大星

Java 面试 跳槽

KaiwuDB × 风电企业 | 高性能、低成本、释放数据价值

KaiwuDB

数据库 解决方案

苹果电脑重装系统教程

Rose

数字人的未来发展?

青否数字人

数字人

我想做一款软件产品,应该怎么做?

天津汇柏科技有限公司

创新创业 产品原型设计软件

ByConity 社区回顾|ByConity 和开发者们一起展望未来,携手共进!

字节跳动数据平台

开源数据库 ByConity

阿里开源 Patrons:大型 32 位 Android 应用稳定性提升50%的“黑科技”_语言 & 开发_刘志龙_InfoQ精选文章