配送骑手端App是骑手用于完成配送履约的应用,帮助骑手完成接单、到店、取货及送达,提供各种不同的运力服务,也是整个外卖闭环中的重要节点。由于配送业务的特性,骑手App对于应用稳定性的要求非常高,体现App稳定性的一个重要数据就是Crash率,而在众多Crash中最棘手最难定位的就是OOM问题。对于骑手端App而言,每天骑手都会长时间的使用App进行配送,而在长时间的使用过程中,App中所有的内存泄漏都会慢慢累积在内存中,最后就容易导致OOM,从而影响骑手的配送效率,进而影响整个外卖业务。

于是我们构建了用于快速定位线上OOM问题的组件——Probe,下图是Probe组件架构,本文主要分享Probe组件是如何对线上OOM问题进行快速定位的。

OOM原因分析

要定位OOM问题,首先需要弄明白Android中有哪些原因会导致OOM,Android中导致OOM的原因主要可以划分为以下几个类型:

Android 虚拟机最终抛出OutOfMemoryError的代码位于/art/runtime/thread.cc。

void Thread::ThrowOutOfMemoryError(const char* msg)
参数 msg 携带了 OOM 时的错误信息

下面两个地方都会调用上面方法抛出OutOfMemoryError错误,这也是Android中发生OOM的主要原因。

堆内存分配失败

系统源码文件:/art/runtime/gc/heap.cc

void Heap::ThrowOutOfMemoryError(Thread* self, size_t byte_count, AllocatorType allocator_type)
抛出时的错误信息:
    oss << "Failed to allocate a " << byte_count << " byte allocation with " << total_bytes_free  << " free bytes and " << PrettySize(GetFreeMemoryUntilOOME()) << " until OOM";

这是在进行堆内存分配时抛出的OOM错误,这里也可以细分成两种不同的类型:

  1. 为对象分配内存时达到进程的内存上限。由Runtime.getRuntime.MaxMemory()可以得到Android中每个进程被系统分配的内存上限,当进程占用内存达到这个上限时就会发生OOM,这也是Android中最常见的OOM类型。

  2. 没有足够大小的连续地址空间。这种情况一般是进程中存在大量的内存碎片导致的,其堆栈信息会比第一种OOM堆栈多出一段信息:failed due to fragmentation (required continguous free “<< required_bytes << “ bytes for a new buffer where largest contiguous free ” << largest_continuous_free_pages << “ bytes)”; 其详细代码在art/runtime/gc/allocator/rosalloc.cc中,这里不作详述。

创建线程失败

系统源码文件:/art/runtime/thread.cc

void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon)
抛出时的错误信息:
    "Could not allocate JNI Env"
  或者
    StringPrintf("pthread_create (%s stack) failed: %s", PrettySize(stack_size).c_str(), strerror(pthread_create_result)));

这是创建线程时抛出的OOM错误,且有多种错误信息。源码这里不展开详述了,下面是根据源码整理的Android中创建线程的步骤,其中两个关键节点是创建JNIEnv结构体和创建线程,而这两步均有可能抛出OOM。

创建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 files
 java.lang.OutOfMemoryError: Could not allocate JNI Env
   at 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 Env
  at java.lang.Thread.nativeCreate(Native Method)
  at java.lang.Thread.start(Thread.java:1063)

创建线程失败

创建线程也可以归纳为两个步骤:

  1. 调用mmap分配栈内存。这里mmap flag中指定了MAP_ANONYMOUS,即匿名内存映射。这是在Linux中分配大块内存的常用方式。其分配的是虚拟内存,对应页的物理内存并不会立即分配,而是在用到的时候触发内核的缺页中断,然后中断处理函数再分配物理内存。
  2. 调用clone方法进行线程创建。

第一步分配栈内存失败是由于进程的虚拟内存不足,抛出错误信息如下:

W/libc: pthread_create failed: couldn't allocate 1073152-bytes mapped space: Out of memory
W/tch.crowdsourc: Throwing OutOfMemoryError with VmSize  4191668 kB "pthread_create (1040KB stack) failed: Try again"
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
        at java.lang.Thread.nativeCreate(Native Method)
        at java.lang.Thread.start(Thread.java:753)

第二步clone方法失败是因为线程数超出了限制,抛出错误信息如下:

W/libc: pthread_create failed: clone failed: Out of memory
W/art: Throwing OutOfMemoryError "pthread_create (1040KB stack) failed: Out of memory"
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
  at 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.去重 gcRoots
deduplicateGcRoots(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文件。

线程数超出限制

对于创建线程失败导致的OOM,Probe会获取当前进程所占用的虚拟内存、进程中的线程数量、每个线程的信息(线程名、所属线程组、堆栈信息)以及系统的线程数限制,并将这些信息上传用于分析问题。

/proc/sys/kernel/threads-max规定了每个进程创建线程数目的上限。在华为的部分机型上,这个上限被修改的很低(大约500),比较容易出现线程数溢出的问题,而大部分手机这个限制都很大(一般为1W多)。在这些手机上创建线程失败大多都是因为虚拟内存空间耗尽导致的,进程所使用的虚拟内存可以查看/proc/pid/status的VmPeak/VmSize记录。

然后通过Thread.getAllStackTraces()可以得到进程中的所有线程以及对应的堆栈信息。

一般来说,当进程中线程数异常增多时,都是某一类线程被大量的重复创建。所以我们只需要定位到这类线程的创建时机,就能知道问题所在。如果线程是有自定义名称的,那么直接就可以在代码中搜索到创建线程的位置,从而定位问题,如果线程创建时没有指定名称,那么就需要通过该线程的堆栈信息来辅助定位。下面这个例子,就是一个“crowdSource msg”的线程被大量重复创建,在代码中搜索名称很快就查出了问题。针对这类线程问题推荐的做法就是在项目中统一使用线程池,可以很大程度上避免线程数的溢出问题。

线程信息:

thread name: Thread[nio_tunnel_handler,5,main]   count: 1
thread name: Thread[OkHttp Dispatcher,5,main]   count: 30
thread name: Thread[process_read_thread,5,main]   count: 4
thread name: Thread[Jit thread pool worker thread 0,5,main]   count: 1
thread name: Thread[crowdSource msg,5,main]   count: 202
thread name: Thread[Timer-4,5,main]   count: 1
thread name: Thread[mqt_js,5,main]   count: 1
  
threadnames:Thread[Thread-5,5,main] count:1
trace:
java.lang.Object.wait(NativeMethod)
com.dianping.networklog.d.run(UnknownSource:28)

FD数超出限制

前面介绍了,当进程中的FD数量达到最大限制时,再去新建线程,在创建JNIEnv时会抛出OOM错误。但是FD数量超出限制除了会导致创建线程抛出OOM以外,还会导致很多其它的异常,为了能够统一处理这类FD数量溢出的问题,Probe中对进程中的FD数量做了监控。在后台启动一个线程,每隔1s读取一次当前进程创建的FD数量,当检测到FD数量达到阈值时(FD最大限制的95%),读取当前进程的所有FD信息归并后上报。

在/proc/pid/limits描述着Linux系统对对应进程的限制,其中Max open files就代表可创建FD的最大数目。

进程中创建的FD记录在/proc/pid/fd中,通过遍历/proc/pid/fd,可以得到FD的信息。

获取FD信息:

File fdFile=new File("/proc/" + Process.myPid() + "/fd");
File[] files = fdFile.listFiles();  
int length = files.length; //即进程中的fd数量
for (int i = 0; i < length ; i++) {
  if (Build.VERSION.SDK_INT >= 21) {
         Os.readlink(files[i].getAbsolutePath()); //得到软链接实际指向的文件
     } else {
      //6.0以下系统可以通过执行readlink命令去得到软连接实际指向文件,但是耗时较久
  }
}

得到进程中所有的FD信息后,我们会先按照FD的类型进行一个归并,FD的用途主要有打开文件、创建socket连接、创建handlerThread等。

比如像下面这个例子中,就是anon_inode:[eventpoll]和anon_inode:[eventfd]的数量异常的多,说明进程中很可能是启动了大量的handlerThread,再结合回传上来的线程信息就能快速定位到问题代码的具体位置。

FD溢出案例:

FD信息:
anon_inode:[eventpoll]   count: 381
anon_inode:[eventfd]   count: 381
pipe   count  26
socket   count  32
/system/framework/framework-res.apk   count: 1
 ..........
Thread信息:
thread name: Thread[Jit thread pool worker thread 0,5,main]   count: 1
thread name: Thread[mtqq handler,5,main]   count: 302
thread name: Thread[Timer-4,5,main]   count: 1
thread name: Thread[mqt_js,5,main]   count: 1

总结

Probe目前能够有效定位线上Java堆内存不足、FD泄漏以及线程溢出的OOM问题。骑手Android端使用Probe组件解决了很多线上的OOM问题,将线上OOM Crash率从最高峰的2‰降低到了现在的0.02‰左右。我们后续也会继续完善Probe组件,例如HPROF文件裁剪方案对7.X系统的兼容以及Native层的内存问题定位。

作者简介

逢搏,美团配送App团队研发工程师。 毅然,美团配送App团队高级技术专家。 永刚,美团平台监控团队研发工程师。

招聘信息

美团配送App团队,负责美团骑手、美团众包、美团跑腿等配送相关App的研发,涉及技术领域包括但不限于App的稳定性建设、App性能监控和优化、大前端跨平台动态化、App安全。对上述领域感兴趣的请联系tech#meituan.com。