OOM线上监控

2,311 阅读11分钟

Jetsam机制终止进程的时候最终是通过发送SIGKILL异常信号来完成的。

从系统库 signal.h 文件中我们可以找到SIGKILL这个异常信号的解释,它不可以在当前进程被忽略或者被捕获,我们之前监听异常信号的常规 Crash 捕获方案肯定也就不适用了。那我们应该如何监控 OOM 崩溃呢?

2015 年的时候Facebook提出了另外一种思路,简而言之就是排除法。

我们在每次 App 启动的时候判断上一次启动进程终止的原因,那么已知的原因有:

  • App 更新了版本
  • App 发生了崩溃
  • 用户手动退出
  • 操作系统更新了版本
  • App 切换到后台之后进程终止

如果上一次启动进程终止的原因不是上述任何一个已知原因的话,就判定上次启动发生了一次FOOM崩溃。

曾经Facebook旗下的Fabric也是这样实现的。但是通过我们的测试和验证,上述这种方式至少将以下几种场景误判:

  • WatchDog 崩溃
  • 后台启动
  • XCTest/UITest 等自动化测试框架驱动
  • 应用 exit 主动退出

自研线上 Memory Graph,OOM 崩溃率下降 50%+

OOM生产环境归因:

排查内存问题的工具主要包括 Xcode 提供的 Memory Graph 和 Instruments 相关的工具集,它们能够提供相对完备的内存信息,但是应用场景仅限于开发环境。

优秀的解决方案:

MLeaksFinder

MLeaksFinder 的基本原理是这样的,当一个 ViewController 被 pop 或 dismiss 之后,我们认为该 ViewController,包括它上面的子 ViewController,及它的 View,View 的 subView 等,都很快会被释放,如果某个 View 或者 ViewController 没释放,我们就认为该对象泄漏了。

为基类 NSObject 添加一个方法 -willDealloc 方法,该方法的作用是,先用一个弱指针指向 self,并在3秒后,通过这个弱指针调用 -assertNotDealloc,而 -assertNotDealloc 主要作用是直接中断言。

UIViewController的分类中,使用 Method Swizzling,hook掉了viewDidDisappear:,viewWillAppear:,dismissViewControllerAnimated:completion:等方法,让他们都执行willDealloc方法,这样,在不入侵开发代码的情况下,为UIViewController添加了检查内存泄露的功能(AOP)

FBRetainCycleDetector

深度优先搜索(DFS)

当传入内存中的任意一个 OC 对象,FBRetainCycleDetector 会递归遍历该对象的所有强引用的对象,以检测以该对象为根结点的强引用树有没有循环引用。

而 FBRetainCycleDetector 最大的技术亮点,正在于如何找出一个 block 的所有强引用对象。

存在两个问题:

1、需要找到候选的检测对象

2、检测循环引用比较耗时

先通过 MLeaksFinder 找到内存泄漏的对象,然后再过 FBRetainCycleDetector 检测该对象有没有循环引用即可。

OOMDetector

手Q自研

功能:

爆内存堆栈统计:负责记录进程内存分配堆栈和内存块大小,在爆内存时Dump堆栈数据到磁盘

内存泄漏检测:检测内存泄漏,目前支持Malloc内存块和OC对象的泄漏检测

爆内存堆栈监控的实现原理:

通过Hook IOS系统底层内存分配的相关方法(包括malloc_zone相关的堆内存分配以及vm_allocate对应的VM内存分配方法),跟踪并记录进程中每个对象内存的分配信息,包括分配堆栈、累计分配次数、累计分配内存等,这些信息也会被缓存到进程内存中。在内存触顶的时候,组件会定时Dump这些堆栈信息到本地磁盘,这样如果程序爆内存了,就可以将爆内存前Dump的堆栈数据上报到后台服务器进行分析。

E50EB763-6001-43DD-98A4-85CE3E08E4F9.png

对Hook方法中耗时较多的堆栈回溯和锁等待进行了优化:

优化堆栈回溯方法:

系统提供了backtrace_symbols方法可以直接获取堆栈信息,但是这个方法特别耗时。

优化方法:运行时只会获取堆栈函数的地址信息,在回写磁盘的时候再根据动态库的地址范围拼装成特定堆栈格式(类似Crash堆栈),后台服务器利用atos命令和符号表文件就可以还原出对应的堆栈内容。

优化锁等待耗时:

优先考虑锁的效率采用了OSSpinLock的方案

堆栈聚类和压缩:

只保留内存占用较大的堆栈。要完成这个工作就必须对内存中所有堆栈先进行聚类合并,统计出每个堆栈累计的内存值。

对于每个记录到的分配堆栈,首先通过md5算法将堆栈数据压缩为16字节的md5,通过md5值进行聚类,缓存中只保留16字节的md5数据,只有当某个堆栈的累计内存超过一定阀值时,才会保留原始堆栈信息,这样因为超过阀值的堆栈数量有限,堆栈原始信息占用的空间几乎就可以忽略不计。

数据Dump方案:

常规的方案是IO接口直接把数据写入到磁盘。

采用了效率更高的mmap方式。实现这样的直接映射关系后,写文件的过程进程不会有额外的文件的数据拷贝操作,避免了内核空间和用户空间的频繁切换。mmap 在内存不足时会主动进行回写操作。

844AB4B0-04EC-4AEC-B212-4577B6C7CB51.png

OOMDetector可以记录到每一个对象的分配堆栈信息,要从这些对象中找出 “泄漏对象”,我们需要知道在程序可访问的进程内存空间中,是否有“指针变量”指向对应的内存块,那些在整个进程内存空间都没有指针指向的内存块,就是我们要找的泄漏内存块。

为了避免内存访问冲突,扫描过程需要挂起所有线程,整个过程会卡住程序1-2秒。因为扫描过程较为耗时,这个功能目前主要用于App的测试阶段,与自动化测试结合可快速高效的发现泄漏问题。

上述方案存在问题:

  • 基于 Objective-C 对象引用关系找循环引用的方案,适用范围比较小,只能处理部分循环引用问题,而内存问题通常是复杂的,类似于内存堆积,Root Leak,C/C++层问题都无法解决。
  • 基于分配堆栈信息聚类的方案需要常驻运行,对内存、CPU 等资源存在较大消耗,无法针对有内存问题的用户进行监控,只能广撒网,用户体验影响较大。同时,通过某些比较通用的堆栈分配的内存无法定位出实际的内存使用场景,对于循环引用等常见泄漏也无法分析。

线上 Memory Graph :

核心的原理是扫描进程中所有 Dirty 内存,通过内存节点中保存的其他内存节点的地址值建立起内存节点之间的引用关系的有向图,用于内存问题的分析定位,整个过程不使用任何私有 API。

这套方案具备的能力如下:

  1. 完整还原用户当时的内存状态。
  2. 量化线上用户的大内存占用和内存泄漏,可以精确的回答 App 内存到底大在哪里这个问题。
  3. 通过内存节点符号和引用关系图回答内存节点为什么存活这个问题。
  4. 严格控制性能损耗,只有当内存占用超过异常阈值的时候才会触发分析。没有运行时开销,只有采集时开销,对 99.9%正常使用的用户几乎没有任何影响。
  5. 支持主要的编程语言,包括 OC,C/C++,Swift,Rust 等。

21B4C5B0-933A-4811-9800-67DC57C33681.jpg

内存快照采集:

线上 Memory Graph 采集内存快照主要是为了获取当前运行状态下所有内存对象以及对象之间的引用关系,用于后续的问题分析。

  • 所有内存的节点,以及其符号信息(如OC/Swift/C++ 实例类名,或者是某种有特殊用途的 VM 节点的 tag 等)。
  • 节点之间的引用关系,以及符号信息(偏移,或者实例变量名),OC/Swift成员变量还需要记录引用类型。

由于采集的过程发生在程序正常运行的过程中,为了保证不会因为采集内存快照导致程序运行异常,整个采集过程需要在一个相对静止的运行环境下完成。因此,整个快照采集的过程大致分为以下几个步骤:

  1. 挂起所有非采集线程。
  2. 获取所有的内存节点,内存对象引用关系以及相应的辅助信息。
  3. 写入文件。
  4. 恢复线程状态。

内存节点的获取:

通过 mach 内核的vm_region_recurse/vm_region_recurse64函数我们可以遍历进程内所有VM Region(每一块单独的虚拟内存)。

通过malloc_get_all_zones获取libmalloc内部所有的zone,并遍历每个zone中管理的内存节点,获取 libmalloc 管理的存活的所有内存节点的指针和大小。

符号化:

获取所有内存节点之后,我们需要为每个节点找到更加详细的类型名称,用于后续的分析。

引用关系的构建:

整个内存快照的核心在于重新构建内存节点之间的引用关系。在虚拟内存中,如果一个内存节点引用了其它内存节点,则对应的内存地址中会存储指向对方的指针值。

  1. 遍历一个内存节点中所有可能存储了指针的范围获取其存储的值 A。
  2. 搜索所有获得的节点,判断 A 是不是某一个内存节点中任何一个字节的地址,如果是,则认为是一个引用关系。
  3. 对所有内存节点重复以上操作。

数据上报策略:

  1. 后台线程定时检测内存占用,超过设定的危险阈值后触发内存分析。
  2. 内存分析后数据持久化,等待下次上报。
  3. 原始文件压缩打包。
  4. 检查后端上报许可,因为单个文件很大,后端可能会做一些限流的策略。
  5. 上报到后端分析,如果成功后清除文件,失败后会重试,最多三次之后清除,防止占用用户太多的磁盘空间。

例如:

所有的图片最终都被TTImagePickController这个类持有,最终排查到是图片选择器模块一次性把用户相册中的所有图片都加载到内存里,极端情况下会发生这个问题。

字节线上监控:juejin.cn/post/688514…

方案:

1、内存泄漏处理

2、内存优化

(1)图片内存使用优化:

使用适当尺寸的图片,在服务端将图片裁剪成控件的精确尺寸,下发到不同机型,从根本上将内存使用降低。

(2)及时回收图片:

当页面pop掉时,有必要清理页面内图片的内存缓存。其次,列表类的页面在滑动时,可以及时清理那些滑出屏幕图片的内存缓存。

(3)图片压缩

直接使用UIImage会在解码时读取文件占用一部分内存,还会生成中间位图bitmap消耗大量内存,而ImageIO不存在上述两种内存消耗,只会占用最终图片大小的内存。

(4)合理使用自动释放池

通常autoreleased对象在runloop结束时才释放。如果在一些体循环中,或者很复杂的逻辑中产生大量autoreleased对象,内存峰值会猛涨,容易触发OOM。

(5)对象按需创建

控件尽量采用懒加载的方式,尽量优化其结构,降低页面复杂度。将不必要的单例对象改为懒加载的普通对象,使用完也能及时释放掉。

(6)大量循环操作

Photos获取照片是有缓存,这个过程中内存主要包括两部分 IOSurface 和 CG raster data 。

PHImageManager中requestImage替换成requestImageDataAndOrientation或者使用串行操作。

(7)持久化对象

didReceiveMemoryWarning放一些大的对象释放操作。

京东OOM内存优化:

mp.weixin.qq.com/s/dNw7evXlT…