要定位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错误,这里也可以细分成两种不同的类型:
系统源码文件:/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。
创建JNIEnv可以归为两个步骤:
第一步创建匿名共享内存时,需要打开/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)
创建线程也可以归纳为两个步骤:
第一步分配栈内存失败是由于进程的虚拟内存不足,抛出错误信息如下:
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 情况大多数是因为内存泄漏或申请大量内存造成的,比较少见的有下面这种跟线程相关情况,但在我们 crash 系统上有时能发现一些这样的问题。
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Out of memory
原因分析
OutOfMemoryError 这种异常根本原因在于申请不到足够的内存造成的,直接的原因是在创建线程时初始 stack size 的时候,分配不到内存导致的。这个异常是在 /art/runtime/thread.cc 中线程初始化的时候 throw 出来的。
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
...
int pthread_create_result = pthread_create(
&new_pthread, &attr, Thread::CreateCallback, child_thread);
if (pthread_create_result != 0) {
env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer, 0);
{
std::string msg(StringPrintf("pthread_create (%s stack) failed: %s",
PrettySize(stack_size).c_str(), strerror(pthread_create_result)));
ScopedObjectAccess soa(env);
soa.Self()->ThrowOutOfMemoryError(msg.c_str());
}
}
}
调用这个 pthread_create 的方法去 clone 一个线程,如果返回 pthread_create_result 不为 0,则代表初始化失败。什么情况下会初始化失败,pthread_create 的具体逻辑是在 /bionic/libc/bionic/pthread_create.cpp 中完成:
int pthread_create(pthread_t* thread_out, pthread_attr_t const* attr,
void* (*start_routine)(void*), void* arg) {
...
pthread_internal_t* thread = NULL;
void* child_stack = NULL;
int result = __allocate_thread(&thread_attr, &thread, &child_stack);
if (result != 0) {
return result;
}
...
}
static int __allocate_thread(pthread_attr_t* attr, pthread_internal_t** threadp, void** child_stack) {
size_t mmap_size;
uint8_t* stack_top;
...
attr->stack_base = __create_thread_mapped_space(mmap_size, attr->guard_size);
if (attr->stack_base == NULL) {
return EAGAIN; // EAGAIN != 0
}
...
return 0;
}
可以看到每个线程初始化都需要 mmap 一定的 stack size,在默认的情况下一般初始化一个线程需要 mmap 1M 左右的内存空间,在 32bit 的应用中有 4g 的 vmsize,实际能使用的有 3g+,按这种估算,一个进程最大能创建的线程数可达 3000+,当然这是理想的情况,在 linux 中对每个进程可创建的线程数也有一定的限制(/proc/pid/limits)而实际测试中,我们也发现不同厂商对这个限制也有所不同,而且当超过系统进程线程数限制时,同样会抛出这个类型的 OOM。
可见对线程数量的限制,可以一定程度避免 OOM 的发生。所以我们也开始对微信的线程数进行了监控统计。
监控上报
我们在灰度版本中通过一个定时器 10 分钟 dump 出应用所有的线程,当线程数超过一定阈值时,将当前的线程上报并预警,通过对这种异常情况的捕捉,我们发现微信在某些特殊场景下,确实存在线程泄漏以及短时间内线程暴增,导致线程数过大(500+)的情况,这种情况下再创建线程往往容易出现 OOM。
在定位并解决这几个问题后,我们的 crash 系统和厂商的反馈中这种类型 OOM 确实降低了不少。所以监控线程数,收敛线程也成为我们降低 OOM 的有效手段之一。
Android中最常见的OOM就是Java堆内存不足,对于堆内存不足导致的OOM问题,发生Crash时的堆栈信息往往只是“压死骆驼的最后一根稻草”,它并不能有效帮助我们准确地定位到问题。
堆内存分配失败,通常说明进程中大部分的内存已经被占用了,且不能被垃圾回收器回收,一般来说此时内存占用都存在一些问题,例如内存泄漏等。要想定位到问题所在,就需要知道进程中的内存都被哪些对象占用,以及这些对象的引用链路。而这些信息都可以在Java内存快照文件中得到,调用Debug.dumpHprofData(String fileName)函数就可以得到当前进程的Java内存快照文件(即HPROF文件)。所以,关键在于要获得进程的内存快照,由于dump函数比较耗时,在发生OOM之后再去执行dump操作,很可能无法得到完整的内存快照文件。
首先,我们介绍几个基本概念:
Object Reference graph to Dominator Tree Conversion
JVM在进行GC的时候会进行可达性分析,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是可回收的。
Github上有一个开源项目HAHA库,用于自动解析和分析Java内存快照文件(即HPROF文件)。下面是HAHA库的分析步骤:
经过认真思考,我们认识到从前忽略了一个重要的基本事实,即应用的启动数和应用的退出数是守恒的。每次启动必然会有对应的退出,只要将所有的退出类型都枚举出来并监控上报,且总数能和启动数吻合,就能覆盖所有的稳定性问题。 基于以上思想,我们提出了退出率的概念,将退出分为以下十大类,每一类的退出率定义为 退出次数 / 启动次数。
图4
其中前五种退出类型是显著影响用户体验的问题,需要重点关注,crash(不含OOM)和OOM对应的是开头提到的通用指标;前台系统强杀指的是设备总内存紧张,应用在前台被系统强杀,比如iOS的jetsam,android的low memory killer,也包括其他一些资源问题,比如上文讲的wakeups;watchdog指的是卡顿引起的系统强杀,典型的即为iOS的watchdog和android的ANR;exit指的是我们主动在代码中自杀,通常情况下不应该有这样的逻辑存在。后五种退出类型绝大多数情况下是正常的退出行为,对用户体验无影响,我们只关注其中异常的情况,比如UI错乱导致的用户强杀,危险代码导致的系统重启等。
图9
回顾前文,OOM在稳定性重点关注问题中的占比非常高,和占比最高的前台系统强杀也有很高的相关性,而OOM问题的定位又特别困难,通常需要投入大量的人力和时间,进行人工复现,灰度收集数据,提交记录二分法暴力验证等等。占比高又定位困难,可以说OOM治理是稳定性治理皇冠上的明珠。 提到OOM,肯定绕不开神器LeakCanary
,其原理也是面试题中的常客,作为Android内存泄漏监控的开创者,多年来一直为广大app保驾护航,解决了OOM治理从0到1的问题。那么直接接入LeakCanary
上线不香么?还真不行,LeakCanary
虽然非常优秀,但也存在以下几点硬伤:
既然没有现成的轮子可用,只能自己动手,丰衣足食,经过一番努力,我们打造了一套可以线上部署、兼顾线下、配置灵活、适用范围广泛、高度自动化,埋点、监控、解析、上报、分发、跟进、报警一站式服务的闭环监控系统。
graph LR
subgraph 解析单一引用链
LeakCanary3
end
subgraph 解析大量引用链
KOOM3
end
监控模块-->LeakCannary1("LeakCannary")-->泄露发生实时,主动触发GC,会造成卡顿
监控模块-->KOOM-->内存阈值监控-->Java堆内存/线程数/文件描述符数突破阈值触发采集
内存阈值监控-->Java堆上涨速度突破阈值触发采集
内存阈值监控-->发生OOM时如果策略1,2未命中,触发采集
内存阈值监控-->泄漏判定延迟至解析时
采集模块-->LeakCannary2("LeakCannary")-->主进程DumpHprof,会造成app冻结
LeakCannary2-->Hprof文件过大
采集模块-->KOOM2("KOOM")-->Fork子进程DumpHprof,提前suspendAllThreads
KOOM2-->HookWrite实时裁剪Hprof
解析模块-->LeakCanary3("LeakCannary")-->解析耗时过长
解析模块-->KOOM3("KOOM")-->客户端解析
KOOM3-->关键对象判定-->泄露
关键对象判定-->shallow/retainedSize超过阈值
KOOM3-->性能优化-->内存懒加载,存储对象在hprof中的位置,并为其建立索引方便按需解析
性能优化-->SortedBytesMap
性能优化-->类型剪枝,同类对象超过阈值不再搜索,缓存每个类的superClass,objectID类型从long改为int
其核心流程为三部分:
为完成这样一套监控系统,我们攻克了以下技术难题
接下来我们一一展开分析。
为什么LeakCanary
需要主动触发GC呢?LeakCanary
监控泄漏利用了弱引用的特性,为Activity创建弱引用,当Activity对象变成弱可达时(没有强引用),弱引用会被加入到引用队列中,通过在Activity.onDestroy()
后连续触发两次GC,并检查引用队列,可以判定Activity是否发生了泄漏。但频繁的GC会造成用户可感知的卡顿,为解决这一问题,我们设计了全新的监控模块,通过无性能损耗的内存阈值监控来触发镜像采集,具体策略如下:
阈值监控只要在子线程定期获取关注的几个内存指标即可,性能损耗可以忽略不计;内存快速上涨用来定位对象频繁分配的问题;OOM作为最后兜底的策略,走到这里说明我们的阈值设计有漏洞,没有拦截住所有可能触发OOM的场景;最后,我们将对象是否泄漏的判断延迟到了解析时。还是以Activity
为例,我们并不需要在运行时判定其是否泄漏,Activity
有一个成员变mDestroyed
,在onDestory
时会被置为true
,只要解析时发现有可达且mDestroyed
为true
的Activity
,即可判定为泄漏(由于时序问题,这里可能有极小概率会发生误判,但不影响我们解决问题),其他关注的对象可以根据其特点设计规则。用一张图总结:
图11
Dump hprof是通过虚拟机提供的API dumpHprofData
实现的,这个过程会**“冻结”**整个应用进程,造成数秒甚至数十秒内用户无法操作,这也是LeakCanary
无法线上部署的最主要原因,如果能将这一过程优化至用户无感知,将会给OOM治理带来很大的想象空间。
面对这样一个问题,我们将其拆解,自然而然产生2个疑问: 1.为什么dumpHprofData
会冻结app,虚拟机的实现原理是什么? 2.这个过程能异步吗? 我们来看dumpHprofData的虚拟机内部实现 art/runtime/hprof/hprof.cc
// If "direct_to_ddms" is true, the other arguments are ignored, and data is sent directly to DDMS.
// If "fd" is >= 0, the output will be written to that file descriptor.
// Otherwise, "filename" is used to create an output file.
void DumpHeap(const char* filename, int fd, bool direct_to_ddms) {
CHECK(filename != nullptr);
Thread* self = Thread::Current();
// Need to take a heap dump while GC isn't running. See the comment in Heap::VisitObjects().
// Also we need the critical section to avoid visiting the same object twice. See b/34967844
gc::ScopedGCCriticalSection gcs(self,
gc::kGcCauseHprof,
gc::kCollectorTypeHprof);
ScopedSuspendAll ssa(__FUNCTION__, true /* long suspend */);
Hprof hprof(filename, fd, direct_to_ddms);
hprof.Dump();
}
可以看到在dump前,通过ScopedSuspendAll
(构造函数中执行SuspendAll
)执行了暂停所有java线程的操作,以防止在dump的过程中java堆发生变化,当dump结束后通过ScopedSuspendAll
析构函数进行ResumeAll
。
解决了第一个问题,接下来看第二个问题,既然要冻结所有线程,子线程异步处理是没有意义的,那么在子进程中处理呢?Android的内核是定制过的Linux, 而Linux fork子进程有一个著名的COW(Copy-on-write
,写时复制)机制,即为了节省fork子进程的内存消耗和耗时,fork出的子进程并不会copy父进程的内存,而是和父进程共享内存空间。那么如何做到进程隔离呢,父子进程只在发生内存写入操作时,系统才会分配新的内存为写入方保留单独的拷贝,这就相当于子进程保留了fork瞬间时父进程的内存镜像,且后续父进程对内存的修改不会影响子进程,想到这里我们豁然开朗。说干就干,我们写了一个demo来验证这个思路,很快就遇到了棘手的新问题:dump前需要暂停所有java线程,而子进程只保留父进程执行fork操作的线程,在子进程中执行SuspendAll触发暂停是永远等不到其他线程返回结果的(详见thread_list.cc
中行SuspendAll
的实现,这里不展开讲了),经过仔细分析SuspendAll
的过程,我们发现,可以先在主进程执行SuspendAll
,使ThreadList
中保存的所有线程状态为suspend
,之后fork,子进程共享父进程的ThreadList
全局变量,可以欺骗虚拟机,使其以为全部线程已经完成了暂停操作,接下来子进程就可以愉快的dump hprof了,而父进程可以立刻执行ResumeAll
恢复运行。
这里有一个小技巧,SuspendAll
没有对外暴露Java层的API,我们可以通过C层间接暴露的art::Dbg::SuspendVM
来调用,dlsym
拿到“_ZN3art3Dbg9SuspendVMEv
”的地址调用即可,ResumeAll
同理,注意这个函数在android 11以后已经被去除了,需要另行适配。Android 7之后对linker做了限制(即dlopen
系统库失效),快手自研了kwai-linker组件,通过caller address替换和dl_iterate_phdr
解析绕过了这一限制。 至此,我们完美解决了dump hprof冻结app的问题,用一张图总结:
图12
Hprof文件通常比较大,分析OOM时遇到500M以上的hprof文件并不稀奇,文件的大小,与dump成功率、dump速度、上传成功率负相关,且大文件额外浪费用户大量的磁盘空间和流量。我们因此想到了对hprof进行裁剪,只保留分析OOM必须的数据,另外,裁剪还有数据脱敏的好处,只上传内存中类与对象的组织结构,并不上传真实的业务数据(诸如字符串、byte
数组等含有具体数据的内容),保护用户隐私。
开发镜像裁剪,有两个衡量指标:一是裁剪率,即在不影响问题分析的前提下,裁剪掉的内容要足够多;二是裁剪性能损耗,如果性能不达标引发耗电、成功率低引入新的问题,就会使得内存镜像获取得不偿失。
照例,我们将问题拆解:
想要了解hprof的数据组织方式,推荐阅读openjdk官方文档[2],Android在此基础上做了一些扩展,这里简要介绍一下核心内容:
Header
和Record
,Header
记录hprof的元信息,Record
分很多条目,每一条有一个单独的TAG代表类型。我们关注的Record
类型主要是HEAP DUMP
,其中又分五个子类,分别为GC ROOT
、CLASS DUMP
、INSTANCE DUMP
、OBJECT ARRAY DUMP
、PRIMITIVE ARRAY DUMP
。图13以PRIMITIVE ARRAY DUMP
(基本类型数组)为例展示Record中
包含的信息,其他类型请查阅官方文档。内存中绝大部分数据是PRIMITIVE ARRAY DUMP
,通常占据80%以上,而我们分析OOM只关系对象的大小和引用关系,并不关心内容,因此这部分是我们裁剪的突破口。
图13
Android对数据类型做了扩展,增加了一些GC ROOT
// Android.
HPROF_HEAP_DUMP_INFO = 0xfe,
HPROF_ROOT_INTERNED_STRING = 0x89,
HPROF_ROOT_FINALIZING = 0x8a, // Obsolete.
HPROF_ROOT_DEBUGGER = 0x8b,
HPROF_ROOT_REFERENCE_CLEANUP = 0x8c, // Obsolete.
HPROF_ROOT_VM_INTERNAL = 0x8d,
HPROF_ROOT_JNI_MONITOR = 0x8e,
HPROF_UNREACHABLE = 0x90, // Obsolete.
HPROF_PRIMITIVE_ARRAY_NODATA_DUMP = 0xc3, // Obsolete.
还有一个HEAP_DUMP_INFO
,这里面保存的是堆空间(heap space)的类型,Android对堆空间做了划分,我们只关注HPROF_HEAP_APP即可,其余也是可以裁剪掉的,可以参考Android Studio中Memory Profiler的处理[3]。
enum HprofHeapId {
HPROF_HEAP_DEFAULT = 0,
HPROF_HEAP_ZYGOTE = 'Z',
HPROF_HEAP_APP = 'A',
HPROF_HEAP_IMAGE = 'I',
};
接下来讨论如何裁剪,裁剪有两种办法,第一种是在dump完成后的hprof文件基础上裁剪,性能比较差,对磁盘空间要求也比较高,第二种是在dump的过程中实时裁剪,我们自然想要实现第二种。看一下Record
写入的过程,先执行StartNewRecord
,然后通过AddU1/U4/U8
写入内存buffer,最后执行EndRecord
将buffer写入文件。
void StartNewRecord(uint8_t tag, uint32_t time) {
if (length_ > 0) {
EndRecord();
}
DCHECK_EQ(length_, 0U);
AddU1(tag);
AddU4(time);
AddU4(0xdeaddead); // Length, replaced on flush.
started_ = true;
}
void EndRecord() {
// Replace length in header.
if (started_) {
UpdateU4(sizeof(uint8_t) + sizeof(uint32_t),
length_ - sizeof(uint8_t) - 2 * sizeof(uint32_t));
}
HandleEndRecord();
sum_length_ += length_;
max_length_ = std::max(max_length_, length_);
length_ = 0;
started_ = false;
}
void HandleFlush(const uint8_t* buffer, size_t length) override {
if (!errors_) {
errors_ = !fp_->WriteFully(buffer, length);
}
}
这个过程中有两个hook点可以选择,一是hook AddUx
,在写入buffer的过程中裁剪,二是hook write
,在写入文件过程中裁剪。最终我们选择了方案二,理由是AddUx
调用比较频繁,判断逻辑复杂容易出现兼容性问题,而write
是public API,且只在Record写入文件的时候调用一次,厂商不会魔改相关实现,从hook原理上来讲,hook外部调用的PLT/GOT
hook也比hook内部调用的inline
hook要稳定得多。
用一张图总结裁剪的流程:
图14
解析hprof文件,对关键对象进行可达性分析,得到引用链,是我们解决OOM最核心的一步,之前的监控和dump都是为解析做铺垫。解析分两种,一种是上传hprof文件由server解析,另一种是在客户端解析后上传报告(通常只有几KB
)。最终我们选择了端上解析,这样做有两个好处:
照例,我们依然将问题拆解:
回顾前文,我们只解析关键对象的引用链,并写入分析报告中上传,判定的准确性和覆盖度决定了分析的质量。
我们将关键对象分为两类,一类是根据规则可以判断出对象已经泄露,且持有大量资源的,另外一类是对象shallow / retained size
超过阈值。
Activity/fragment
泄露判定即为第一种: 对于强可达的activity
对象,其mDestroyed
值为true时(onDestroy
时赋值),判定已经泄露。类似的,对于fragment
,当mCalled
值为true且mFragmentManager
为null时,判定已经泄露 。 我们可以用同样的思路合理制定规则,来处理我们核心的业务组件,比如无处不在的presenter
。
Bitmap/window/array/sufacetexture
判定为第二种 检查bitmap/texture
的数量、宽高、window
数量、array
长度等等是否超过阈值,再结合hprof中的相关业务信息,比如屏幕大小,view
大小等进行判定。
一开始我们尝试了LeakCanary
的解析引擎HAHA
(Android Studio解析引擎perlib的Android移植版),解析过程中非常容易OOM,且解析速度极慢,500M的hprof文件,内存峰值达到2G,绝大多数Andriod设备的Java堆内存上限只有512M,即使顶配的macbook解析耗时都在3分钟以上,如此性能,在端上解析成功率低到发指。一度使我们想放弃现有的轮子,用C重写解析库,恰好此时LeakCanary
发布了新的解析引擎shark
[4],号称内存峰值可以降低10倍,解析速度可以提升6倍。我们实验了一下,发现小的demo hprof基本能达到其宣称的性能,线上真实环境拿到的包含百万级对象hprof文件,性能会急剧下降,分析时间突破10分钟。因此,我们需要进一步优化,优化之前,先来研究一下HAHA
和shark
的原理。
为什么HAHA
内存峰值高,速度慢呢,概括起来主要是以下几点:
domanitor tree
[5]全量计算,实际上我们只关心关键对象的retained size
。Shark
是如何优化的呢? Shark
是LeakCanary
2.0推出的全新解析组件,其设计思想详见作者的介绍[6],主要做了以下几项优化:
shark
低内存开销的最根本原因就是通过索引做到了内存懒加载,遍历hprof时存储对象在hprof中的位置,并为其建立索引方便按需解析。map
,有2个:第一是对于key
和value
都是基础类型或字符串的使用hppc
做map
,第二是对于value
不是基本类型的,使用SortedBytesMap
存储内容。具体的索引有:实例索引、类索引、字符串索引、类名索引、数组索引:
/**
* This class is not thread safe, should be used from a single thread.
*/
internal class HprofInMemoryIndex private constructor(
private val positionSize: Int,
private val hprofStringCache: LongObjectScatterMap<String>,
private val classNames: LongLongScatterMap,
private val classIndex: SortedBytesMap,
private val instanceIndex: SortedBytesMap,
private val objectArrayIndex: SortedBytesMap,
private val primitiveArrayIndex: SortedBytesMap,
private val gcRoots: List<GcRoot>,
private val proguardMapping: ProguardMapping?,
val primitiveWrapperTypes: Set<Long>
) {
/**
* Code from com.carrotsearch.hppc.LongLongScatterMap copy pasted, inlined and converted to Kotlin.
*
* See https://github.com/carrotsearch/hppc .
*/
class LongLongScatterMap constructor(expectedElements: Int = 4) {
/**
* A read only map of `id` => `byte array` sorted by id, where `id` is a long if [longIdentifiers]
* is true and an int otherwise. Each entry has a value byte array of size [bytesPerValue].
*
* Instances are created by [UnsortedByteEntries]
*
* [get] and [contains] perform a binary search to locate a specific entry by key.
*/
internal class SortedBytesMap(
private val longIdentifiers: Boolean,
private val bytesPerValue: Int,
private val sortedEntries: ByteArray
) {
复制代码
所谓hppc
是High Performance Primitive Collection
[7]的缩写,shark
使用kotlin
将其重写了。hppc
只支持基本类型,所以没有了装、拆箱的性能损耗,相关集合操作也做了大量优化,其benchmark
可以参考[8]。
再来看一下一个普通的对象在虚拟机中的内存开销有多大(ps:这还只是截图了一部分,一个int4个字节,1个long8个字节):
图15
前文提到,基于shark
在解析大hprof时,性能依然不够理想,需要做进一步的优化。 先来分析一下shark
的使用场景和我们解析需求的差异:
LeakCanary
中shark
只用于解析单一泄漏对象的引用链,而我们要分析大量对象的引用链。Shark
对于结果的要求非常精准,而我们是线上大数据分析,允许丢弃个别对象的引用链。Shark
对于镜像中的对象所有字段都进行解析,用于查询字段的值,而我们并不关心基础类型的值。经过一番探索与实践,中途还去研究了MAT的源码,我们对其主要做了以下几点优化:
JavaFrame
、MonitorUsed
等此类GC Root可以直接剪枝。long
修改为int
,Android虚拟机的object ID大小只有32位,目前shark
里使用的都是long
来存储的,OOM时百万级对象的情况下,可以节省10M内存。另外,还有几项实验中的调优项:
shark
改用c++重写,从GC日志来看,大hprof解析时,GC还是十分频繁的,改用c++会降低这部分开销。经过以上优化,将解析时间在shark
的基础上优化了2倍以上,内存峰值控制在100M以内。 用一张图总结解析的流程:
图16
解析结果上传到server以后,还要做反混淆,聚类等工作。通过关键对象以及引用链,将问题聚合后自动分发给研发同学,分发的原则是引用链中最近提交代码的owner。图17&18摘录了跟进系统的关键信息:
图17
图18
https://github.com/KwaiAppTeam/KOOM
//Matrix ResourceCanary没有解决dump hprof慢的问题,无法在线上使用
https://github.com/Tencent/matrix/wiki/Matrix-Android-ResourceCanary