LJ的Blog

学海无涯苦做舟

0%

Android内存泄露浅析

写在前面

前段时间写了一篇MVP初尝试,由于当时只是刚接触,只是简单的实现,还有很多问题没想明白。关于内存泄露这事是本文着重要谈的一点,同时本文是我在看了很多关于Java和Android内存泄露分析的文章之后的所得。

概述

在了解MVP引起的内存泄露问题之前,我们首先要理解在Android中内存泄露是啥玩意?简单的讲内存泄漏就是** 本该被释放内存的对象没有被释放 *。最近也和同学@iamxiarui就内存泄露这个问题进行了一些讨论,最后发现要搞清楚这个东西,还要从Java层上找原因。学习Android的同学都应该知道,Java这门语言有一个垃圾回收器,一般来说我们是无需关心内存回收的问题。但是玩过LOL或者DOTA的同学都知道,一个猪队友和一个神对手究竟哪个威胁更大一些,我们不能当GC的队友,所以多了解一些这玩意吧。

在C++中会有析构函数这个概念,在C++中销毁对象必须用到这个函数,如此说来C++中是可以手动释放内存的。你可能会说Java中不也有finalize()方法吗?是的,是有这东西,让我们来看看这玩意。

finalize()

关于这货,在《Thinking in java》里说:

  • 不能指望finalize()。

在《effective java》里说:

  • 避免使用终结方法:终结方法通常是不可预测的,也是很危险的,一般情况下是不必要的。

至于为什么那么描述finalize()方法,原因如下:

终结方法的缺点在于不能保证会被及时的执行。

你以为这就完了?《effective java》中还有一段描述:

Java语言规范不仅不保证终结方法会被及时的执行,而且根本就不保证它们会被执行。

那么在很多由于生命周期所引发的内存泄漏问题上,我们就不能想着手动释放内存了,因为我们需要“及时”的释放内存,但是finalize()并不能满足我们的需求。那么我们应该想一些办法,“告诉”GC:我这是可以回收的,请回收这部分内存吧!

那么问题来了:我们该用怎样的方式告诉GC,并且让GC可以回收这部分内存呢?这是我们今天主要要解决的问题,但是我们首先要弄明白的是Java中关于内存的一些事。

Java内存分配策略

Java程序运行时的内存分配策略有三种,分别是静态分配,栈式分配和堆式分配。对应的,三种存储策略使用的内存空间主要分别是静态存储区、栈区和堆区。

  • 静态存储区:编译时就分配好,在程序整个运行期间都存在。主要存放静态数据和常量。
  • 栈区:当方法执行时,会在栈去内存中创建方法体内部的局部变量,方法结束后自动释放内存。
  • 堆区:通常存放new出来的对象,由Java垃圾回收器管理内存的回收。

很明显,本文需要关注的就是堆区了,堆内存用于存放对象实例,至于堆内如何划分,如何存放对象,这些东西都由具体的实现来决定。

Java内存管理

在我们对Java内存管理作了解之前我们需要抓住这个问题的核心:

  • 如何判定可回收对象
  • 采用什么策略

引用计数

首先介绍一种用于说明垃圾收集工作方式的策略,** 引用计数 **:

每个对象都含有一个引用计数器,当有引用连接至对象时,引用计数加1。当引用离开作用域或者被置为null时,引用计数减1。垃圾回收器在遍历所有对象时发现引用计数为0便释放其内存。这种策略很难处理循环引用的情况。不过我们无需过多的考虑此策略有何优缺点,这仅仅是用来让你了解一些垃圾回收的工作方式。而且现在JVM大多也不用这种策略来进行垃圾回收。

以上我们简单的了解了一下垃圾回收的大致流程,那么接下来我们来了解一下垃圾回收器如何判断一个对象是否可回收。

可达性分析算法(根搜索算法)

既然引用计数有缺点,那么可以采用其他的策略,Java采用了一种新的算法:可达性分析算法。

对象引用遍历从一组对象开始(GC Roots),沿着整个对象图上的每条链接,递归确定可到达(reachable)对象并生成一棵引用树,树的节点视为可达对象,反之视为不可达。之后垃圾回收器在进行垃圾回收的时候便可以回收那些不可达的对象。

我们以一个经典的例子来说明以上的东西:

1
2
3
4
5
6
public static void main(String[] args){
Object o1 = new Object();
Object o2 = new Object();
o2 = o1;
//此为第五行
}

用一张图来表示到第三行为止时的示意图:

第三行.png

而到了第五行时,这个情况发生了变化:

第五行.png

此时Obj2便是不可达对象,垃圾回收器在进行回收时便可以将Obj2的内存回收。以上是垃圾回收如确定可回收对象,接下来简要介绍一下垃圾回收的策略。

内存回收策略

  • 标记——清除(标记回收算法 或 Mark-Sweep)

从堆栈和静态存储区出发,遍历所有引用,进而标记出所有存活对象,在整个标记过程中不会有回收工作发生。当标记工作完成时,清理动作才会开始。在清理过程中,没有标记的对象将会被释放。

这种策略的缺点很容易想到,分配内存的时候是连续的堆空间,但是在释放之后内存空间是不连续的,如果要分配较大的内存,这些内存碎片是不行的。如果想要得到连续的内存空间就得提前触发gc整理内存空间。

一种对Mark-Sweep进行优化的便是Mark-Compact(标记整理算法)。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。这样就不会产生特别多的内存碎片了。

  • 停止——复制(复制算法)

垃圾回收动作发生的同时,程序将会被暂停(gc stop the world)。复制算法将可用内存分为大小相等的两块,在垃圾回收器释放内存之前,这块内存内存活的对象都会被复制到另外一块内存中,之后将已使用的内存空间清理掉。这么做优点是不容易产生内存碎片,缺点也是显而易见的,存活对象非常多的话,其效率会降低。

  • 分代回收算法

根据对象存活的生命周期将内存划分若干个不同的区域,一般划分为老年代(Old Generation)和新生代(Young Generation)。老年代的特点是每次gc时只有少量对象需要被回收,而新生代的特点是每次gc都有大量的对象需要被回收。这样就可以根据不同代的特点采取合适的策略,对于新生代采用copying算法,对于老年代使用Mark-Compact。

说真的,本来还想简介一下Davik或者ART虚拟机的,资料也找到了,但是从本篇来说不需要介绍那么多了,事实上甚至我觉得关于内存回收策略也不需要介绍……

四种引用类型

根据以上我们对于Java如何判定可回收对象的简介,我们可以对发生内存泄露的对象总结出以下特征:
1.在引用树上可达(被引用)
2.程序以后不会再使用这些对象了

强引用(Strong Reference)

在第一点中我们说对象被引用,其实指的是被强引用。说的好像很高大上的样子,其实我们平时用的大多都是强引用。

1
2
Person p = new Person();
List<String> list = new ArrayList<String>();

这类对象JVM自己抛OOM也不会通过GC回收这类对象。这是非常容易理解的,因为我们写代码需要一切是“可预料的”,如果我声明以上两个对象,竟然会莫名其妙的被JVM回收,那我只能和JAVA说再见了。当然了,我们也可以“提醒”gc回收该对象,比如将其引用置null—–>p = null; list = null。这样这两个对象便没有引用指向他了,下一次GC这两个对象便可以被回收了。

软引用(Soft Reference)

1
SoftReference<Bitmap> bm = new SoftReference<Bitmap>(bmp);

当一个对象只有软引用存在时,系统内存不足时会回收此对象。听起来还不错,但是在Android2.3以后,gc会很频繁,导致释放软引用的频率也很高,这无疑增加了程序维护的难度和不稳定性。所以如果有可替代的东西,就用别的来实现。

弱引用(Weak Reference)

发现就会被干掉的存在。

虚引用(Phantom Reference)

不做介绍。

MVP中的内存泄露

前面铺垫了

1
2
3
while(true){
System.out.println("那么");
}

长,终于说到重点了……是不是很激动,很期待?不管你是怎么想的,反正我是激动了~

在本篇中,其他可能引发内存泄露的东西,嗯,不分析。只分析耗时操作所引发的Activity或者其他V层实现类的内存泄露问题。熟悉MVP套路的同学应该会清除这么几点:
1.Model层获取数据
2.View层实现类执行回调的逻辑
3.Presenter层解除M和V的耦合,使M和V通过P层交互。

这么做肯定是有好处的,解除了M和V的耦合,他们俩互不感知,但是P层作为中间交互层不得不持有一个V层的引用和一个M层的实例。而当M层在进行一个耗时的操作时,由于P层是调用M层的逻辑实现一些功能,所以也可以将P层视为是一个耗时的操作。而且前面也说了,P层会持有一个V层的引用,如果在这个时候我们想要销毁这个Activity,那么这个Activity因为仍有P在持有Activity的引用从而导致其不会被回收,也就导致了内存泄露[** 注1 **]。恩,可能你看了我的纯文字描述有点头疼,没事,画张图你就好理解了(关系并不一定是这样的,但是方便理解):

画张图

可能你会有点奇怪,中间那个p咋整的啊,三个箭头指的你都晕了,但是MVP就是这么个套路啊~我们现在想要干的是释放activity的内存,那么按照我们之前说过的套路,虽然activity已经去掉了指向a的引用,但是p还没有去掉指向a的引用。那么显而易见的是如果presenter的生命周期长于activity的生命周期,恩,恭喜你内存泄露了。这种内存考虑值得我们更多的考虑[** 注2 **]

先放一个模拟MVP内存泄露的代码
首先是Model层的接口和实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import android.os.Handler;

/**
* Created by Luo on 2016/9/30.
* desc:
*/
public interface TestModel {
void sendMessageDelayed(Handler handler);
}


//实现类
package com.xiasuhuei321.studyforrxjava;


import android.os.Handler;

/**
* Created by Luo on 2016/9/30.
* desc:
*/
public class TestModeImpl implements TestModel {
public static final int MESSAGE_DELAY = 0;

@Override
public void sendMessageDelayed(final Handler handler) {
new Thread(
new Runnable() {
@Override
public void run() {
handler.sendEmptyMessageDelayed(MESSAGE_DELAY, 200000);
}
}).start();
}
}

恩,上面我写的200000是我深深的怨念,本来写个2000,结果leakcanary这个检测内存泄露的工具貌似会调gc,结果成功回收了……尼玛我只要个现象啊……嗯,关于这一块回收掉的情况,我会在之后的情况里说明。接下来,放Presenter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import android.os.Handler;
import android.os.Message;

/**
* Created by Luo on 2016/9/30.
* desc:
*/
public class TestPresenter {

TestView testView;
TestModel testModel;

private Handler handler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case TestModeImpl.MESSAGE_DELAY:
TestPresenter.this.testView.getMessage();
break;
}
}
};

public TestPresenter(TestView testView) {
this.testView = testView;
testModel = new TestModeImpl();
}

public void getMessage() {
testModel.sendMessageDelayed(handler);
}
}

View层的接口和Activity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* Created by Luo on 2016/9/30.
* desc:
*/
public interface TestView {
void getMessage();
}


//activity
import android.content.Intent;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.Toast;

import com.squareup.leakcanary.RefWatcher;

public class MainActivity extends AppCompatActivity implements TestView {

private Button btTest;
private TestPresenter p;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
RefWatcher refWatcher = ExampleApplication.getRefWatcher(this);
refWatcher.watch(this);
p = new TestPresenter(this);
refWatcher.watch(p);
btTest = (Button) findViewById(R.id.bt_test);
btTest.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
p.getMessage();
startActivity(new Intent(MainActivity.this, SecondActivity.class));
MainActivity.this.finish();
}
});
}

@Override
public void getMessage() {
Log.e("MainActivity", "asdf");
}
}

来看一下内存泄露检测的情况

内存泄漏

结合代码我们可以发现的确是因为在presenter中因为持有testView的引用导致了MainActivity的内存泄露。

关于这种内存泄漏,我们利用以上对于Java内存回收、管理的策略的理解,可以这么解决:我们将presenter的生命周期和Activity的生命周期关联起来:

  • 在presenter中声明一个onDestroy()方法,在这个方法中将testView置为null,然后在presenter中凡是使用到testView的使用的,都判断一下是否为空。

  • 在activity的onDestroy()方法中调用presenter.onDestroy(),同时也将activity持有的presenter置空。

这样就可以解决MVP中由耗时操作和强引用导致的内存泄露的问题,是不是简单而优雅?(才怪)

当然了,以上方法里还有两个内存泄露我么有解决,那就是handler导致的activity内存泄露。handler在建立的时候会拿到当前线程的Looper,如果当前线程没有Looper就会报错,根据这个特性我猜测是因为取到线程这事导致的内存泄露。不过只是猜测,如果各位看官有知道这其中缘故还请告诉我。第二个是非静态内部类Handler所引发的内存泄露,Handler生命周期长于presenter,所以会引发presenter的内存泄露,你说我为啥不搞定?原理我都给你说了,刚好给你个机会去实践~(逃…)

内存泄漏

恩,刚和大佬越越聊了一下handler这事,他说了一个东西handler.removeCallbacks(null),我把他放在presenter的onDestroy()里搞定了……

1
2
3
4
public void onDestroy() {
this.testView = null;
handler.removeCallbacks(null);
}

这种解决的方式使我们根据自己的经验得出的最简单粗暴的解决方式,这样能有效的避免因testView持有activity的引用而导致的内存泄露问题。本来想试一下Rxjava+MVP,然后在对应的生命周期里unsubscribe()来解决内存泄漏的问题,但是用leakcancry检测一直会报和上面一样的内存泄露,而我试了各种方法都没能解决。虽然在leakcancry的android sdk所导致的内存泄露中貌似找到了这个

sdk内存泄露

问题是我的手机系统版本是6.0.1,按照他这个来说应该是被修复了的。而且很奇怪的一点是我在startActivty跳转到第二界面并finish自身才会报之前的内存泄露,不然的话直接返回桌面并finish是不会有内存泄露的,暂时没弄懂是什么状况,如果有人知道是为什么请务必告诉我,谢谢!

因为这个问题没解决,暂时不往下写了,但是我以上写的原理肯定是对的。我没能解决的问题那是因为我现在还不是Android系统的好队友,嗯,猪队友吧,先去搞会Android压压惊。关于注1,2我想说的其实是一件事:其实有的时候这种内存泄漏是** 可以接受 的,比如有时可能这种内存泄露所引发的后果只是 本次GC **无法回收这块内存,但是下一次呢?下一次耗时操作过了,这块内存没有引用指向他了,是可以被回收的。但是这也取决于你,你要是觉得不能忍受,那就麻溜的修复这些东西。

如果你对我发现的这个问题有兴趣,问题代码已经放在了:
demo地址
一不小心上传了配置文件……


以下为我写文时的参考资料,感谢各位大神无私分享的精神!

** java回收机制: **

杰风居-理解Java垃圾回收机制

老杨HJ-详细介绍Java垃圾回收机制

《Thinking in Java》& 《Effective Java》

** Android内存泄露分析: **

Android内存泄露总结

箫鉴哥-Android内存泄漏总结

QQ空间终端开发团队-Android GC那点事

我同学写的关于MVP中P造成的内存泄露的花式解决:

iamxiarui-Android:聊聊 MVP 中 Presenter 的生命周期

最后还要感谢一下square的开源项目:

内存泄漏检测工具