京东技术:Sieve—Android 内存分析系统 | 解决你的内存溢出问题

纯洁的微笑 2018-08-21 17:19:59 ⋅ 770 阅读

来这里找志同道合的小伙伴!


>>>>  背景

内存问题是个老大难,对用户来说,泄漏或者不合理的内存使用最终会反映到性能和体验上,并且极易造成 OOM( Out Of Memories ) 而闪退, 而对开发者来说更为头疼:

  • OOM 堆栈价值不大

    它是压倒骆驼的最后一根稻草罢了。

  • 现有工具不够理想

    LeakCanary 为解决内存泄漏而存在,但其实“泄漏”的定性其实是人为的:即你认为该对象不该继续存在了,结果它仍然被一条链路引用着,那我们说这个对象泄漏了。 LeakCanary 帮我们把 对象不该继续存在了 这个概念绑定为了比如 Activity 这种本身有生命周期的对象的 onDestroy(),这也意味着对于其他一些没有所谓生命周期的对象,只要它还在内存中存在着,那它泄漏与否实际上取决于你认定它该不该活着(但 LeakCanary 不知道你怎么想的,所以它无法帮你找到这些泄漏)。因此,我们希望能够单纯的提供对象的引用链路给你,至于它存在的合理性交由你自己判断。

    MAT 的问题在于它实际上是个专为 Java 虚拟机做内存分析的工具,对于 Android 不够友好,尤其是 Bitmap 等对象大小不对的问题,这个后面细说,并且功能上来讲,部分冗余部分又达不到一些特定的需求,比较难用。

  • 元凶不止一个

    举个例子,一个 OOM 背后可能是两个 Activity 的泄漏,三张超大的图片等等一系列问题。这些捆稻草都有责任,理想情况是让这些稻草从重到轻排个序,谁负主要责任一目了然。

>>>>  成果

先上链接为敬!Sieve—Android 内存分析系统(http://performance.jd.com/,暂时只面向京东集团内部使用)

这是一个面向开发者的工具,上传一份 Hprof 文件(堆转储),系统将为你生成一份分析报告,下面是某一份报告页的截图。

总览

Activity/Fragment

Bitmap

对象的引用路径

Class 前三十

>>>>  实现

实现主要分为堆转储的解析、支配树的生成、RetainSize 的计算、引用链路的构造

>>>>  解析堆转储文件

Android Studio 中 Monitor 工具 Hprof 文件存储了当前时刻堆的情况,主要包括类信息、栈帧信息、堆栈信息、堆信息。其中堆信息是我们关注的重点,其中包括了所有的对象:线程对象、类对象、实例对象、对象数组对象、原始类型数组对象。

在所有对象中,部分对象还拥有另一重身份虚拟机认定的 GC Root(指向的对象)。众所周知,GC Roots 是 ART VM 垃圾回收算法设定的根,代表了从这些根出发,顺着强引用关系所能达到对象是绝不会被回收的。 GC Roots 必须是对于当前 GC 堆的一组活跃的引用,这是显然的,因为引用是活跃的,那么引用直接或间接引用的对象们必然是有用的,是不能被回收的。知道了谁是不能回收的,也就知道了谁是能被回收的,GC 的目标也就找到了。这便是是 GC Roots 存在的意义。

GC Roots 分许多类型(不同虚拟机甚至更细或者不同的叫法),比如:

  • Stack Local:当前活跃的栈帧里指向 GC 堆中对象的引用(也就是当前调用方法的参数、局部变量)

  • Thread:存活的线程

  • JNI Local:Native 栈中的局部变量

  • JNI Global:Native 全局引用

  • 分代收集中,从非收集代指向收集代的引用

等等,可以看出,这些分类都与 "GC Roots 是一组活跃的引用"的说法相吻合。同时,需要知道的是,GC Roots 是动态变化的,一个引用可能刚刚是 GC Root ,这会又不是了。

解析工作其实就是读取文件中以上提到的信息,并将其映射至内存的过程。最终映射的结果是一个叫做堆快照的数据结构。

严格来讲,GC Root 是引用而不是对象,为了描述方便,文中我们把其指向的对象也叫做 GC Root。

SNAPSHOT

  • Default Heap:对于某对象,系统未指定堆

  • App Heap:对应 ART VM 中的 Allocation Space,其实分裂自 Zygote Space。进程独享的主堆。

  • Image Heap:对应 ART VM 中的 Image Space,系统启动映像,包含启动期间预加载的类, 此处的分配保证绝不会移动或消失。

  • Zygote Heap :对应 ART VM 中的 Zygote Space ,进程共享。在该Hprof 文件中表示Zygote Space 中属于该进程的那部分。

在将 Hprof 映射至这份快照的同时,我们通过它提供类的继承关系、类的字段信息等等,在这份 SnapShot 的各个对象之间建立了引用与被引用的关系(可以叫它父子关系,这里我们只保留强引用关系)。那如果再为所有是 GC Root 的对象的头上添加一个超级源点同时作为他们的父亲的话,其实我们就得到了一个以这个”超级源点“为根的引用关系”树“。(引号的原因是,实际情况里引用之间可能存在环,严格的讲它不一定是个树)

>>>>  支配树的生成与 RetainSize 的计算

先介绍一下相关概念

1、支配点与支配树

在有向图中,如果从源点到 B 点,无论如何都要经过 A 点,则 A 是 B 的支配点,称 A 支配 B。而距离 B 最近的支配点,称之为直接支配点。

比如上图中

  • A 支配 B、C、D、E、F, 而 B 支配 D、E 不支配 F

  • E 的直接支配点是 B

支配树是基于原图生成的一棵树,其每个点的父亲是原图中这个点的直接支配点。对与上图来说,支配树是

2、Shallow Size 与 Retained Size

某个对象的 Shallow Size 是对象本身的大小,不包含其引用的对象。也就是说比如对于下面这个类:

 
  1. public class TestA {

  2.    int a;

  3.    TestB b;

  4. }

其大小应该为:12(对象头)+4(int a)+4(TestB b)+4(对齐) = 24 (关于对象头,字段在内存中排列,对齐等不展开讨论)

这里重点关注字段 TestB 只计算了一个引用的大小:4 byte,而不管这个 TestB 有多少字段,每个字段是什么。

某个对象的 Retained Size 是其支配的所有节点的 Shallow Size 之和。

讲到这里其实就明白了,Retained Size 其实就是某个对象所能维护和保有的大小,换句话说它代表了如果回收掉该对象虚拟机所能收回掉的内存大小。各个对象的 Retained Size 大小是我们分析内存使用情况的重要指标,当某些对象的Retained Size 过大时,可能代表着不合理的内存使用或者泄露。

3、支配树的生成

对于 DAG(有向无环图)来说,可以按照拓扑序来构建支配树,记拓扑序中第 x 个点 为 v ,求 v 的直接支配点时,拓扑序中 v 之前的点(拓扑序为 1~x-1的点 )的直接支配点已经求好了(也就是对于这些点,支配树已经构造好了),接下来对在原图中 v 的所有父亲求在已经构造的支配树上的最近公共祖先(因为父亲们肯定拓扑序小于 x,所以父亲们已经在目前构造好的支配树上了)。举个栗子,对于下图(点已按拓扑序标号)

假设走到了求点 8 的直接支配点这一步,则说明 1~7 的支配树已构造完毕,如下图:

接着,对点 8 的父亲,点 5、6、7 求在上图支配树中的最近公共祖先,显而易见他们的最近公共祖先是点 1,因此点 8 的直接支配点就是点 1,继续添加到支配树上,得到:

以上就是支配树的构造过程,这里是树在不断改变并且是在线查询的情况,我们采用的倍增法,树的 LCA(最近公共祖先)问题的算法很多,比如转化为 RMQ(范围最值查询) 问题求解等等,可自行了解。

还没完呢!细心的你可能发现了,之前提到过,实际的引用关系并不是树,也不是 DAG ,而仅仅是个有向图。这意味着有环,意味着拓扑序失去了意义,意味着对每个点的所有父亲求在支配树上的 LCA 时,它的某个父亲可能还没有处理。这里采取的方式是,如果这个父亲没有处理,那就先跳过,继续之前的算法,就当少了一个父亲,直至支配树构造完毕。紧接着,从头开始重复构造支配树,之前某点没有处理的父亲,这一次可能就变成处理过的了,所以就可能将该点求出的直接支配点结果”刷新“。不断的重复这一过程,直至不存在某个点求出的直接支配点被”刷新“。也就是说既然环的存在使的拓扑关系不再成立,那就跳过因此导致此时还未处理的父节点,通过不断迭代的方式使得最终所有求得的支配点”收敛“。

上述算法的瓶颈在于这个迭代的次数随着图的复杂程度爆炸增长,有向图(有环也行)的支配树构造其实有更为优秀的算法,Lengauer-Tarjan算法。该算法引入了半支配点的概念,半支配点代表了有潜力成为直接支配点的点,该算法正是通过修正半支配点得到直接支配点的。详细可自行了解

4、Retained Size 计算

有了支配树,Retained Size 计算就是个累加过程。遍历每个点,将其 Shallow Size 加至支配树里其所有祖先身上去,比如对于上面的那张图,对于点 5 ,就是将其 Shallow Size 加至点 4 、点 2、点 1。当遍历完的时候,所有点的 Retained Size 也就计算完毕了。

但如果真就这样算下来,会发现比如不少 Bitmap 的 RetainSize “根本不对”,如果你用 MAT 查看,发现经常就几十字节,这在直觉上是无法理解的。这就与文初提到的 GC Root 有关了,虚拟机会将某些对象标记成各种 Type 的 GC Root。直观的想象一下,这就相当于把某个本是从顶到下的引用关系链中的普通节点,被提扯到最顶上去当做根节点,这也是出现环的原因之一

Bitmap 中 mBuffer 成员正是如此,byte[] 类型的 mBuffer 存储了位图的像素数据,几乎占据了 Bitmap 的全部大小。如果它本本分分,那么它就是支配树上的一个叶子,其直接支配点就是其父对象 Bitmap,那就一切正常皆大欢喜。然而事实是在某些情况下,由于它被“提拔”成了 GC Root,它的直接支配点会被支配树算法直接置为超级源点。这会导致其 Shallow Size 无法加至其原本的祖先链上去。

比如上面图中,假设点 5 就是那个 mBuffer,点 4 是 Bitmap,因为点 5 的支配点不是点 4 了,所有点 5 的 Shallow Size ,加不到点 4、点 2、点 1 身上去了。因此我们做些特殊的处理,让mBuffer 记下其对应的 bitmap 对象,计算 Retained Size 时,碰到 mBuffer ,直接将其 Shallow Size 加至支配树中其记下的 bitmap 和 bitmap 的祖先链上去

那 MAT 就是错的吗?并不是,按照 retained size 的定义,既然 bitmap 并不是 mBuffer 的直接支配点了,那 bitmap 所支配的大小确确实实就不包含 mBuffer 的大小。只是考虑到 mBuffer 作为 GC Root 的状态是变化的,而开发者又希望能够直观了解应用中位图的大小,才产生了这个“修补”策略。

如果某个对象是 GC Root,那么它的内存当然不会被回收。但有时候这个对象就不应该一直还是 GC Root。比如我们常常调侃单例模式其实就是个泄漏,因为静态成员让其成为了 GC Root,内存永远无法释放。所以你应该在不再需要某个对象的时候,断掉对它的强引用(无论是让其不再不合理的成为 GC Root或是断掉其被引用链中的一环)。对于图片来说,如果你选择自行管理其加载缓存等,那你可能还需要及时的 bitmap.recycle() , 该方法会断掉对 mbuffer 的引用。如果你使用 Fresco ,那你需要确保 DraweeView 的 onAttach 和 onDetach 能够正确及时的被调用。

此外,以上修补策略仅限于 8.0 以下。在 Android 8.0及以上,java 层 Bitmap 不再持有 mBuffer 成员,像素数据被移至 Zygote Heap。

>>>>  引用链路的构造

通过 Retained Size 大小找到怀疑对象之后,需要找到它被引用的链路。对象的被引用路径其实就是个树,从怀疑对象开始,一层一层展开,树的叶子们就是 GC Root 。(见成果展示中的 Leak Trace 附图)

考虑到实际需要,这里采用是类似宽搜的方式,维护一个 FIFO 队列, 从怀疑对象开始,当搜索到GC Root 时保存当前的搜索状态,并返回路径。然后无限重复从保存的状态继续搜索,直到该次搜索找不到路径(返回为空)。最终得到若干条”最短路径“,也就是该对象的一条条的伸展开来的被引用链路。

注意到每一条路径中的任意相邻的点构成的线段实际上就代表了我们最终构造的树中的父子关系,遍历这些线段,完成这个有向图的存储即可。这里用邻接表的方式存下这个树,举个例子,这个数据结构可以长这样

 
  1. public final class PathsFromGCRootsTree{

  2.    private String description;

  3.    private ArrayList<PathsFromGCRootsTree> inboundTrees;

  4. }

>>>>  Something else

其实这个项目一开始不是一个放在服务端的离线的面向开发者的分析系统,我们想在客户端线上搞定这个事情。在接近 OOM 的时候 dump hprof,另起进程分析,分析完上报,这样会解决很多痛点。不理想的是:

  • Debug.dumpHprof()会造成 GC,在接近 OOM 时的 GC 更是卡顿的让人无法接受。

  • dump 下来的 hprof 文件如果映射至内存,动辄两三百兆(经观察发现它与 hprof 中对象的多少正相关),这显然会直接让分析进程就 OOM 了。

  • 我们尝试过解决分析进程 OOM 的问题,设定一个阈值,舍弃同类型实例中的数量大于阈值的那一部分,边瘦身边映射。这样确实可以继续分析了,然而由于不少实例被舍弃,引用关系这张图变的残缺,也就导致引用链路构造变得不准确。

另外,研发过程中我们碰到并解决了巨多细节上的坑和问题,限于篇幅,感兴趣的同学可以私下交流探讨。


---------------END----------------

后续的内容同样精彩

长按关注“IT实战联盟”哦



全部评论: 0

    我有话说:

    京东到家订单中心系统mysql到es转化之路

    原文:https://www.toutiao.com/i6796507988602389006 京东到家订单中心系统业务中,无论是外部商家订单生产,或是内部上下游系统依赖,订单查询调用量都非常

    Redis 5.0.11、6.0.11、6.2 发布,修复 32 位系统整数溢出

    Redis 同时发布了 5.0.11、6.0.11 和 6.2 版本。对于使用 32 位 Redis 用户来说,此次更新解决了一个重要安全问题,即 32 位系统整数溢出((CVE-2021

    抖音 APP 性能优化系列:Java 内存优化篇

    内存作为计算机程序运行最重要资源之一,需要运行过程中做到合理资源分配与回收,不合理内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of

    京东技术京东系统架构师如何让笨重架构变得灵巧

    京东系统架构师,从事架构设计与开发工作,熟悉各种开源软件架构。在Web开发、架构优化上有较丰富实战经历。

    京东技术:开发属于自己插件 | IDEA & Android Studio插件开发指南

    是否曾经被ide重复繁琐操作所困扰,又或者没有心仪UI控件而难受。那么请阅读这篇文章,掌握idea插件开发流程,开发属于自己插件,造福开源社区。

    Linux 5.10.8 发布,最终解决了 Btrfs 性能问题

    Linux 内核 5.10.8 已经发布。作为 Linux LTS 5.10 系列最新版本,此次更新解决了 Btrfs 文件系统性能问题。 Btrfs 是一种支持写入时复制

    京东技术:Flutter图片缓存 | Image.network源码分析

    Android目前提供了很丰富图片框架,像ImageLoader、Glide、Fresco等。对于Flutter而言,为了探其缓存机制或者定制自己缓存框架,特从其Image入手进行突破。

    大厂技术大佬:用大白话给解释Zookeeper选举机制

    Zookeeper 是一个分布式服务框架,主要是用来解决分布式应用中遇到一些数据管理问题如:统一命名服务、状态同步服务、集群管理、分布式应用配置项管理等。 我们可以简单把 

    Spring Boot 解决跨域问题 3 种方案!

    作者:telami telami.cn/2019/springboot-resolve-cors/ 前后端分离大势所趋,跨域问题更是老生常谈,随便用标题去google

    Apache Arrow 3.0.0 发布,内存数据交换格式

    Apache Arrow 3.0.0 发布了,该版本包含 2.0.0 发布以来修复 678 个问题。Apache Arrow 是 Apache 基金会顶级项目之一,目的是作为一个跨平台数据层来

    使用分支进行开发和部署

    开发协作更加高效,而且也为系统升级和问题修复部署...

    可能不知道CRUD

      本系列旨在系统学习提升Mysql技能,更完整内容可以参考阿里新零售数据库设计与实战 DB引擎 可能不知道CRUD INSERT 情况一 Duplicate key 当批量更新,如果

    MoguBlog v6.0 已经发布,前后端分离博客系统

    MoguBlog v6.0 已经发布,这是一个前后端分离博客系统。 此版本更新内容包括: fix: 解决博客详情页重复点赞时,弹出登录框情况 docs: nacos数据库中增加prod配置

    京东技术:Hystrix 分布式系统限流、降级、熔断框架

    Hystrix是Netflix开源一款容错框架,包含常用容错方法:线程隔离、信号量隔离、降级策略、熔断技术

    商城系统 DBShop V3.0 Beta 发布

    全新重构,首次亮相。 系统简介 DBShop企业级商城系统,使用PHP语言基于Laminas(Zendframework 3) + Doctrine 2 组合框架开发完成。可定制、多终端、多场景、多

    Android 12 开发者预览版 2.2 发布

    上月底,Google 发布了 Android 12 DP 2.1,解决了 WebView 崩溃问题。目前,开发者预览版 2.2 已经向运行 Android 12 Pixel 手机正式推出

    京东技术京东风格移动端Vue组件库NutUI2.0来啦

    移动端 Vue 组件库 NutUI 自发布以来受到了广泛关注。据不完全统计,目前至少有30多个京东 web 项目正在使用 NutUI 。

    为什么说作为程序员分库分表必要性一定要掌握?

      互联网大厂程序员必须掌握海量数据和高并发问题处理技能,期望进入大厂程序员一定要仔细看这篇! MySQL 分库分表是做什么? 相信很多程序员对 MySQL 都比较熟悉了,目前国内