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<DeviceChangedListener> 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<Context> 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<Context> 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服务,该服务将在后续文章中介绍。
为了找到这个内存问题,我们将使用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,F
- B直接支配: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进行堆内存分析。敬请期待。