HAHA库实现原理剖析

1.前提

本文将以讲解原理为主,部分代码讲解,可以自行下载HAHA库源码进行对照学习。

implementation 'com.squareup.haha:haha:2.0.3'

如果你看过Leakcanary2.0之前的源码,你应该会熟悉HAHA库,它是一个解析hrpof文件的库,通过它我们可以获得当前内存的对象信息,包括如下信息。

1.获得对象的shallow size和retained size。

2.获得对象的引用链。

2.HAHA库的实现步骤:

1.Hprof(堆转存)文件的解析。

2.支配树的构造和Retained Size计算。

3.最短路径构建。

下面我将会从这几个主要维度进行介绍。

2.1 Hprof(堆转存)文件的解析。

    Hprof是一份存储了当前时刻内存信息的文件,即内存快照。可以方便我们进行内存分析。里面包括类信息、栈信息、堆信息。其中堆信息是需要我们关注的重点。堆信息包括了所有的对象:线程对象、类对象、实例对象、对象数组对象、原始类型数组对象。其中类对象可以获得该类的常量、静态变量、实例变量等信息。

2.1.1 Hprof 文件格式

    首先需要理解Hprof文件的格式,它的基本数据类型为u1、u2、u4、u8,分别代表1 byte、2 byte、4 byte、8 byte的内容,由文件头和文件内容(records)组成,如下图:
image

2.1.2 文件头格式如下:

长度 含义
[u1]* 以null为结尾的一串字节,表示版本号名称及相应的版本,比如JAVA PROFILE 1.0.3(一般是18byte + 1byte null字节)
u4 标识符大小,这些标识符代表UTF8的字符串、对象、堆栈等信息的id长度
u4 高位时间戳
u4 低位时间戳

2.1.3 文件头格式如下:

文件内容格式如下:

长度 含义
u1 TAG值,16进制地址,代表它是一个什么类型的信息,它是一个16进制的值,比如strings、堆信息等record
u4 时间戳
u4 LENGTH,即BODY的字节长度
[u1]* BODY,具体内容,即上述读取出的字节长度

2.1.4 文件内容格式如下:

string的格式如下,其他信息可以自行查阅:http://hg.openjdk.java.net/jdk6/jdk6/jdk/raw-file/tip/src/share/demo/jvmti/hprof/manual.html#mozTocId848088

图片.png

2.1.5 TAG类型信息:

HPROF_TAG_STRING = 0x01;字符串信息
HPROF_TAG_LOAD_CLASS = 0x02; 可以获得类id对应的字符串信息
HPROF_TAG_STACK_FRAME = 0x04;栈帧信息
HPROF_TAG_STACK_TRACE = 0x05;栈信息
HPROF_TAG_HEAP_DUMP = 0x0C;
HPROF_TAG_HEAP_DUMP_SEGMENT = 0x1C;这2个是所有的对象信息

如果TAG是HEAP_DUMP 或 HEAP_DUMP_SEGMENT ,它的BODY由一系列子record组成
HEAP DUMP 或  HEAP DUMP SEGMENT的sub-tag信息
// Traditional. 
HPROF_ROOT_UNKNOWN = 0xFF, 
HPROF_ROOT_JNI_GLOBAL = 0x01,        // native 变量  
HPROF_ROOT_JNI_LOCAL = 0x02,  
HPROF_ROOT_JAVA_FRAME = 0x03,  
HPROF_ROOT_NATIVE_STACK = 0x04,  
HPROF_ROOT_STICKY_CLASS = 0x05,  
HPROF_ROOT_THREAD_BLOCK = 0x06,  
HPROF_ROOT_MONITOR_USED = 0x07,  
HPROF_ROOT_THREAD_OBJECT = 0x08,  
HPROF_CLASS_DUMP = 0x20,            // 类  
HPROF_INSTANCE_DUMP = 0x21,         // 实例对象  
HPROF_OBJECT_ARRAY_DUMP = 0x22,     // 对象数组  
HPROF_PRIMITIVE_ARRAY_DUMP = 0x23,  // 基础类型数组   
// 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.

2.1.6 Basic Type:

在类对象中可以获得所有实例变量的type,通过type我们即可以知道它是一个什么类型。

长度 含义
2 object
4 boolean
5 char
6 float
7 double
8 byte
9 short
10 int
11 long

通常我们关心的主要是三类信息:

  1. 字符串信息:所有的字符串,包括类名、实例名、变量名等。
  2. 类的结构信息:父类信息、变量布局等。
  3. 堆信息:包括GC ROOT、普通对象、普通对象数组、对象之间的引用等。

2.2 GC ROOT

HAHA库就是通过上述规则读取获得类对象、实例对象、对象数组、基本类型数组、GC ROOT等信息。
在所有对象中,会有一部分虚拟机认定的GC Root。
什么是GC Root:首先我们需要了解一下JVM的垃圾回收算法,其中一个是可达性算法,可达性的意思是如果存在从一个起点到该对象的引用链,该对象就不能被回收,该起点就是GC Root。
GC Root的特点:当前时刻存活的对象!这是肯定的,只有引用是活跃的,那么它所直接引用和间接引用的对象必然是有用的,是不能被回收的。
可以被当成GC root的类型:

  • Stack Local:当前活跃的栈帧中指向GC堆中的引用(局部变量、方法的引用类型参数)
  • Thread:存活的线程
  • JNI Local:Native栈中的局部变量
  • JNI Global:Native全局引用
  • Monitor Used:用于同步的监控对象
  • Held by JVM:用于JVM特殊目的由GC保留的对象。包括类加载器、JVM中重要的异常类等。

3.构建支配树:

在Hprof这份文件中,将解析出来的对象之间建立一个引用与被引用的关系,然后再为所有的GC ROOT设置一个超级源点,这样就会形成一个以超级源点为根的引用树(中间可能会存在环)。

3.1 Shallow Size 和 Retained Size

3.1.1 Shallow Size:

某个对象的Shallow Size就是对象本身的大小,不包含引用的大小。
下面是对象的内存结构:包括下面几个部分:

  1. 对象头:包括Mark Word和Klass Point(指针,指向方法区的Class对象信息),下面还分三种大小
  1. 在32位系统下,Mark Word是4byte,指针大小是4byte,所以对象头为8byte
  2. 在64位系统下,Mark Word是8byte,指针大小是8byte,所以对象头为16byte
  3. 在64位系统下并开启指针压缩的情况下,Mark Word是8byte,指针大小是4byte,所以对象头为12byte
  1. 实际数据:就是实例对象的类型大小,比如int,String等大小
  2. 对齐填充:这个并不一定会存在,因为要求对象起始地址必须是8字节的整数倍。


    图片.png

    比如下面这个类:

public class A {
    int a;
    B b;
}

在64位系统并开启指针压缩的情况下,它的Shallow Size = 12 (对象头)+ 8(实际大小)+4(对齐填充)= 24byte。

3.1.2 Retained Size:

Retained Size的大小简单来理解就是其支配的所有节点的Shallow Size之和,这里有一个支配的节点的概念,在对象中的应用就是对象A->B->C,这中间没有任何引用指向B或者C,这就是A支配B,B支配C,A支配C。
比如下面这张图,我们来求对象A的Retained Size,对象A有实例对象B、C、D,正常来说就是B、C、D的Shallow Size之和就是它的Retained Size,但是对象D(D有可能是一个全局的实例)也被对象E所引用,所以对象D的支配点就不是A而是最顶层的O,因此对象A的Retained Size = B + C的Retained Size。


图片.png

换句话说Retained Size就是某对象被VM回收后所能释放的内存大小。
Retained Size对于内存的分析有很大的指引作用,该值大有可能是不合理的内存使用或者泄露。

3.2 支配树

再来看一下支配树的定义:对于有向图D,起点S,终点T,存在S>多条路径>T,在所有的路径中S到T必经的点称为支配点,删了该点,将不存在S到T的路径,因此称起点为S时,该点支配了T。离T点最近的支配点称为直接支配点。
S>T中可能有很多支配点,由这些支配点构成的树,称为支配树
支配树的性质:

  • S为树根
  • 根到树上任一点的路径经过的点均为根支配的点
    任意子树也有上述性质。
    有木有觉得和求Reatined Size的流程很像,也就是说,在求某个对象的Reatined Size,只要构建相应的支配树,然后进行其支配节点的相加即可。
    支配树的生成:
    一般来说,对于一张DAG(有向无环图)来说,可以通过拓扑序+LCA(最近公共祖先)的方法来构造支配树。但是对于实际对象的引用关系,有可能会存在环,也就是普通的有向图。下面我先介绍一下DAG构造支配树的过程:

3.3 拓扑排序

定义:它是对有向图的顶点排成一个线性序列。
在图论中,拓扑排序是一个有向无环图(DAG)的所有顶点的线性序列。且满足下面两个条件:

  • 每个顶点出现且只出现一次。
  • 若存在一条从顶点A到顶点B的路径,那么在序列中顶点A出现在顶点B的前面。
  • 拓扑序可以有多个。
    拓扑序的求得方式可以自行查资料,我这里只说结果。求拓扑序中第x点的直接支配点时,该x点前的直接支配点已经求得完成,也就是支配树已经构造好,这时候就可以对点x的所有父亲求LCA求得直接支配点。举个例子,对于下图(数字代表求好的拓扑序号):


    图片.png

    比如我们现在想求7的直接支配点,则说明1-6的支配树已经构造完成,只需要求7所有父类的LCA即可
    。即下图:


    图片.png

    上面是一般情况,正常来说引用都是存在环的,也就是说在求某个点的直接支配点时,它的有些父亲可能还没有找到自己的直接支配点,这时候子类当然也不能找到它自己的支配点,HAHA库中的做法是:如果某个父类没有支配点(也就是并未处理),就先跳过它,也就是认为少了一个父类,继续当前寻找直接支配点,等整棵树处理完毕以后,再迭代进行支配树的构建,直到所有的节点的支配树都找到且没有改变,至此结束。
    第一遍可能看的会有点蒙,我举个例子,比如下面的有向图:序号是已经排好的拓扑序
    图片.png

    我们现在求点3的直接支配点,具体步骤如下:

  1. 找到所有指向3的引用点(也就是1和4),求他们的最近公共节点。
  2. 1的支配点是它自己,但是4的直接支配点并未处理(第一遍还没轮到它呢),所以4的支配点肯定是null。
  3. 这种情况下我们本次就先去掉父节点4,所以本次3的直接支配点是1。
  4. 继续往后求4的直接支配点,即2和3的最近公共节点,也就是1。
  5. 重头开始构建支配树,再次轮到3求直接支配点,因为4的直接支配点上一次已经求得是1,所以3最终的支配点就是1。
  6. 在构造过程中所有节点的支配点未改变,结束支配树的构造。
    可以看到通过这种迭代的方式可以求得最终的构造树,但是缺点也显而易见,如果有向图过于复杂,时间复杂度会爆炸增长。其实构造支配树有一张更优的算法,Lengauer-Tarjan算法,不过我并没有去研究过,感兴趣的小伙伴可以去研究下。

3.4 Retained Size的计算

支配树构造完毕以后将每个对象的Shallow size加到各自的直接支配点上去就是某个对象的Retained Size大小。
上面就是HAHA库构造支配树和Reatined Size的整体流程。下面是代码实现:
//构建支配树并计算retainedSize

public void computeDominators() {
    if (mDominators == null) {
        //根据GC ROOT 计算拓扑序
        mTopSort = TopologicalSort.compute(getGCRoots());
        //根据拓扑序构建支配树
        mDominators = new Dominators(this, mTopSort);
        //根据支配树计算retained size
        mDominators.computeRetainedSizes();

        ShortestDistanceVisitor shortestDistanceVisitor = new ShortestDistanceVisitor();
        shortestDistanceVisitor.doVisit(getGCRoots());
    }
}

@NonNull
public static ImmutableList<Instance> compute(@NonNull Iterable<RootObj> roots) {
    TopologicalSortVisitor visitor = new TopologicalSortVisitor();
    visitor.doVisit(roots);
    ImmutableList<Instance> instances = visitor.getOrderedInstances();

    // We add the special sentinel node as the single root of the object graph, to ensure the
    // dominator algorithm terminates when having to choose between two GC roots.
    //设置一个超级源点
    Snapshot.SENTINEL_ROOT.setTopologicalOrder(0);

    // Set localIDs in the range 1..keys.size(). This simplifies the algorithm & data structures
    // for dominator computation.
    int currentIndex = 0;
     //设置拓扑序号,为后面查找最近公共祖先做铺垫
    for (Instance node : instances) {
        node.setTopologicalOrder(++currentIndex);
    }

    return instances;
}

@Override
public void doVisit(Iterable<? extends Instance> startNodes) {
    // root nodes are instances that share the same id as the node they point to.
    // This means that we cannot mark them as visited here or they would be marking
    // the actual root instance
    // TODO RootObj should not be Instance objects
    //将GC root压栈
    for (Instance node : startNodes) {
        node.accept(this);
    }
    //这里的算法是先根据右左根的方式放入队列
    while (!mStack.isEmpty()) {
        Instance node = mStack.peek();
        //这里会把各自的实例对象加入栈中
        //如果存在环,不会再进行入栈
        if (mSeen.add(node.getId())) {
            node.accept(this);
        } else {
            mStack.pop();
            //因为可能存在环,所以这里是防止重复写入
            if (mVisited.add(node.getId())) {
                mPostorder.add(node);
            }
        }
    }
}
//按照拓扑序反向去取出
ImmutableList<Instance> getOrderedInstances() {
    return ImmutableList.copyOf(Lists.reverse(mPostorder));
}

/**
 * Kicks off the computation of dominators and retained sizes.
 */
public void computeRetainedSizes() {
    // Initialize retained sizes for all classes and objects, including unreachable ones.
    for (Heap heap : mSnapshot.getHeaps()) {
        for (Instance instance : Iterables.concat(heap.getClasses(), heap.getInstances())) {
            //先重置变成shallow size
            instance.resetRetainedSize();
        }
    }
    //设置对象的支配点
    computeDominators();
    // We only update the retained sizes of objects in the dominator tree (i.e. reachable).
    for (Instance node : mSnapshot.getReachableInstances()) {
        int heapIndex = mSnapshot.getHeapIndex(node.getHeap());
        // Add the size of the current node to the retained size of every dominator up to the
        // root, in the same heap.
        //计算某个对象的retained size就是从顶点开始到该对象的所有shallow size之和
        for (Instance dom = node.getImmediateDominator(); dom != Snapshot.SENTINEL_ROOT;
                dom = dom.getImmediateDominator()) {
            dom.addRetainedSize(heapIndex, node.getSize());
        }
    }
}

//图中可能会存在环,所以有可能在处理某个点时父节点还未被处理,也就是没有支配点。
//当前的算法是如果父类没有支配点就先跳过,等父类的支配点计算完以后再迭代去计算,
//直到所有计算完所有的支配点即可。
//缺陷:如果图很复杂,话费的时间就会非常庞大。
private void computeDominators() {
    // We need to iterate on the dominator computation because the graph may contain cycles.
    // TODO: Check how long it takes to converge, and whether we need to place an upper bound.
    boolean changed = true;
    //有可能会存在环,进行迭代计算
    while (changed) {
        changed = false;
        //把之前排好的拓扑序进行对象支配点的设置
        for (int i = 0; i < mTopSort.size(); i++) {
            Instance node = mTopSort.get(i);
            // Root nodes and nodes immediately dominated by the SENTINEL_ROOT are skipped.
            if (node.getImmediateDominator() != Snapshot.SENTINEL_ROOT) {
                Instance dominator = null;

                //如果有多个指向该对象的引用,就寻找最近支配点
                for (int j = 0; j < node.getHardReferences().size(); j++) {
                    //拿到指向它的引用
                    Instance predecessor = node.getHardReferences().get(j);
                    if (predecessor.getImmediateDominator() == null) {
                        // If we don't have a dominator/approximation for predecessor, skip it
                        continue;
                    }
                    if (dominator == null) {
                        dominator = predecessor;
                    } else {
                        Instance fingerA = dominator;
                        Instance fingerB = predecessor;
                        //找到最近公共祖先,也就是最近支配点
                        while (fingerA != fingerB) {
                            //这里有个疑问,如果存在环的情况下,有可能拿出的支配点是null
                            //会有空指针?
                            if (fingerA.getTopologicalOrder() < fingerB.getTopologicalOrder()) {
                                fingerB = fingerB.getImmediateDominator();
                            } else {
                                fingerA = fingerA.getImmediateDominator();
                            }
                        }
                        dominator = fingerA;
                    }
                }
                //设置该对象的支配点
                if (node.getImmediateDominator() != dominator) {
                    node.setImmediateDominator(dominator);
                    changed = true;
                }
            }
        }
    }
}

3.最短路径构建:

HAHA库对于引用链的构造也比较简单,从GC ROOT开始,利用PriorityQueue先进先出的性质,将构造一条从GC root开始不断加到实例变量上的引用链。下面是实现

@Override
public void doVisit(Iterable<? extends Instance> startNodes) {
    // root nodes are instances that share the same id as the node they point to.
    // This means that we cannot mark them as visited here or they would be marking
    // the actual root instance
    // TODO RootObj should not be Instance objects
    //将GC root放入队列中
    for (Instance node : startNodes) {
        node.accept(this);
    }
    
    while (!mPriorityQueue.isEmpty()) {
        Instance node = mPriorityQueue.poll();
        mVisitDistance = node.getDistanceToGcRoot() + 1;
        mPreviousInstance = node;
        node.accept(this);
    }
}

@Override
public void visitLater(Instance parent, @NonNull Instance child) {
    //如果当前child不在队列中
    //(是GC ROOT || 指向child的软引用列表不存在 || 指向chaild的软引用列表不包括当前的parent || child是一个软引用
    if (mVisitDistance < child.getDistanceToGcRoot() &&
            (parent == null ||
                 child.getSoftReferences() == null ||
                 !child.getSoftReferences().contains(parent) ||
                 child.getIsSoftReference())) {
        child.setDistanceToGcRoot(mVisitDistance);
        child.setNextInstanceToGcRoot(mPreviousInstance);
        mPriorityQueue.add(child);
    }
}
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,653评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,321评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,833评论 0 324
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,472评论 1 266
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,306评论 4 357
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,274评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,658评论 3 385
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,335评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,638评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,697评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,454评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,311评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,699评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,986评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,254评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,647评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,847评论 2 335

推荐阅读更多精彩内容