发布时间:2024-03-25 09:01
此文在 快手Android OOM治理实践与思考 的基础上进行了自己的剖析和引导
如果您阅读到此文章,请您带着批判的眼光看待内容,如有错误或不严谨的地方请联系我,我将不尽感激:im_dsd@126.com,谢谢。
Android 高手核心知识点笔记(不断更新中)点击查看 PS:给为童鞋不要忘记给我 star 一波哦
VSS(包含共享库占用的内存) OOM 指的是虚拟内存 OOM,按理说虚拟地址是无限的,为啥会出现 OOM 呢?原因在于寻址空间:
64 位设备的 kernal 运行于 64位模式寻址空间我们可以认为是无限的,而32位 app 的寻址空间实际只有 4G
虚拟机会占用 1 ~ 2G 的 VSS 用于 GC 等操作、占用1G用于映射内核内存,,因此32位应用只有 1~2G的 VSS 可供使用
高端机型往往放大了堆内存的限制,可供 Native 使用的内存就没有多少了
因此对于32位、且重度依赖 Native 的 app 在越高端的机型上(分辨率高、Java Heap高)反而越容易触发 VSS OOM。
这个错误在 Native 层会返回 12 或者 -12 错误码。
PSS(比例分配共享库占用的内存) 不像是 Java Heap 有单进程的上线,而是根据设备总体内存情况决定。 LowMemoryKiller 守护进程(LMKD)会每隔一段事件检查设备的内存使用情况,当内存不足的时候,会依据进程优先级从低到高依次杀进程释放内存,保证系统服务和前台应用有足够的内存。
比如我们在一个4G物理内存的手机上,使用 Native 分配 2G 内存,很大概率 App 会被 LMK 杀死,而且没有任何提示。
在 Java 调用 Thread#start 方法之后,Native层就会创建一个 Thread,创建 Thread 需要在 Java Stack 上分配一个1040kb
的空间作为线程栈。
上图可以看到 Thread OOM 会提示 ptread_create 失败,Thread OOM 主要出现两种情况:
下图是 ART 虚拟机线程栈的内存布局图,其中绿色部分就是一个线程需要的 Stack 内存空间: 1024kb,再加上 ART 提供的内存保护:两个8KB,总共是1040KB
线程数的上线系统默认是 32768 部分华为设备的限制是500, 也是 VSS OOM的一种表现形式。通常1000(1000 * 1040k ≈ 1G)左右就会触发 VSS OOM。因此Thread OOM其实也是 VSS OOM的一种表现形式。
通过 hook libart.so 的 pthread_attr_setstacksize 外部调用, 可以减小创建线程时分配的内存比如 256KB, 根据快手技术专家的经验 256kb 已经足够用了,大幅减低Thread OOM率。
同理,反射减小sCursorWindowSize,可以大幅降低数据库访问时的OOM。
在 Android 9 以前 FD 是宝贵的资源,系统默认设定一个进程最多分配 1024 个FD,可以通过 ulimit -a 查看。到了 Android 9 Google 工程师也意识到这个设定有点小了已经拓展到 32768 (3k个)
Anrdoid 9 以前 | Android 9 |
---|---|
我们很多应用场景都会默默的使用 FD 例如 Handler、stock 等比如创建一个 HandlerThread 在创建线程的同时还会创建两个 FD:mWeakEventFd和mEpollEvent(原因是 Handler 内使用了 epoll 机制来保障 Looper 的等待和唤醒,在 Android 5.0之前 Handler 的底层使用的是管道此时会使用三个 FD)。
/proc//fd 根据文件名聚类可以初步定位fd泄漏问题。(此操作需要 Root)
JNIT 的引用 OOM 可以在 logcat 中查看。在 Android 日常开发中很少会遇到 JNI Reference OOM 场景,除非你是 Native 的开发人员。
解决内存问题最有效的办法就是通过内存镜像(Hprof文件)
LeakCanary 的核心也是解决内存问题常见的三板斧:监控泄漏、采集镜像、分析镜像。
但是 LeakCanary 有致命的问题:无法线上部署。原因很简单在采集和分析内存镜像中会造成明显的卡顿。而且官方文档也明确建议不推荐在线上版本使用,如果集成到 release 包内会直接触发崩溃。
We do not recommend including LeakCanary in release builds, as it could negatively impact the experience of your customers.
LeakCanary dump 操作使用了 Android Api Debug.dumpHprofData(String fileName)
:
Debug.dumpHprofData(String fileName)
会冻结 Appdump 操作会通过 Native 层的 hprof.Dump() 将内存数据按照 Hprof 协议的格式按照二进制的形式保存到磁盘中
为了保证 dump 过程中内存数据的不变性在执行 hprof.Dump() 之前会通过 ScopedSuspendAll (构造函数内调用了 SupendAll)暂停了所有 Java 线程,在 dump 结束后通过 ScopedSusendAll 析构函数中(通过 ResumeAll )恢复线程
既然要冻结所有线程那么常规的子线程操作就没了任何意义。那么在子进程中处理可以吗?这里要设计一个知识点 :进程的创建过程。
Android 是在 Linux 内核基础上构建的操作系统,所以 Android 创建进程的流程大体如下:
这意味着我们可以在 dump 之前先 fork App 进程,这样子进程就获得了父进程所有的内存镜像。但还有一个棘手的问题需要解决。
这个棘手的问题是:dump 前需要暂停所有线程(不仅仅是 Java 线程还有 Native 线程),而子进程只保留执行 fork 操作的进程,但主进程内所有的线程信息还是保留的,以至于虚拟机认为子进程中还是由很多线程的。所以在子进程中执行 SuspendAll 触发暂停是永远无法等到其他线程执行暂停后的返回结果的。
经过仔细分析 SuspendAll 过程,我们发现可以先在主进程中执行 SuspendAll 方法,使 ThreadList 中保存所有线程状态为 suspend,之后再fork,这样子进程共享父进程的 ThreadList 全局变量 _list,可以欺骗虚拟机,使其认为所有的线程完成了暂停操作,接下来就可以在子线程执行 hprof.dump 操作了。而主进程在 fork 之后调用 ResumAll 恢复运行。
fork dump流程图 | dump hprof blocking 时间对比 |
---|---|
hprof 文件通常比较到,文件的体积和手机最大堆内存呈正相关。分析 OOM 时遇到 500M 以上的 hprof 文件也不稀奇。文件的大小和 dump 成功率、dump 性能、上传成功率均呈负相关。且大文件额外浪费用户的磁盘空间和上传到服务端的流量。因此需要对 hprof 文件进行裁剪,只保留分析 OOM 必须的数据,另外,裁剪还有对用户数据脱敏的好处,只上传内存中类域对象的组织结构,并不上传真是的业务数据(诸如字符串、byte 数组等含有具体数据的内容),保护了用户的隐私。
查阅 openJDK 官方文档和 AOSP 源码可知(更多 Hprof 文件信息见 HPROF Agent)
Hprof 文件按 byte by byte 顺序存储,u1、u2、u4 分别代表 1byte 、2byte、4byte
Hprof 的核心数据结构物 Header 和 Recod,Header 记录元数据,Record 分很多条目,每一条有一个单独的 TAG 表示(下图来自 ART 源码)
我们主要关注 Record 中的 HEAP_DUMP 类型,其中分为五个子类,分别为 GC ROOT、CLASS DUMP、INSTANCE DUMP、OBJECT ARRAY DUMP 、PRIMITIVE ARRAY DUMP。
下图为 PRIMITIVE ARRAY DUMP(基本类型数组)的数据结构,内存中绝大部分数据是 PRIMITIVE ARRAY DUMP,通常占据 80% 以上,而我们 OOM 分析只关心对象的引用关系和对象大小,其他部分我们并不关心,因此这部分是我们裁剪 Hprof 的主要突破口。
通过 Art hprof.cc 源码可以看到Android 对 TAG 类型做了拓展,增加了一些 GC ROOT 类型
Android 对 Java堆进行了更细致的划分(用 HEAP_DUMP_INFO 表示具体划分情况见下图)。我们只关心 HPROF_HEAP_APP 即可,其余的部分都可以裁剪掉,这是因为其他部位都是 Android 内存所有 App 共享的不参与 GC。这里可以参考 Android Studio 中 Memory Profile 的处理。
接下来讨论的是如何实现裁剪,裁剪的方案有两个:
为了较少不必要的 IO 操作和资源消耗,快手选择了第二种。
顺着 TAG 找到了 HEAP_DUMP 的写入点,PS :下图可以清楚的看到 Recod的写入过程:
根据快手 Anroid 专家的意思写入的过程也是使用 AddU1/U4/U8 方法将数据写入 Buffer。但是笔者对 C/C++ 不了解,没看懂源码是如何将 hprof 文件到写入 buffer 中的,
在写入时先会执行 StartNewRecord() 方法,然后通过 AddU1/U4 写入 buffer 此时可以发现 Record 数据的都是通过 AddU1/U4/U8 方法写入 Buffer 的
我们在 EndRecord 中顺着 HandleEndRecord 的蔓摸到了最终写入磁盘的方法:WriteFully。
显而易见此时有两个点可提我们 hook:
理论上讲 AddUx 方法性能最好,可以较少写入 buffer 时候的内存开销。但是 AddUx 的调用比较复杂,而且他是 ART 内部代码,很可能会被不同手机厂商修改出现兼容性的问题。而 write 是一个外部调用且只在 Record 写入文件的时候调用一次,从 hook 原理上来讲,hook 外部调用的 PLT/GOThook 也比 hook 内部调用的 inline hook 要稳定的多(PS:好吧,我对 hook 这块一无所知,一脸懵逼)。因此快手最终选择了方法二。
为什么 LeakCanary 需要主动触发 GC 呢?LeakCanary 监控泄漏利用了弱引用的特征,为 Activity 对象创建弱引用,当 Activity 变为弱可达(没有强引用)的时候,弱引用将会加入弱引用队列中,但是 GC 的触发时机不可知,所以不能仅仅依靠弱引用队列是否不为空就确定存在内存泄漏。在通过主动 GC 的方式可以使垃圾回收器进行一次扫描标记从而确定是否存在内存泄漏(确认的方式还是看弱引用队列中是否存在此 Activity 的弱引用)。
但是 GC 是 Stop-The-World 的,频繁的 GC 会造成用户感知的明显卡顿。为了解决这一方案快手、美团都使用了阈值判定的方式设计了无性能损耗的内存监控策略。其中快手的策略大致如下:
改成阈值监控除了性能外,还有其他明显的好处 :OOM 的来源一般有两种
LeakCanary 的弱引用策略仅能解决内存泄漏问题,而且需要手动埋点(PS:没试过,怎么埋点呢?)而通过阈值监控的方式可以同时解决内存泄漏和峰值的问题,也不需要手动埋点,甚至还能解决频繁对象的引发的内存抖动问题。
那么如何判定对象泄漏呢?以 Activity 为例,其实并不需要在运行时判定其是否内存泄漏,Activtu 内部有一个成员变量 mDestroyed,在 onDestroy 时会被置为 true,只要解析时发现 可达且 mDestroyed 为 true 的 Activity 即判定为泄漏。其他类型的对象可以以此类推,根据其特点设计。
三板斧的最后一斧就是镜像分析了,其基本思路是模仿 GC 算法,对我们关注的对象(泄漏、大对象)进行科可达性分析,找出 GC Root 和引用链,再结合代码分析。
暴力解析引用关系树的 CPU 和内存消耗都是很高的,即使在独立进程解析,很容易触发 OOM 或者被系统强杀,成功率非常低。因此需要对解析算法进行优化。
对所有对象进行可达性分析性能开销是巨大的,且没有重点也不利于问题解决,回顾前文,如果我们只关注泄漏对象和不合理使用的大对象,那么第一步就可以对引用关系树尽可能的进行剪枝:
LeakCanary 早起使用的解析引擎是 HAHA 。在 HAHA1 使用了 MAT 的分析组件到了 HAHA2 改为 Android Stuido Perlib 组件。但是这两个组件都是基于 PC 端设计的且表现优秀,但是在移植到资源受限的手机端后,性能就急剧下降,解析过程很容易出现第二次 OOM,且解析极慢,成功率低。
随着 LeakCanary 2.0 版本的发布,其研发团队退出了新一代 hporf 分析引擎 shark。其宣称 Shark 相比 HAHA 内存峰值降低10倍,解析速度提升 6倍。快手团队试验后发现在较小的 hprof 文件下 Shark 的表现和宣称的性能一致。但是在分析包含百万级对象的 hprof 是性能解决下降,分析耗时突破 10 分钟。
Shark 的核心思想就是懒加载和数据结构优化:
Shark 索引源码地址
索引对应前文提到的 hprof Record 中的 HEAP DUMP 类型。
包含实例索引,类索引,字符串索引,类名索引,数组索引。
我们的目标是打造线上内存分析工具,所以和 LeakCanary 还是存在挺大差异的:
对 GC Root 剪枝,可达性分析是从 GC Root 自顶向下 BFS,如 JavaFrame MonitorUsed 等 GC Root 都可以直接剪枝。
基本类型和基本类型数组不搜索、不解析;同类对象超过阈值时不再搜索。
增加预处理,缓存每个类递归调用的结果,减少重复计算。
将 object ID 的 类型从 long 改为 int,Android 虚拟机的 object ID 大小只有 32 位,目前shark 里使用的都是 long 来存储的, OOM 时百万级对象的情况下,可以节省10M 内存。
经过上面的优化、将解析时间在 Shark 的基础上优化了2倍以上,内存峰值控制在 100M 以内。
此部分就没啥好说的了,就只能看看人家大厂是如何做的。
快手Android OOM治理实践与思考
为什么 Redis 快照使用子进程