摘要
Raphael [1]是西瓜视频基础技术团队开发的一款 native 内存泄漏检测工具,广泛用于字节跳动旗下各大 App 的 native 内存泄漏治理,收益显著。工具现已开源,本文将通过原理、方案和实践来剖析 Raphael 的相关细节。
背景
Android 平台上的内存问题一直是性能优化和稳定性治理的焦点和痛点,Java 堆内存因为有比较成熟的工具和方法论,加上 hprof 快照作为补充,定位和治理都很方便。而 native 内存问题一直缺乏稳定、高效的工具,仅有的 malloc debug [6]不仅性能和稳定性难以满足需要,还存在 Android 版本兼容的问题。
现状
事实上,native 内存泄漏治理一直不乏优秀的工具,已知的可用于调查 native 内存泄漏问题的工具主要有:LeakTracer、MTrace、MemWatch、Valgrind-memcheck、TCMalloc、LeakSanitizer 等。但由于 Android 平台的特殊性,这些工具要么不兼容,要么接入成本过高,很难在 Android 平台上落地。这些工具的原理基本都是:先代理内存分配/释放相关的函数(如:malloc/calloc/realloc/memalign/free),再通过 unwind 回溯调用堆栈,最后借助缓存管理过滤出未释放的内存分配记录。因此,这些工具的主要差异也就体现在代理实现、栈回溯和缓存管理三个方面。根据这些工具代理实现的差异,大致可以分为 hook 和 LD_PRELOAD 两大类,典型的如 malloc debug [5] 和 LeakTracer。
malloc debug
malloc debug 是 Android 系统自带的内存调试工具(官方 Native 内存调试 有相关介绍 ) ,虽然没有额外的接入代码,但开启方式和核心功能等都受 Android 版本限制。
我们在线下尝试使用 malloc debug 监控西瓜视频 App(配置 wrap.sh)时发现,正常启动时间小于 1s 的机型(Pixel 2 & Android 10),其冷启动时间被拉长到了 11s+。而且在正常使用过程中滑动时的卡顿感非常明显,页面切换时耗时难以接受,监控过程中应用的使用体验极差。不仅如此,西瓜视频在 malloc debug 监控过程中还会遇到必现的栈回溯 crash(堆栈如下,《libunwind llvm 编年史》[8] 有相关分析)。
LeakTracer
LeakTracer 是另一个比较知名的内存泄漏监控工具,其原理是:通过 LD_PRELOAD 机制抢先加载一个定义了 malloc/calloc/realloc/memalign/free 等同名函数的代理库,这样就全局代理了应用层内存的分配和释放,通过 unwind 回溯调用栈并过滤出疑似的内存泄漏信息。Android 平台上的 LD_PRELOAD 是被严格限制的,因为其没有独立的 unwind 实现,依赖系统的 unwind 能力,也会遇到 malloc debug 遇到的栈帧兼容问题;如果把 LeakTracer 集成到目标 so 里通过 override 方式实现代理,只能拦截到本 so 里显式的内存分配/释放,无法拦截到其他 so 和跨 so 调用的内存分配/释放。通过 native 插桩的方式也是如此,只能监控局部单纯的内存泄漏,无法全局监控内存使用。
综合以上分析和接入体验,我们不难发现,这些内存泄漏监控工具在 Android 平台上实际接入时基本都存在以下三个比较典型的问题:
流程繁琐:需要配置 wrap.sh/root permission/setprop 等,受 Android 版本限制
兼容问题:unwind 库存在严重的兼容性问题,libunwind_llvm 无法正确回溯 GNU 编译的栈帧
性能问题:官方的 malloc debug 性能数据是损失 10 倍以上,实测西瓜开启后在中高端机上不可用
我们的需求
西瓜视频 App 是一个汇集了视频播放、特效拍摄、视频剪辑辑、P2P 加速等 native 代码非常多的中大型应用,每个 native 代码相关的模块背后都有一个专业团队在高速迭代,加上日人均使用时长超过 100 分钟的影响,西瓜视频 App 的 native 内存问题治理难度非常大。事实上,单纯的内存泄漏问题相对较少,更多的是因为业务逻辑不合理带来的内存使用问题,需要工具渗透到 App 运行的过程中进行监控,无形中提高了对工具性能和稳定性的要求。
线上 native 内存问题基本都是以虚拟内存触顶的形式暴露出来的。在西瓜视频 App 里,虚拟内存的消耗除了上述几大模块外,还有其他几个消耗大户,如线程、webview、Flutter、硬件加速、显存等。事实上,malloc/calloc/realloc/memalign 等相对于 mmap/mmap64 直接分配出的内存在整个虚拟内存空间中通常占比比较小。因为内存问题通常以虚拟内存耗尽的形式表现出来,只有尽可能多的收集各种内存消耗来无限逼近虚拟内存上限,才能准确找出虚拟内存耗尽的原因。因此,像 malloc debug 这样只监控 malloc/calloc/realloc/memalign/free 等根本无法满足内存治理需要,覆盖 mmap/mmap64/munmap 等尽可能多的内存分配形式是监控工具必须要做的。
综合上面的分析可以得出,西瓜视频 App 乃至整个字节跳动旗下其他 App, 对于一个通用的 native 内存泄漏监控工具的诉求主要有以下几个方面:
接入层面:不依赖 Android 版本,无需 root,对业务渗透尽可能低
稳定性:不存在影响业务的稳定性问题,可以满足线上使用的诉求
性能层面:没有明显的性能问题,达到可线上使用的标准
监控范围:不局限于 malloc/calloc/realloc/memalign/free,至少还能覆盖 mmap/mmap64/munmap
Raphael 核心设计
通过前面的分析可以知道,一个完整的 native 内存泄漏监控工具主要包含三部分:代理实现、栈回溯和缓存管理。代理实现是解决 Android 平台上接入问题的关键,栈回溯是性能和稳定性的核心,缓存逻辑在一定程度上也会直接影响性能和稳定性。接下来我们会从四个方面介绍 Raphael 的核心设计。
代理实现
鉴于 wrap.sh 和 LD_PRELOAD 在 Android 平台上不具有通用性,首先被排除。又因 malloc hook 只能代理 malloc/calloc/realloc/free,无法覆盖 mmap/mmap64/munmap,也被放弃。但受 malloc hook 实现方式的启发,借助于 inline hook / PLT hook 工具我们可以实现同样的代理效果,这其中比较有代表性的工具主要有 Android-Inline-Hook[3] 和 xHook[1]。
xHook 是比较优秀的 PLT hook 工具代表,其稳定性可以达到上线标准。因其实现依赖正则,同时 hook 的 so 或函数比较多时,hook 耗时会比较明显。此外,其原生实现只能 hook 当前已经加载的 so,对于未加载的没做特殊处理,如果用来做长时间的进程级监控,需要解决增量 so hook 问题。不过这种 hook 方式非常适合做 so 定向监控。
与 PLT hook 原理不同,inline hook 则是在目标函数的头部直接插入跳转指令,其 hook 的是最终的函数实现,不存在增量 so hook 问题,hook 效率高效直接。但 inline hook 在 hook 那些可能正在执行的函数后,需要挂起相关线程进行指令修正,这个是 inline hook 的痛点,现有 hook 实现很多没有做指令修复,或者在指令修复时或多或少都存在一些问题。
Raphael 在早期的验证版本里采用 xHook 来实现代理接入。后续为了实现长时间进程级监控,以覆盖更多的业务场景,Raphael 又通过 Android-Inline-Hook 解决增量 so hook 问题,通过 xHook 实现定向监控。为了进一步提升工具的性能和稳定性, Raphael 内部最新版本已切换到了 bytehook(字节跳动自研的 PLT hook 工具,可自动处理增量 so hook 问题)。
栈回溯
定位一个对象或者一段内存通常可以通过引用/依赖关系,也可以通过创建/分配时的堆栈。Java 堆内存因为有明确的组织形式和清晰的依赖关系,可以通过依赖关系静态分析内存泄漏问题。但 native 堆内存依赖/引用比较隐晦,也没有 Java 堆内存那样明确的组织格式,无法通过依赖/引用关系进行静态分析,只能通过分配时的堆栈来辅助定位。栈回溯(unwind)是 native 层获取调用堆栈的通用方式,是 native 内存泄漏监控工具不可或缺的核心,同时也是工具性能和稳定性的瓶颈所在。接下来本文将从栈回溯工具选取、限制栈回溯频次、减少无用栈回溯三个方面介绍 Raphael 在栈回溯上所做的工作。
栈回溯工具选取
Android 平台上常用的 32 位栈回溯库主要有:libunwind_llvm、libunwind (nongnu)、libgcc_s、libudf、libbacktrace、libunwindstack 等,实践证实这些工具或多或少都存在一些问题,以下是我们基于三个主流的栈回溯库做的简单对比分析(平台:Pixel 2 & Android 10,性能:Demo 里统计 16 层栈帧回溯的总耗时;兼容性:字节跳动旗下多个应用长时间的优化治理实践)
栈回溯涉及到的东西比较多,想要自己短时间内实现一个在稳定性、回溯性能、回溯成功率等方面都表现优异的 32 位栈回溯工具难度非常大。为了快速验证并解决实际机问题,Raphael 在早期版本里采用的是 libunwind_llvm,随后切换到 libunwind_llvm & libunwind (nongnu),通过 libunwind_llvm 保证回溯性能,在回溯深度低于 2 层时切换到 libunwind (nongnu),以保证回溯成功率。最新版本里则采用的是 libudf,兼具了性能和回溯成功率。相对而言,64 位下基于 FP 的栈回溯实现性能和稳定性基本都能满足需求,这里不做过多介绍。Rapahel 同时也在设计时做了充分的扩展考虑,可以轻松切换到其他更优秀的栈回溯实现。
限制栈回溯频次
即便是 libudf 实现,其在 demo 里回溯 16 层栈帧的平均耗时也需要 0.6ms,监控工具实际运行时对 App 性能的影响是很明显的。提升监控性能的途径除了直接优化栈回溯性能外,减少回溯频次也是十分有效的手段。我们在西瓜视频 App 的优化治理实践中发现,多数场景小于 1024 byte 的内存分配其频率约占 70% 以上,但线上遇到的 native 内存触顶问题,却很少是由小内存泄漏引发的,监控小内存泄漏对于解决线上 native 内存触顶问题没有实质效果。即便真的是由小内存引发的,这个需要高频和必现的场景才能达到,这类问题通常在线下单测(定向监控)场景是完全可以覆盖到的。基于此,Raphael 通过设定内存阈值来控制栈回溯频次,可以大幅降低栈回溯的性能损耗。
减少无用栈回溯
受限于代理流程和栈回溯实现机制,从代理函数入口到回溯开始的路径上会存在几层跟分配堆栈无关的函数调用,这几层调用最终会体现在最后回溯成功的堆栈上(下图的红色部分),每次内存分配都回溯这几层无用的调用链是十分损耗性能的。解决这种问题的直观方法就是减少甚至完全规避这种无关的栈回溯,体现在代码层面就是减少代理入口到回溯开启函数之间的调用层级。inline 是一种简单直接的实现方式,也可以直接在代理入口处提前构建回溯的 context 数据。
缓存管理
缓存管理作为 native 内存监控的重要一环,对整个监控工具性能的影响至关重要。以 malloc debug 和 LeakTracer 为例,它们都是通过分配后的内存地址作为 key 来计算 hash 后散列存储的,并通过一个全局锁来同步缓存更新的时序。两者不同的是,malloc debug 会通过堆栈聚合调用链完全相同的内存分配记录,其缓存的存储单元通过 malloc 动态分配;而 LeakTracer 则不会根据堆栈聚合,其存储单元会预先分配一部分,缓存不足时也会动态申请。通过以上分析和实测可以发现,malloc debug 的实际性能比 LeakTracer 低很多,原因主要体现在堆栈聚合和缓存动态分配上。
对比 malloc debug 和 LeakTracer 的源码也可以发现:运行时的堆栈聚合是完全没有必要的;如果限制内存监控的阈值,缓存空间和缓存单元的上限都可以控制在一定范围内的,不需要动态申请,可以减少动态分配的性能损耗;此外,由于 native 内存分配和释放频率比较高,全局锁一定程序上会影响整体性能,通过 key 计算 hash 后再散列存储时不需要全局锁。
Raphael 是预先分配固定大小的缓存空间,除了发生内存触顶导致的 crash 外,缓存单元提前耗完也认为存在内存泄漏问题。这主要是因为:对于 32 位进程,其虚拟内存的上限通常是 4G,正常运行时相对比较容易触达上限,而 64 位进程的虚拟地址空间非常大,实际很难遇到虚拟内存触顶的 case,但遇到物理内存不足的概率则要大很多,这与 32 位进程基本相反。通过控制 vmPeak 阈值和缓存单元余量可以有效捕捉到内存泄漏数据,最终实现稳定可靠的全自动内存泄漏监控及消费流程
监控范围
通过前面的分析可以知道,只监控 malloc/calloc/realloc/memalign/free 是无法满足治理需求的,这主要是因为 malloc/calloc/realloc/memalign/free 等分配出的内存通常在整个虚拟内存空间里占比较小,常见的内存消耗大户 Thread、webview、Flutter、硬件加速、显存等,都不是通过这些函数分配出的。为了能够对 Android 平台上的 native 内存触顶问题精准归因,监控需要无限逼近虚拟内存的上限,这就需要监控尽可能多的内存分配形式。
Android 上的内存操作主要是 malloc/calloc/realloc/memalign/free 和 mmap/mmap64/munmap,同监控 malloc/calloc/realloc/memalign/free 相比,监控 mmap/mmap64/munmap 有两点不同:一个是线程栈的释放问题,虽然创建线程时是通过 mmap/mmap64 分配的栈内存,但栈内存的释放并不一定是通过显式调用 munmap 实现的;另一个是监控重入问题,当通过 malloc/calloc/realloc/memalign 等分配大内存时,底层通常是通过 mmap/mmap64 实现的,两类接口同时监控时会存在重入问题。
栈内存释放
线程的栈内存又分为信号栈和执行栈,信号栈在调用 void pthread_exit(void *return_value) 接口时会通过 munmap 即刻释放,而执行栈的释放则有两种形式:
void pthread_exit(void return_value) 函数体里,当线程状态为 THREAD_DETACHED 时会直接通过 void _exit_with_stack_teardown(voidstack, size_t sz) 释放
int pthread_join(pthread_t t, void** return_value) 里通过 pthread_internal_remove_and_free,最终在 pthread_internal_free 里通过 munmap 释放
综上,最终通过 munmap 释放的内存都可以被监控到,而通过_exit_with_stack_teardown 释放的内存则无法拦截到。我们针对这种情况做了特殊处理:在 Raphael 里代理拦截了 void pthread_exit(void *) ,并判断此时线程状态是否为 THREAD_DETACHED,如果是则在监控里直接移除相关记录,否则不移除。
重入问题
下图是一个典型的重入现场,其上层的 malloc 函数最终调用到了 mmap 函数,同时监控两类内存接口时就会遇到此类问题。重入问题带来的一个挑战是缓存如何管理,同一个缓存里只能维护一个记录,维护两个记录的逻辑和性能过于复杂。此外,从 malloc 到 mmap 的堆栈是固定的,这几层堆栈对分析内存泄漏完全没用,因为这个时候关注的是 malloc 之上的堆栈。
解决重入问题的方案很直接,在检测到 mmap/mmap64 之上有 malloc/calloc/realloc 等栈帧时,忽略本次分配。这样不仅解决了重入问题,也避免了不必要的栈回溯。因为 Android 平台不支持 thread local storage(TLS),只能通过 pthread_setspecific 和 pthread_getspecific 实现。
综合评估
功能
相对于 malloc debug 和 LeakTracer,Raphael 不仅支持 malloc/calloc/realloc/memalign/free,也支持监控 mmap/mmap64/munmap 等,使监控范围扩展到了线程、webview、Flutter、显存等,基本完全覆盖了 Android 平台上的 native 内存使用场景。
性能
Android 平台上的 native 内存泄漏检测通常都是在程序运行过程中进行的,栈回溯和缓存管理会消耗部分 CPU 和内存,带来一定的性能损失。Raphael 可配置的监控能力有很大的伸缩性,性能影响可以限制在可接受范围内,以下数据基于西瓜视频 App 32 位模式评测(中高端机型和 64 位下的性能更高):
CPU:32 位模式 & ≥1024 的监控阈值下,在低端机上 CPU 消耗< 3%
内存:32 位模式下默认会有约 16M 的虚拟内存消耗
帧率:32 位模式 & ≥1024 的监控阈值下,低端机上帧率没有明显变化
稳定性
已开源的版本是基于开源 inline hook 实现的,在部分 Android 6 机型上存在卡死问题,除此之外暂未发现其他稳定性问题。此外,字节跳动这边早期的治理实践集中在线下,并基于 Raphael 建设完善了线下的防治体系,更为稳定的版本可以满足线上的监控需求,我们会在后续迭代开源。
治理实践
Raphael 在字节跳动内部使用非常广泛,是字节跳动 native 协会指定的 native 内存泄漏检测工具。在治理实践中,Raphael 覆盖了几乎所有的 native 内存使用场景,辅助解决了大量的 native 内存泄漏和内存使用不合理的问题。接下来通过四个典型的案例简单介绍下 Raphael 的监控能力和基于 Raphael 的数据分析方法(应用自身的,Java 层的,webview 的,系统层的)。
案例 1
下图是西瓜视频里两个比较典型的 native 内存问题现场,既有严格意义上的内存泄漏(用完之后未释放),也有更为广泛的内存不合理使用的问题(短暂泄漏、局部场景问题、上层业务逻辑问题等)。针对内存泄漏问题,在明确了相关内存的生命周期之后,可以相对轻松的快速定位到。对于内存使用不合理的问题,则需要尽可能多的搜集未释放的内存,来综合评估影响。
早期在分析数据时,我们也会通过 maps 来验证 Raphael 的数据。通常通过分析 maps 可以大致知道内存触顶的原因,下图是一个典型的运行时通过 malloc/calloc/realloc/memalign 和 mmap/mmap64 分配的内存过多导致的 OOM 现场。
案例 2
下图是字节跳动内部一个业务遇到的 native 内存问题现场,未接入 Raphael 前虽能轻松复现 native 内存增长的问题,但无法定位内存增长的原因。在接入 Raphael 后,虽然拦截到的内存并不多,但问题暴露的非常明显。排名第一个的堆栈是 Java 层创建 bitmap 对象时调用到 native 层堆栈(Android 8 以后 Bitmap 的数据是存储在 native 层),该问题的调查最终转移到了 Java 层。
基于以上分析,我们可以断定 Java 层的堆内存里一定存在大量的 Bitmap 对象。因为该问题是线下可复现的,我们可以很容易的通过 Java 堆内存快照验证并定位到问题原因(如下图所示)。如果是线上,我们需要抓取异常现场的快照才能最终定位,这也正是 西瓜视频稳定性治理体系建设一:Tailor 原理及实践 里所提到的通用异常数据搜集建设。
案例 3
一直以来 Android 设备上 webview 消耗的内存很少被重视,随着前端业务场景增多,webview 导致的内存问题也越来越明显、越来越频繁。下图是 Raphael 在西瓜视频 App 里监控到的一个前端活动页导致的内存问题现场。由于系统 webview 自身的原因,工具无法回溯出完整的调用栈,无法直观定位到问题原因。最终我们通过定向分析内存数据,定位到这些内存基本都是前端页面里缓存的图片资源,在对该页面的图片缓存策略进行优化之后,相关的内存触顶的异常大幅降低。
案例 4
下图是 Android 系统上长期存在的一类 Camera 内存泄漏现场。通过分析源码可知,Camera 在拍摄过程中会在 native 层持续构造 CameraMetadata 实例,而每个 CameraMetadata 对象都会指向一块不小的 native 内存,这块 native 内存的释放依赖 Java 层的 CameraMetadataNative 对象执行 finalize 函数。这个逻辑最终导致这部分 native 内存的回收间接依赖 Java 层的 GC。如果一段时间内 Java 层没有 GC ,这部分 native 内存就会因为没有及时释放而堆积,进而在触顶后引发各种因 native 内存不足而导致的异常。《Android Camera 内存问题剖析》里有详细的分析过程,《ART 视角 | 如何让 GC 同步回收 native 内存》针对此类问题也同步给出了方案,通过沟通 Android 团队表示会在后续版本里彻底修复此问题。
后续规划
Native 内存泄漏监控的原理相对简单,但想要做到完美通用却很困难,最主要的考验当属性能和稳定性问题,例如 32 位栈回溯的性能和稳定性、缓存管理的性能等。前期我们在调研和开发 Raphael 时,基于快速落地和解决紧迫问题的目的,复用了大量第三方代码,并简化了很多逻辑。经过长期的治理实践,工具自身也暴露出一些问题和后续可以优化的方向。
就代理逻辑而言,Android-Inline-Hook 和 And64InlineHook 虽然都是比较优秀的 inline hook 工具,但实际使用时仍然存在兼容和卡死的问题。虽然 xHook 在兼容性和性能上都可以达到上线标准,但不具有通用性,很难将 native 内存泄漏监控扩展到其他有上限的资源上(如 JNI Reference Table)。我们也在调研优化 inline hook,探索更为稳定高效的 hook 方案。
栈回溯和缓存管理是 native 内存泄漏监控性能和稳定性的瓶颈。相对而言,基于 FP 的 64 位栈回溯方案已经到了极致,但 32 位下目前仍没有完美理想的方案。在 32 位下,Raphael 通过限制栈回溯深度和控制监控范围来规避频繁栈回溯带来的性能影响,虽然可以大幅提升性能,但也存在漏报问题。因此,32 位栈回溯性能也是我们后续的优化方向。此外,Raphael 已开源的版本其缓存管理仍然是通过全局锁来实现同步的,会有一定的性能损失,这个我们也会在后续的开源迭代里同步最新的优化。
众所周知,物理内存、虚拟内存、Thread、FD、JNI Reference Table 等都是典型的有上限的资源,不合理使用都会造成常规手段难以调查的稳定性问题。显而易见,内存泄漏的监控逻辑, 同样适用于其他这些有上限的资源。甚至于那些虽然没有明确上限的(如 Binder、流量、耗时等),我们也可以构造出相应的上限来实现监控和溯源。基于 Raphael 扩展其他的监控能力是我们后续要高优完善的。
总结
Android native 内存泄漏话题由来已久,在此之前业界一直没有稳定可靠的工具可用,得益于 AOSP 和其他优秀的开源项目(Android-Inline-Hook、And64InlineHook、xHook、xDL),使得我们有机会进行相关的尝试。Raphael 是西瓜视频基础技术团队的初步探索和尝试,在字节跳动内部众多 App (如西瓜、抖音、头条)长期的治理实践中,不仅解决了大量疑难问题,也进一步完善了工具和方法论。
虽然基于 Raphael 的 native 内存泄漏监控方案目前已经足够成熟和稳定,但其监控过程毕竟渗透到了 App 的运行过程,会有一定程度的性能损失和稳定性风险。我们倡导的方案是基于此来建设完善线下的内存泄漏防治体系,谨慎带到线上。由于内部迭代的 Raphael 版本比较多,且涉及其他未开源的项目,本次开源我们只能选择其中一个稳定可用的版本,其他优化会在后续逐步开源。
Raphael 只是迈开了其中的一小步,方案还有很大的优化空间。开源不是终点,我们希望集思广益、共同探索完善,在 Android 稳定性治理上走的更快更远。
相关资料
Raphael 开源地址:
https://github.com/bytedance/memory-leak-detector
xHook 链接:
https://github.com/iqiyi/xHook
xDL 链接:
https://github.com/hexhacking/xDL
Android-Inline-Hook 链接:
https://github.com/ele7enxxh/Android-Inline-Hook
And64InlineHook 链接:
https://github.com/Rprop/And64InlineHook
malloc debug 链接:https://android.googlesource.com/platform/bionic/+/master/libc/malloc_debug/README.md
LeakTracer 链接:
http://www.andreasen.org/LeakTracer/
libunwind llvm 编年史:
https://zhuanlan.zhihu.com/p/33937283
10. ART 视角 | 如何让 GC 同步回收 native 内存:
https://juejin.cn/post/6894153239907237902
本文转载自:字节跳动技术团队(ID:toutiaotechblog)
评论 1 条评论