讲关于内存泄露之前,先抛出一个问题,两个相互引用的对象是不是一定会引起内存泄露?回答这个问题之前就需要理清内存泄露是怎么产生的。首先,分配了内存的对象是可达的,既可追溯到根节点的,其次,这个对象是没用的,既再也用不到这个对象。这个时候,垃圾收集器因为相关的对象是可达的,因此无法标记为垃圾,但它又没什么用,且占内存,因此这就是内存泄露。
我们是不是可以这么想,如果已经分配的对象无法追溯到根节点,既如果对应的根节点已经置空,是不是就可以被垃圾收集器标记了。因此,从源头根节点开始,查看是哪个引用了该对象,然后又因为根节点没有及时释放,从根节点开始,还存在有向图到该对象节点,那么这种情况必定存在内存泄露。因此解决的思路不就是,找到根节点,然后找到从根节点(包括根节点)下来各路的引用对象,查找可能没解决的,这样就可以快速找到泄露的原因了。实际上,一般都是因为根节点没有释放引起的。
既然涉及到根节点,那么就有必要了解下什么样的对象可以作为根节点?
在Java中,其实很好理解,有一下几类可以作为根节点:
- 虚拟机栈中(虚拟机栈中的本地变量表)引用的对象。如下列子, 在栈帧对一个的method01方法中,局部变量t既为根节点,在method01方法还没执行完成的情况下,t作为GC roots不会被回收,当method01执行栈帧返回时,局部变量跟随方法消失,此时进行GC既会回收没被t引用的对象。
public class TestGCRoots01 {
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];
public static void main(String[] args) {
method01();
System.out.println("返回main方法");
System.gc();
System.out.println("第二次GC完成");
}
public static void method01() {
TestGCRoots01 t = new TestGCRoots01();
System.gc();
System.out.println("第一次GC完成");
}
}
- 方法区中类静态属性应用的变量。例子如下,main方法执行完之后,因为t属于类静态变量,存于方法区中,所以执行GC之后,回收了t2引用的对象,而t引用的对象不会被回收,因为类静态属性t为根节点没被释放。
public class TestGCRoots02 {
private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;
private static TestGCRoots02 t;
public TestGCRoots02(int size) {
memory = new byte[size];
}
public static void main(String[] args) {
TestGCRoots02 t2 = new TestGCRoots02(4 * _10MB);
t2.t = new TestGCRoots02(8 * _10MB);
t2 = null;
System.gc();
}
}
- 方法区中常量引用的对象,这一点同2中的类似;
- 本地方法栈中JNI(Native方法)中引用的对象,这一点同1中的类似。
上面是关于Java GC根节点,那么在Android中,又是哪些可以作为GC root对象呢?,以下根节点都有对应的table来遍历
- 被加载到虚拟机的系统类对象;
- 在虚拟机内部创建的原子类,如Double、Boolean类等。
- 线程和线程块引用的对象
- JNI本地变量和JNI全局全局引用的对象
- 位于虚拟机栈线程所引用的对象
- 等待finalizer运行的垃圾可回收对象
- 有一个finalize方法,但还没finalized和还没在finalizer队列上的对象
- 对象不可达,但是被MAT标记成root的对象
- Java栈帧
- 在虚拟机内部创建的OutOfMemory异常对象、Internal异常对象以及NoClassDefFoundError异常对象
知道了根节点的出处,我们查找内存泄露就事半功倍了,只要根节点被释放了,那么相关的引用对象才能得以释放,才不会发生内存泄露的问题。因此,知道什么是根节点是解决内存泄漏等垃圾回收重要的一个知识点。
单纯知道哪些是根节点只能在编码上尽量避免内存泄露,但在实际的开发生产过程中,我们经常会因为疏忽等而发生内存泄露问题。那么如何查看哪些引用的对象存在内存泄露了,这个时候我们就需要借助工具帮我们查找了。
现在查找内存泄露的工具很多,笔者比较懒,只想一个Android Studio就能查出问题所在。因此,自然就想到用Profile工具,实际上,这个Profile太好用了,你可以大体知道,Java的堆是如何慢慢增大的、哪里发生卡顿了、哪里网络请求比较差、可以具体到哪一步哪一个函数占用了比较多的时间等等。
这里,我们只讲如何借助Profile查找内存泄露的。打开profile工具(工具怎么使用可以谷歌百度),首先进入预判发生内存泄露的页面,然后退出页面,然后点击左边红圈的图标,表示进行垃圾回收,执行完成之后,点击右边红圈的图标,进行dump相关依然存在的heap。通过查看相关的heap中有没有还存在已退出页面相关的引用对象存在,存在的话就可能会发生内存泄露了。
笔者在开发过程中,遇到一个泄露日志是LazDetailActivity存在泄露的风险,于是通过接着Profile,dump的结果如下,通过查找关键字,得到已退出的页面LazDetailActivity仍然存在,相关的TaoLiveVideoView也仍然存在。
接着说明如何查找内存泄露问题。根据上图dump下来的,笔者把问题瞄准到对应的listener,一般情况,我们都习惯registerListener和ungRegisterListener配套,那么问题来了,是不是只用regiseterListener而没调用unRegisterListener就一定会发生内存泄露了,其实不然,正如我们上面分析的,只要根节点能释放,那么就可以不用。而为什么还要ungRegisterListener呢?原因之一就是可能在registerListener挂钩的根节点没有得到释放,那么通过来ungRegisterListener保证可以顺利被回收。而在查找过程当中,发现reisterListener都是正常注册到对象成员变量中,不存在所谓的根节点。接着,查看相关的类有没有类静态属性、发现也没有。接着,查看到本地代码中有块代码如下:
private void sendMsg(Message msg) {
Log.i(TAG, this + "\tsendMsg");
synchronized (mHandlerLock) {
if (mHandler == null || mThread == null || !mThread.isAlive() || mHandler.getLooper() == null) {
Log.d(TAG, this + "\tplay thread not ready, create...");
mThread = new HandlerThread("lazvideo_play");
mThread.start();
mHandler = new PlayHandler(this, mThread.getLooper());
}
mHandler.sendMessage(msg);
}
}
这块代码引起了我的关注,起初我一开始认定泄露应该是从这里引起的,一,HandlerThread启动了线程在跑,满足作为根节点(参考上面根节点),二还包含了Handler,当我以为问题很容易就解决的时候,我查看了下PlayHandler,结果该Handler是静态的,且对this加了弱引用,因此,在垃圾回收的时候,不存在回收不了this指向的对象问题。之所以找到这一步,一是遵从查找根对象,二是因为我们SDK提供给第三方业务接入的时候,因为我们架构设计不完善的问题,我们有两个类分别需要用到释放资源的,这两个类中释放资源的方法最终都会调用到上面方法sendMsg,只不过其中一个会对mThread、looper进行关闭,一个可能不小心又创建了线程,正是因为调用方可能存在方法调用顺序的不同,导致这个线程没有关闭。然而,这个并不是导致内存泄露问题,只是线程没停止而已。最后,把问题瞄准了Handler,在另一个类中,执行了Handler handler = new Handler(this)。显然,这块是潜在存在内存泄露的,message.target持有handler对象。然后查看OnDestroy看看有没有做释放,看到Handler置空了,那么这个置空是否就已经没问题了,其实仍然存在内存泄露的风险,因为在发送延时消息的时候,message已经持有callback这个对象了,且target已经指向handler所在的对象。那么只是置空handler,并不能保证handler指向的对象没有其他引用引用,因此还需调用handler.removeCallbacksAndMessages(null)这个方法,把消息指向的callback都清空了。
通过这次的内存泄露查找,我们可以总结查找内存泄露是有规律可循的,就像垃圾回收如何收集对象一样。垃圾收集器一开始也要比较哪些是根节点,所以我们要先确认的是哪些是根节点,在页面退出的时候,根节点是否释放了。确认根节点下来的引用是否引用了View或Activity,因为引用了View,其实就间接了引用了Activity,而Activity是页面的对象容器。因此我们往往看到的泄露也是和Activity相关。