Probe:Android 线上 OOM 问题定位组件,移动互联网 app 检测取证系统
创建 JNI 失败
创建 JNIEnv 可以归为两个步骤:
通过 Andorid 的匿名共享内存(Anonymous Shared Memory)分配 4KB(一个 page)内核态内存。
再通过 Linux 的 mmap 调用映射到用户态虚拟内存地址空间。
第一步创建匿名共享内存时,需要打开/dev/ashmem 文件,所以需要一个 FD(文件描述符)。此时,如果创建的 FD 数已经达到上限,则会导致创建 JNIEnv 失败,抛出错误信息如下:
E/art: ashmem_create_region failed for 'indirect ref table': Too many open filesjava.lang.OutOfMemoryError: Could not allocate JNI Envat java.lang.Thread.nativeCreate(Native Method)at java.lang.Thread.start(Thread.java:730)
第二步调用 mmap 时,如果进程虚拟内存地址空间耗尽,也会导致创建 JNIEnv 失败,抛出错误信息如下:
E/art: Failed anonymous mmap(0x0, 8192, 0x3, 0x2, 116, 0): Operation not permitted. See process maps in the log.java.lang.OutOfMemoryError: Could not allocate JNI Envat java.lang.Thread.nativeCreate(Native Method)at java.lang.Thread.start(Thread.java:1063)
创建线程失败
创建线程也可以归纳为两个步骤:
调用 mmap 分配栈内存。这里 mmap flag 中指定了 MAP_ANONYMOUS,即匿名内存映射。这是在 Linux 中分配大块内存的常用方式。其分配的是虚拟内存,对应页的物理内存并不会立即分配,而是在用到的时候触发内核的缺页中断,然后中断处理函数再分配物理内存。
调用 clone 方法进行线程创建。
第一步分配栈内存失败是由于进程的虚拟内存不足,抛出错误信息如下:
W/libc: pthread_create failed: couldn't allocate 1073152-bytes mapped space: Out of memoryW/tch.crowdsourc: Throwing OutOfMemoryError with VmSize 4191668 kB "pthread_create (1040KB stack) failed: Try again"java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try againat java.lang.Thread.nativeCreate(Native Method)at java.lang.Thread.start(Thread.java:753)
第二步 clone 方法失败是因为线程数超出了限制,抛出错误信息如下:
W/libc: pthread_create failed: clone failed: Out of memoryW/art: Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Out of memory"java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memoryat java.lang.Thread.nativeCreate(Native Method)at java.lang.Thread.start(Thread.java:1078)
OOM 问题定位
在分析清楚 OOM 问题的原因之后,我们对于线上的 OOM 问题就可以做到对症下药。而针对 OOM 问题,我们可以根据堆栈信息的特征来确定这是哪一个类型的 OOM,下面分别介绍使用 Probe 组件是如何去定位线上发生的每一种类型的 OOM 问题的。
堆内存不足
Android 中最常见的 OOM 就是 Java 堆内存不足,对于堆内存不足导致的 OOM 问题,发生 Crash 时的堆栈信息往往只是“压死骆驼的最后一根稻草”,它并不能有效帮助我们准确地定位到问题。
堆内存分配失败,通常说明进程中大部分的内存已经被占用了,且不能被垃圾回收器回收,一般来说此时内存占用都存在一些问题,例如内存泄漏等。要想定位到问题所在,就需要知道进程中的内存都被哪些对象占用,以及这些对象的引用链路。而这些信息都可以在 Java 内存快照文件中得到,调用 Debug.dumpHprofData(String fileName)函数就可以得到当前进程的 Java 内存快照文件(即 HPROF 文件)。所以,关键在于要获得进程的内存快照,由于 dump 函数比较耗时,在发生 OOM 之后再去执行 dump 操作,很可能无法得到完整的内存快照文件。
于是 Probe 对于线上场景做了内存监控,在一个后台线程中每隔 1S 去获取当前进程的内存占用(通过 Runtime.getRuntime.totalMemory()-Runtime.getRuntime.freeMemory()计算得到),当内存占用达到设定的阈值时(阈值根据当前系统分配给应用的最大内存计算),就去执行 dump 函数,得到内存快照文件。
在得到内存快照文件之后,我们有两种思路,一种想法是直接将 HPROF 文件回传到服务器,我们拿到文件后就可以使用分析工具进行分析。另一种想法是在用户手机上直接分析 HPROF 文件,将分析完得到的分析结果回传给服务器。但这两种方案都存在着一些问题,下面分别介绍我们在这两种思路的实践过程中遇到的挑战和对应的解决方案。
线上分析
首先,我们介绍几个基本概念:
Dominator:从 GC Roots 到达某一个对象时,必须经过的对象,称为该对象的 Dominator。例如在上图中,B 就是 E 的 Dominator,而 B 却不是 F 的 Dominator。
ShallowSize:对象自身占用的内存大小,不包括它引用的对象。
RetainSize:对象自身的 ShallowSize 和对象所支配的(可直接或间接引用到的)对象的 ShallowSize 总和,就是该对象 GC 之后能回收的内存总和。例如上图中,D 的 RetainSize 就是 D、H、I 三者的 ShallowSize 之和。
JVM 在进行 GC 的时候会进行可达性分析,当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是可回收的。
Github 上有一个开源项目 HAHA 库,用于自动解析和分析 Java 内存快照文件(即 HPROF 文件)。下面是 HAHA 库的分析步骤:
于是我们尝试在 App 中去新开一个进程使用 HAHA 库分析 HPROF 文件,在线下测试过程中遇到了几个问题,下面逐一进行叙述。
分析进程自身 OOM
测试时遇到的最大问题就是分析进程自身经常会发生 OOM,导致分析失败。为了弄清楚分析进程为什么会占用这么大内存,我们做了两个对比实验:
在一个最大可用内存 256MB 的手机上,让一个成员变量申请特别大的一块内存 200 多 MB,人造 OOM,Dump 内存,分析,内存快照文件达到 250 多 MB,分析进程占用内存并不大,为 70MB 左右。
在一个最大可用内存 256MB 的手机上,添加 200 万个小对象(72 字节),人造 OOM,Dump 内存,分析,内存快照文件达到 250 多 MB,分析进程占用内存增长很快,在解析时就发生 OOM 了。
实验说明,分析进程占用内存与 HPROF 文件中的 Instance 数量是正相关的,在将 HPROF 文件映射到内存中解析时,如果 Instance 的数量太大,就会导致 OOM。
HPROF 文件映射到内存中会被解析成 Snapshot 对象(如下图所示),它构建了一颗对象引用关系树,我们可以在这颗树中查询各个 Object 的信息,包括 Class 信息、内存地址、持有的引用以及被持有引用的关系。
HPROF 文件映射到内存的过程:
// 1.构建内存映射的 HprofBuffer 针对大文件的一种快速的读取方式,其原理是将文件流的通道与 ByteBuffer 建立起关联,并只在真正发生读取时才从磁盘读取内容出来。HprofBuffer buffer = new MemoryMappedFileBuffer(heapDumpFile);
// 2.构造 Hprof 解析器 HprofParser parser = new HprofParser(buffer);// 3.获取快照 Snapshot snapshot = parser.parse();// 4.去重 gcRootsdeduplicateGcRoots(snapshot);
为了解决分析进程 OOM 的问题,我们在 HprofParser 的解析逻辑中加入了计数压缩逻辑(如下图),目的是在文件映射过程去控制 Instance 的数量。在解析过程中对于 ClassInstance 和 ArrayInstance,以类型为 key 进行计数,当同一类型的 Instance 数量超过阈值时,则不再向 Snapshot 中添加该类型的 Instance,只是记录 Intsance 被丢弃的数量和 Instance 大小。这样就可以控制住每一种类型的 Instance 数量,减少了分析进程的内存占用,在很大程度上避免了分析进程自身的 OOM 问题。既然我们在解析时丢弃了一部分 Instance,后面就得把丢弃的这部分找补回来,所以在计算 RetainSize 时我们会进行计数桶补偿,即把之前丢弃的相同类型的 Instance 数量和大小都补偿到这个对象上,累积去计算 RetainSize。
链路分析时间过长
在线下测试过程中还遇到了一个问题,就是在手机上进行链路分析的耗时太长。
使用 HAHA 算法在 PC 上可以快速地对所有对象都进行链路分析,但是在手机上由于性能的局限性,如果对所有对象都进行链路分析会导致分析耗时非常长。
考虑到 RetainSize 越大的对象对内存的影响也越大,即 RetainSize 比较大的那部分 Instance 是最有可能造成 OOM 的“元凶”。
我们在生成 Reference 之后,做了一步链路归并(如上图),即对于同一个对象的不同 Instance,如果其底下的引用链路中的对象类型也相同,则进行归并,并记录 Instance 的个数和每个 Instance 的 RetainSize。
然后对归并后的 Instance 按 RetainSize 进行排序,取出 TOP N 的 Instance,其中在排序过程中我们会对 N 的值进行动态调整,保证 RetainSize 达到一定阈值的 Instance 都能被发现。对于这些 Instance 才进行最后的链路分析,这样就能大大缩短分析时长。
排序过程:创建一个初始容量为 5 的集合,往里添加 Instance 后进行排序,然后遍历后面的 Instance,当 Instance 的 RetainSize 大于总共消耗内存大小的 5%时,进行扩容,并重新排序。当 Instance 的 RetainSize 大于现有集合中的最小值时,进行替换,并重新排序。
基础类型检测不到
为了解决 HAHA 算法中检测
不到基础类型泄漏的问题,我们在遍历堆中的 Instance 时,如果发现是 ArrayInstance,且是 byte 类型时,将它自身舍弃掉,并将它的 RetainSize 加在它的父 Instance 上,然后用父 Instance 进行后面的排序。
至此,我们对 HAHA 的原始算法做了诸多优化(如下图),很大程度解决了分析进程自身 OOM 问题、分析时间过长问题以及基础类型检测不到的问题。
针对线上堆内存不足问题,Probe 最后会自动分析出 RetainSize 大小 Top N 对象到 GC Roots 的链路,上报给服务器,进行报警。下面是一个线上案例,这里截取了上报的链路分析结果中的一部分,完整的分析结果就是多个这样的组合。在第一段链路分析可以看到,有个 Bitmap 对象占用了 2MB 左右的内存,根据链路定位到代码,修复了 Bitmap 泄漏问题。第二段链路分析反映的是一个 Timer 泄漏问题,可以看出内存中存在 4 个这样的 Instance,每个 Instance 的 Retain Size 是 595634,所以这个问题会泄漏的内存大小是 4*595634=2.27MB。
裁剪回捞 HPROF 文件
在 Probe 上线分析方案之后,发现尽管我们做了很多优化,但是受到手机自身性能的约束,线上分析的成功率也只有 65%。
于是,我们对另一种思路即回捞 HPROF 文件后本地分析进行了探索,这种方案最大的问题就是线上流量问题,因为 HPROF 文件动辄几百 MB,如果直接进行上传,势必会对用户的流量消耗带来巨大影响。
使用这种方案的关键点就在于减少上传的 HPROF 文件大小,减少文件大小首先想到的就是压缩,不过只是做压缩的话,文件还是太大。接下来,我们就考虑几百 MB 的文件内容是否都是我们需要的,是否可以对文件进行裁剪。我们希望对 HPROF 无用的信息进行裁剪,只保留我们关心的数据,就需要先了解 HPROF 文件的格式:
Debug.dumpHprofData()其内部调用的是 VMDebug 的同名函数,层层深入最终可以找到/art/runtime/hprof/hprof.cc,HPROF 的生成操作基本都是在这里执行的,结合 HAHA 库代码阅读 hrpof.cc 的源码。
HPROF 文件的大体格式如下:
一个 HPROF 文件主要分为这四部分:
文件头。
字符串信息:保存着所有的字符串,在解析的时候通过索引 id 被引用。
类的结构信息:是所有 Class 的结构信息,包括内部的变量布局,父类的信息等等。
堆信息:即我们关心的内存占用与对象引用的详细信息。
其中我们最关心的堆信息是由若干个相同格式的元素组成,这些元素的大体格式如下图:
每个元素都有个 TAG 用来标识自己的身份,而后续字节数则表示元素的内容长度。元素携带的内容则是若干个子元素组合而成,通过子 TAG 来标识身份。
具体的 TAG 和身份的对应关系可以在 hrpof.cc 源码中找到,这里不进行展开。
弄清楚了文件格式,接下来需要确定裁剪内容。经过思考,我们决定裁减掉全部基本类型数组的值,原因是我们的使用场景一般是排查内存泄漏以及 OOM,只关心对象间的引用关系以及对象大小即可,很多时候对于值并不是很在意,所以裁减掉这部分的内容不会对后续的分析造成影响。
最后需要确定裁剪方案。先是尝试了 dump 后在 Java 层进行裁剪,发现效率很低,很多时候这一套操作下来需要 20s。然后又尝试了 dump 后在 Native 层进行裁剪,这样做效率是高了点,但依然达不到预期。
经过思考,如果能够在 dump 的过程中筛选出哪些内容是需要保留的,哪些内容是需要裁剪的,需要裁剪的内容直接不写入文件,这样整个流程的性能和效率绝对是最高的。
为了实现这个想法,我们使用了 GOT 表 Hook 技术(不展开介绍)。有了 Hook 手段,但是还没有找到合适的 Hook 点。通过阅读 hrpof.cc 的源码,发现最适合的点就是在写入文件时,拿到字节流进行裁剪操作,然后把有用的信息写入文件。于是项目最终的结构如下图:
我们对 IO 的关键函数 open 和 write 进行 Hook。Hook 方案使用的是爱奇艺开源的xHook库。
在执行 dump 的准备阶段,我们会调用 Native 层的 open 函数获得一个文件句柄,但实际执行时会进入到 Hook 层中,然后将返回的 FD 保存下来,用作 write 时匹配。
在 dump 开始时,系统会不断的调用 write 函数将内容写入到文件中。由于我们的 Hook 是以 so 为目标的,系统运行时也会有许多写文件的操作,所以我们需要对前面保存的 FD 进行匹配。若 FD 匹配成功则进行裁剪,否则直接调用 origin-write 进行写入操作。
流程结束后,就会得到裁剪后的 mini-file,裁剪后的文件大小只有原始文件大小的十分之一左右,用于线上可以节省大部分的流量消耗。拿到 mini-file 后,我们将裁剪部分的位置填上字节 0 来进行恢复,这样就可以使用传统工具打开进行分析了。
原始 HPROF 文件和裁剪后再恢复的 HPROF 文件分别在 Android Studio 中打开,发现裁剪再恢复的 HPROF 文件打开后,只是看不到对象中的基础数据类型值,而整个的结构、对象的分布以及引用链路等与原始 HPROF 文件是完全一致的。事实证明裁剪方案不会影响后续对堆内存的链路分析。
方案融合
由于目前裁剪方案在部分机型上(主要是 Android 7.X 系统)不起作用,所以在 Probe 中同时使用了这两种方案,对两种方案进行了融合。即通过一次 dump 操作得到两份 HPROF 文件,一份原始文件用于下次启动时分析,一份裁剪后的文件用于上传服务器。
Probe 的最终方案实现如下图,主要是在调用 dump 函数之前先将两个文件路径(希望生成的原始文件路径和裁剪文件路径)传到 Native 层,Native 层记录下两个文件路径,并对 open 和 write 函数进行 Hook。hookopen 函数主要是通过 open 函数传入的 path 和之前记录的 path 比对,如果相同,我们就会同时调用之前记录的两个 path 的 open,并记录下两个 FD,如果不相同则直接调原生 open 函数。hookwrite 函数主要是通过传入的 FD 与之前 hookopen 中记录的 FD 比对,如果相同会先对原始文件对应的 FD 执行原生 write,然后对裁剪文件对应的 FD 执行我们自定义的 write,进行裁剪压缩。这样再传入原始文件路径调用系统的 dump 函数,就能够同时得到一份完整的 HPROF 文件和一份裁剪后的 HPROF 文件。
评论