android 如何分析应用的内存(十六)——使用AS查看Android堆

在前面,先介绍了如何使用jdb和VS code查看应用栈相关内容。 本文将介绍,如何查看堆中的内容。大概有:

堆中的对象,有哪些堆中的对象,由谁分配堆中的对象,引用关系是怎么

堆中的对象有哪些,以及他们的引用关系——使用堆转储

要查看当前堆中的对象,需要使用工具将堆数据dump出来。

接下来,我们使用Android studio自带的memory profiler进行操作。

第一步:打开Android profiler 在Android Studio中,可以按照如下的步骤,打开memory profiler

在上图中,出现了两个选择,分别解释如下:

profile xxx with low overhead:性能分析器只会启用cpu性能分析器和 内存分析器,在内存分析器中,只有Record native allocations为启用 状态(即,录制native分配的功能为启用状态)profile xxx with complete data:启用所有的分析器,包括cpu分析器, 内存分析器,功耗分析器。

注意:除了上图通过图标启动以外,还可以通过菜单启动:Run->Profile.然后 选择要进行性能分析的模块。

注意:在性能不是很好的电脑上,可以单独运行Android profiler。如下: android studio安装目录/bin/profiler.xx(mac,linux为profiler.sh, windows为profiler.exe)

启动之后如下图

注意:一个session就表示一次性能分析,因此可以在Android Profiler中新建session,然后选择不同的应用进程。如下图:

第二步:打开Memory profiler

在上图中,点击memory的任何区域,打开memory profiler。下图展示了各个区域的具体意义。

如上图,各个类型解释如下,也可以参考,本系列的第一篇文章:[android 如何分析应用的内存 (一)——内存总览]http://t.csdn.cn/HN1Ma

Java:java或kotlin分配的对象的内存Native:c或者c++分配的内存Graphics:图形缓冲区队列为了向屏幕显示像素使用的内存,如GL表面,GL纹理。他们不是GPU专用内存,而是与cpu共用的内存Stack:java栈和native栈的内存,这个跟运行线程的多少有关Code:应用用于处理代码和资源的内存,如dex字节码,so库,字体等Others:无法确定分类的内存Allocated:应用分配的对象个数。在上图中是:N/A。即无法统计,如果能够统计,则会显示一跟虚线,Y轴则对应于图像的右侧,表示多少个对象数。

第三步:使用capture heap dump

为了查看堆中有多少对象,使用capture heap dump捕获当前的堆。如下图:

从上图可以看到,总共有727个类,所有对象按照类名,列在了列表中

对于上图的几个标记,分别解释如下:

标记1:选择不同的堆进行查看。有如下的堆可以查看

image heap:镜像堆,包含启动期间预加载的类。此处的分配不会移动或消失。zygote heap:zygote堆,继承于zygote进程,包含系统资源,类库。app heap:应用的分配的主堆JNI heap:jni引用的堆

注意:如何理解这四个堆,请见本文后面部分:如何理解Image heap,zygote heap,app heap,JNI heap

标记2:选择不同的排序方法,有如下几种:

arrange by class:按照类名排序arrange by package:按照报名排序arrange by callstack:按照调用栈排序。

注意:按照调用栈排序,在capture heap dump中不支持此功能,欲支持此功能需要使用:Record java/kotlin allocations。见后文:堆中的对象由谁分配

标记3:对类进行条件过滤,有如下几种:

show all classes:显示所有的类show activity/fragments classes:显示可能的Activity和fragment的泄露

注意:此处使用了”可能“两个字。事实上,memory profiler显示的泄露,不一定是真正的泄露

show project classes:显示本project中的类。 标记4:Allocations表示分配的次数,如第一排表示MaterialTextView分配了1次,即分配了一个对象 标记5:Native size表示该对象native的大小。尽管只有java代码或者kotlin代码,在某些情况下,依然会存在native的大小,因为java可以使用jni操作native的内存。上图第一行,表示native大小为0 标记6:shallow size表示本对象自身所具有的大小,有时又称为flat size.它不包含内部引用对象的大小。 标记7:Retained size表示本对象自身大小加上内部引用对象的大小。可以直接理解为:若该对象被回收,heap将会释放的大小,上图第一行表示:若MaterialTextView被回收,将会释放10381个字节

注意注意:如果A对象引用了E,C对象,而B对象也引用了E,C对象。那么A对象的retained size会包含E和C吗?B对象的retained size会包含E和C吗?关于retained size的计算,请见后文:如何计算 Shallow Size和retained size

标记8:搜索框,后面两个复选框表示是否大小写敏感,是否使用正则。

第四步:查看各个对象的引用关系

为了举例说明引用关系,现在写一个测试用的链表。如下

//首先定义一个测试类

public class WanbiaoTest{

public String value = "wanbiao_test";

public WanbiaoTest next ;

}

//链表的头,用字母o表示

private WanbiaoTest o ;

//构建测试链表

public void do(){

for(int i=0;i<10;++i){

if( o == null){

o = new WanbiaoTest();

}else{

WanbiaoTest p = o;

while(p.next != null){

p = p.next;

}

p.next = new WanbiaoTest();

}

}

}

把上述代码运行之后,按照第一,二步dump出heap如下图。然后按照图中步骤进行查看引用

在上图中。

通过键入类名,快速定位到要查找的类。然后点击类名之后,出现对象列表。在对象列表中,我们看到各个不同的对象。因为WanbiaoTest对象按照链表组织。所以Depth一列,将会出现不同的深度。任意选择一个对象,右侧出现该对象的详细信息。点击references栏,则查看它的引用情况。

从图中可以看到:选中的对象一直通过next被引用。最顶层的WanbiaoTest被o引用着,它存在于MainActivity中。而MainActivity这个对象存在于ActivityThread$ActivityClient对象中。 整个引用链如下图

如果要查看某个具体的对象,还可以右键点击对象,然后选择:go to instance

注意:除了查看”到最近GC root的引用链“以外,还可以查看所有的引用,即去掉:show nearest GC root only,即可查看该对象被引用的所有对象

上面只是展示了,如何查看对象之间的引用关系。那么该如何去确定内存是否泄露了呢?为了回答这个问题,我们还需要先学习如何查看这些对象是由谁分配的。

堆中的对象由谁分配——使用Record java/kotlin allocations

要想知道对象由谁分配,即知道分配该对象的调用栈,因此可以按照如下的步骤,录制应用的调用栈信息。如下:

第一步:录制调用栈信息 如下图开始录制

如下图结束录制

录制结果如下

详细信息见图中标注,未标注地方,前文已经介绍过。

在这里还需要注意一点:在结束录制按钮边上有一个下拉单选框,当前为Full。可选的选项有:

Full:录制所有的对象分配,这会导致app性能大幅下降Sample:以特定的采样间隔(采样率)来采样内存分配。关于采样间隔和采样率的具体细节描述见:[android 如何分析应用的内存(十三)——perfetto]http://t.csdn.cn/laqYB中:heapprofd为什么性能好

第二步:查看对象调用栈

选中待查看的类,然后出现对象列表,然后选中某个对象。如下图

至此,可以查看某个对象的调用栈信息了。

除了前面介绍的表格查看以外,还可以通过火焰图来查看。选择table边上的Visualization切换到火焰图模式下。如下图

上图火焰图中,函数跨度越大,则表示选择对应的值越大(即Allocation count,Allocation Size,Totaol Remainning Size,Total Remaining Count之一)

自此,介绍了工具如何查看堆中对象,以及对象的引用关系,还有对象的调用栈信息。

接下来使用两个小小的例子,作为实战

综合运用上面的工具——实战1,Activity泄露

在这个例子里面,我们手动造就了一个Activity的泄露。我们考虑如下的场景:

我们有一个设备管理器叫做DeviceManager.它是一个单例的对象。该设备管理器,有两个接口,分别用于注册和销毁设备状态的监听器。如下

public class DeviceManager {

//单例对象

private DeviceManager() {

}

private static class DeviceManagerHolder {

private static final DeviceManager INSTANCE = new DeviceManager();

}

public static final DeviceManager getInstance() {

return DeviceManagerHolder.INSTANCE;

}

//定义监听器接口

public interface DeviceChangedListener{

void onChanged(int oldStatus,int newStatus);

}

private ArrayList listeners = new ArrayList<>();

public void addListener(DeviceChangedListener listener){

if(!listeners.contains(listener)){

listeners.add(listener);

}

}

public void removeListener(DeviceChangedListener listener){

listeners.remove(listener);

}

}

现在有我们的业务对象Class Task,需要做如下的操作。在Task创建时,向DeviceManager注册一个监听。在Task销毁时,从DeviceManager注销掉监听。代码如下:

//Task自我监听,Device的状态改变

//看上去这是一个较好的封装

public class Task implements DeviceManager.DeviceChangedListener {

private Runnable mTaskRunnable;

public Task(Runnable task){

mTaskRunnable = task;

DeviceManager.getInstance().addListener(this);

task.run();//运行其他业务

}

@Override

protected void finalize(){

//回收的时候,注销掉监听器

DeviceManager.getInstance().removeListener(this);

}

@Override

public void onChanged(int oldStatus, int newStatus) {

Log.i("Task","oldStatus = "+oldStatus+"newStatus = "+newStatus);

}

}

接下来在Activity的onCreate中创建任务,并开始执行。代码如下:

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

binding = ActivityMainBinding.inflate(getLayoutInflater());

setContentView(binding.getRoot());

//执行业务

class TaskRunable implements Runnable{

private Context mContext;

public TaskRunable(Context context){

mContext = context;

}

@Override

public void run() {

// do something

}

}

Task t = new Task(new TaskRunable(this));

}

接下来我们模拟用户的操作。

我们将横竖屏颠倒几次。然后强制调用GC。如下图

然后使用capture heap dump查看是否有内存泄露。如下图

从上图我们可以看到,memory profiler已经提示了Activity的泄露。

思考:为什么memory profiler能够检查到Activity的泄露。 回答:因为当Activity destroy之后,如果还能从GC root可达,则表示泄露

在此处,我们并不知道是何处的代码导致的泄露,为了找到泄露,我们按照如下图中的步骤进行操作。 从上图我们可以看到如下的调用链

从中我们找到了Activity的泄露原因,TaskRunnable持有了其强引用。那么我们将其改为弱引用,是不是就会好了呢?如下:

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

binding = ActivityMainBinding.inflate(getLayoutInflater());

setContentView(binding.getRoot());

//执行业务

class TaskRunable implements Runnable{

//将强引用改为弱引用

private WeakReference mContext;

public TaskRunable(Context context){

mContext = new WeakReference<>(context);

}

@Override

public void run() {

// do something

}

}

}

改成上面的代码之后,再次使用Memory profiler进行heap dump。你会看到如下结果

非常滴不幸!!!依然没有解决泄露。这种问题出现在什么地方?按照上面查看引用的步骤,得到如下的图

从图中可以看到,TaskRunable对象内部有一个this$0的引用指向了MainActivity.

哦!!,原来如此,这个this$0是内部类对外部对象的一个引用,因此为了解决这个问题,将 class TaskRunable移动到MainActivity之外。

再次进行heap dump,这次,终于没有Activity的泄露了!!!

综合运用上面的工具——实战2,对象泄露

上面实战1,真的就没有泄露吗?假若TaskRunnable含有一个大对象呢?它将会表现如何?

为了模拟TaskRunnable含有一个大对象,我们在内部增加一个整数数组,如下:

class TaskRunable implements Runnable{

private WeakReference mContext;

//1024*4=4096byte 等于4KB.模拟一个大对象

private int[] values = new int[1024];

public TaskRunable(Context context){

mContext = new WeakReference<>(context);

}

@Override

public void run() {

// do something

}

}

在经过一系列的内存测试之后,发现java内存一直在增大,并且GC也无法回收(蓝色部分),如下图:

注意:关于Android 应用如何测试其内存,后续有时间再写,本系列专注于如何分析内存。不过好在内存测试比较容易。事实上,可以按照:http://t.csdn.cn/ovFmO。写一个实时监控脚本即可。当然还可以搭配procstats服务,见http://t.csdn.cn/4Qp9t最后一小节的procstats推荐

为了找到这个内存问题,我们将使用heap dump,看看内部的细节。如下图:

上图可以看到,并没有提示有什么对象泄露了。那么怎么查找这种泄露问题呢?我们观察到java内存在持续增大,我们怀疑,是heap中某个对象分配太多次,而没有被释放。为此,我们点击Allocations(即分配次数)进行倒序查看

从上图可以看到,allocations最多的分别是:int[],WeakReference,FinalizerReference,TaskRunable,Task这几个类。 再从Shallow size可以看到,最高的为int[]. 几乎可以肯定导致内存泄露的对象即为int[].按照上面的学习,我们来看看其引用链。如下:

从图上可以看到,导致泄露是因为注册到DeviceManager的对象没有及时的注销掉。

为了解决这个问题,DeviceManager应该在合适的时候,移除其中不再使用的监听器。从Task的finalize函数可以知道,当类不再使用的时候,应该由GC移除监听器。因此,将DeviceManager的listener设计成弱引用。代码过于简单,不再附上代码。

改成弱引用之后,不再出现对象的泄露。

注意:上面的代码,依然不能当做工程实践。因为当Task不再使用,而GC还未回收时,若DeviceManager里面确实有状态发生变化,将会通知到Task,此时若Task有相应的处理逻辑,可能会导致问题。故,此处应该谨慎处理。不过上面的例子仅仅是为了说明内存工具的使用。

上面的两个例子,太过于清晰明了了。他们仅仅是为了说明memory profiler的使用。事实上,真正的内存关系可能比上面复杂得多。不过介于篇幅不再展开,若有机会后续补充。

在结束本文之前,似乎还有两个小问题需要解答:第一个,Android的Image heap,zygote heap,app heap以及JNI heap都是什么。第二个:retained size如何计算

如何理解Image heap,zygote heap,app heap,JNI heap

为了说明这四个堆,我们从启动开始说起,然后简单概括如下

Android系统启动的时候,会创建一个进程,叫做zygote进程。zygote进程作为第一次启动,会加载很多很多的资源,包括一些系统资源。然后再运行。 当Android要启动另外一个进程时,并不会再次像zygote一样,从头再来。而是直接fork zygote进程。然后复用zygote进程中的部分资源,其中zygote的一个特殊堆,就会被复用。这个堆就是第一步中,加载系统资源的堆。这个堆的名字就叫zygote heap。如果应用需要修改这个堆中内容,此时应用才会新建一个堆,然后拷贝zygote heap中的内容到新堆中,然后再修改,即:写时复制 当Android的虚拟机启动之后,需要去加载经过优化的字节码。将这些经过优化的字节码被映射到一个特殊的堆中,方便以后直接使用,这个堆就叫做Image heap 当Android应用起来之后,需要分配对象,那么就从app heap中分配对象,这个堆就是我们应用的主堆 当Android应用在使用过程中,使用了JNI 引用,则会将这些JNI 引用单独放在一个堆中,这个堆就是JNI heap

如何计算 Shallow Size和retained size

shallow size:即对象本身的大小,而不用计算它内部引用对象的大小。如下:

class A{

int a;

A aInner;

}

那么A对象的shallow size = 4(a为int占四个字节)+4(aInner为引用占四个字节)+8(为Object对象中的字段)= 16字节

注意:有时候,为了保证4字节对齐,当不满4字节的倍数时,也会变成4字节的倍数。为什么要四字节对齐?这源于内存总线为了提高内存访问效率。此处不表,可自行百度

retained size:对象本身的大小,加上它内部引用对象的大小。但一个对象被多个对象引用怎么办呢?如下图

要解决这个问题,我们需要知道一下dominator tree(支配树)。

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

根据上面的关系,可以得到如下的支配树图形(右图)

对上图说明:

D直接支配:E,FB直接支配:D,G,H

那么reatined size就是根据这个支配树来计算。 E retained size = E shallow size F retained size = F shallow size H retained size = H shallow size G retained size = G shallow size D retained size= E shallow size+ F retained size + D shallow size B retained size = D retained size + H retained size + G retained size + B shallow size

至此,本文完。

下一篇文章,依然是java的堆内存,将使用另外的两个工具,分别是perfetto和mat进行堆内存分析。敬请期待。

精彩文章

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。