Android 内存泄漏相关总结

提纲.png

一.Java内存分配结构复习

1.Java内存分配策略

上一篇Android内存管理分析总结中我们提到了Java内存分配策略,这里我们再复习一下:

JVM体系结构.png

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

  • 方法区(静态储存区):既然称为静态储存区,我们在代码中分配的静态域都存在于这个区域中;图中我们还可以看到该区域中存在一个“常量池”的区域,这个区域主要存放常量,包括我们在代码中通过final声明的字段(不包括局部的final)和各种编译器生成的常量。这里说一下,我们知道静态实例是在类加载时初始化,而这里的常量池是在编译期就分配好的
  • Java堆:又称动态分配内存区域,通场是指程序运行时直接new出来的内存,包括各种实例和数组该区域是垃圾回收的重点区域
  • Java栈:当方法执行时,方法体内的局部变量都在栈上创建,并在方法执行结束时这些局部变量所持有的内存将会自动释放。因为栈内存分配运算置于处理器的指令集中,效率很高,但分配的内存容量有限

2.Java栈与Java堆

在方法体中定义的局部变量,包括基本类型的变量和对象的引用变量都是在Java栈内存中分配的。当在一段方法中定义一个变量时,Java就会在栈中为该变量分配内存空间,当超过该变量的作用域后,该变量也就无效了,分配给他的内存空间就会被释放掉了。
  堆内存中用来存放所有new创建的对象(包括实例对象和数组)。在堆中产生了一个数组或者对象之后,可以在栈中定义一个特殊的变量,该变量的取值等于数组或者对象在堆中的首地址,这个特殊的变量就是我们上文所说的引用变量。我们可以通过这个引用变量来访问堆中的对象或者数组。

3.几个容易混淆的问题

(1).全局变量存放在哪里?
链接:https://www.nowcoder.com/questionTerminal/51123ddacab84a158e121bc5fe3917eb?toCommentId=23586
来源:牛客网

下列Java代码中的变量a、b、c分别在内存的____存储区存放。
class A {
    private String a = “aa”;
    public boolean methodB() {
        String b = “bb”;
        final String c = “cc”;
    }
}

A.堆区、堆区、堆区
B.堆区、栈区、堆区
C.堆区、栈区、栈区
D.堆区、堆区、栈区
E.静态区、栈区、堆区
F.静态区、栈区、栈区

这道题的答案是C。
  可以看到private String a = “aa”;这里的a是一个全局变量的应用,或者叫成员变量/实例变量的引用,这个引用是储存在堆区中的;“aa”未经 new 的常量,在方法区的常量区中。
  String b = “bb”;b 为局部变量的引用,在栈区;“bb”为未经 new 的常量,在常量区。
  final String c = “cc”;这句虽然有final作修饰符,但是由于是在方法中定义的,所以c仍然存在于栈中。

(2).在方法中new一个对象,这个对象在堆中还是在栈中?
class A {
    private ObjectA obj1;
    public boolean methodB() {
        obj1 = new ObjectA();
        ObjectB obj2 = new ObjectB();
    }
}

这段代码写的更加明确一点了,new ObjectA()与new ObjectB()这两个对象,只要是通过“new”来创建的,就一定是存在于堆内存中的;obj1obj2这两个对象的引用,obj2由于存在于方法块中,那么一定就是存在于栈中的,而obj1是A这个类的成员变量,笔者的理解也是:既然obj1是A的一个成员,那么当A的对象在堆上开辟内存的并实例化时候,也会一并将obj1这个变量放到堆中——因此它是存在于堆中的,但是并不能确定,这点有待于验证。

总之,记住一句话:局部变量位于栈区,静态变量位于方法区,实例变量及new出来的对象位于堆区!
  另:局部变量的基本数据类型和引用存储于栈中,引用的对象实体存储于堆中。—— 因为它们属于方法中的变量,生命周期随方法而结束;成员变量全部存储与堆中(包括基本数据类型,引用和引用的对象实体)—— 因为它们属于类,类对象终究是要被new出来使用的。

二.Java内部类

Java中引入内部类的最大理由在于“间接实现多重继承”——我们知道,Java中禁止了混乱的多重继承,通过接口来实现这一功能。但是,通过接口实现有很多缺点,比如说实现一个接口就得实现他其中的所有方法。而假如在外部类已经继承了一个类的基础之上,我们还有继承另一个类中某一个特定的方法或者属性,这个时候就可以通过内部类来继承——因为内部类类继承了另一个类中的属性之后,外部类就可以通过调用内部类中继承来的属性,从而间接的实现多继承。

1.成员内部类

也就是普通的内部类,相当于外部类的一个成员的位置,可以使用任意得到访问修饰符,如 public, protected, private 或是不加访问修饰符(包可见)。成员内部类有以下几个特点:

①.成员内部类是依附于外围类的,所以只有先创建了外围类才能够创建内部类

内部类中存在一个外部类的引用,内部类实例需要使用 OuterClass.InnerClass inner = outer.new InnerClass(); 的形式创建。这里直接拿一下网上博客中的代码来说明问题吧,笔者自己也没有反编译过,大家知道有这回事就行了:

public class Outer {
    private int id;
    private int state;

    public class Inner {
        private int field;
        public void displayOuterState() {
            System.out.println("Outer Id: " + id);
            System.out.println("Outer State: " + Outer.this.state);
        }
        public void setField(int field) {
            this.field = field;
        }
        public int getField() {
            return this.field;
        }
    }
    public static void main(String[] args) {
        Outer.Inner inner = new Outer().new Inner();
        inner.displayOuterState();
    }
}

反编译一下 Outer$Inner.class:

F:\Demos\InnerClass>javap -p Outer$Inner.class
Compiled from "Outer.java"
public class Outer$Inner {
  private int field;
  final Outer this$0;
  public Outer$Inner(Outer);
  public void displayOuterState();
  public void setField(int);
  public int getField();
}

其中 this$0 就是外部类的引用。也就是说,创建一个成员内部类之后,内部类就会隐式的持有外部类的引用。

②.外部类是不能直接使用内部类的成员和方法的,但是内部类可以直接访问外部类的成员,包括私有成员

内部类可以访问外部类的成员,这个很容易理解,通过上面的分析我们知道,内部类隐式的持有外部类的引用:
  反编译一下 Outer.class, 看到编译器在外部类添加了静态方法 access$000(Outer) 和 access$100(Outer), 内部类正是通过这两个方法来读取到私有的外部成员变量的。


F:\Demos\InnerClass>javap -p Outer.class

Compiled from "Outer.java"
public class Outer {
  private int id;
  private int state;
  public Outer();
  public static void main(java.lang.String[]);
  static int access$000(Outer);
  static int access$100(Outer);
}

外部类访问内部类的时候,必须先通过OuterClass.InnerClass inner = outer.new InnerClass();的形式创建内部类的实例,进而获取内部类的成员变量和方法。

③.成员内部类中不能存在任何 static 的变量和方法;

成员内部类中成员内部类中不能存在任何 static 的变量和方法,但 static final 的常量是可以的。
  因为内部类是要依赖外部类得实例的,而静态变量和静态方法是不依赖对象的,他在外部类初始化的时候就已经加载;而在加载静态域的时候外部类的实例尚未创建,自然会出错。
  但是static final是常量型,常量是在编译期就确定的,放在方法区的常量池中,因此可以。但是还是要注意一点,方法块中,即便是final类型也是存放在栈中的。

2.静态内部类

在定义内部类的时候,可以在其前面加上一个权限修饰符static。此时这个内部类就变为了静态内部类。笔者的理解是这样的,既然是静态的类,那么也是属于静态域的,也就是说该类是属于JVM的方法区的,而普通类或者普通类的对象,是属于堆内存的——因此我们可以知道,静态内部类和外部类对象没有任何关联,最好将静态内部类看作一个普通类,只是碰巧被声明在另一个类的内部而已。

①.静态内部类和外部类没有关联,也不能访问外部类中的非静态成员变量

静态内部类在编译期会单独生成一个.class文件,不像普通内部类那样会生成一个Outer$Inner.class。从这里我们可以看出,静态内部类和外部类基本是独立的两个类,没有什么关联,只是恰好声明在了外部类中而已。
  基于这个认识,静态内部类不能访问外部类的非静态成员变量方法就很好理解了——毕竟这是两个类,静态内部类又不持有外部类的引用,自然不能访问;但是外部类中的静态成员变量例外,因为静态成员变量是属于静态域的,或者说方法区的,静态内部类也是属于静态域的,两个在一个区域自然可以互相访问

②.创建静态内部类的对象时不需要通过外部类的对象来进行;静态内部类可以直接访问外部类的静态成员,包括私有的。

这个,感觉上面已经说的很清楚了,没什么好说的。

三.Android中的内存泄漏

前面两个大点都是在为内存泄漏做准备,好了现在我们来说说内存泄漏的事情。在Java中,内存泄漏指的是存在一些分配后的对象,在他变的无用(即程序以后不会再使用这些对象)之后,仍然通过GC Roots可达,因此GC不会回收这些已经无用的对象,这样的对象就被判定为内存泄漏

下面我们来说说Android中常见的内存泄漏:

(1).集合类造成内存泄漏

集合类如果仅仅有添加元素的方法,而没有相应的删除机制,导致内存被占用。如果这个集合类是全局性的变量 (比如类中的静态属性,全局性的 map 等即有静态引用或 final 一直指向它),那么没有相应的删除机制,很可能导致集合所占用的内存只增不减。如网上给出的这个例子:

Vector v = new Vector(10);
for (int i = 1; i < 100; i++) {
    Object o = new Object();
    v.add(o);
    o = null;
}

在这个例子中,我们循环申请Object对象,并将所申请的对象放入一个 Vector 中,如果我们仅仅释放引用本身,那么 Vector 仍然引用该对象,所以这个对象对 GC 来说是不可回收的。因此,如果对象加入到Vector 后,还必须从 Vector 中删除,最简单的方法就是将 Vector 对象设置为 null。
  这个例子是网上直接拷贝过来的,主要是很清晰的展示了集合类内存泄漏的泄漏。但是毕竟拿人家的手断嘛,下面我就举一个我在实际开发中的例子,笔者在做应用开发的时候,还真遇到集合类造成内存泄漏的情况:
  我们可能有看过“Android中优雅的退出所有的Activity”这类文章,在郭霖的《第一行代码(第二版)》中也有讲到过这种方式,具体的实现就是:

  • ①.创建一个自定义的myApplication类,该类继承自Application。在这个类中创建一个List集合类;

public class myApplication extends Application {
    ......
    private List<Activity> list = new ArrayList<>();

  • ②.在myApplication中创建两个方法,一个用于添加我们启动的各个Activity,另一个用于退出时清楚List中的Activity;
public void addActivity(Activity activity) {
    list.add(activity);
}

public void exit() {
    try {
        for (Activity activity : list) {
            if (activity != null)
                activity.finish();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }

}

  • ③.在我们启动的Activity中,调用addActivity()方法添加启动的Activty;
myApplication.getInstance().addActivity(this);

  • ④.在我们退出程序的地方,调用exit()方法,删除添加的Actiivty;
myApplication.getInstance().exit();

这样做看似优雅,但是是使用的过程中,我的leakcanary检测工具一直内存泄漏,打开一看,发现调用过myApplication.getInstance().addActivity(this);方法的Activity全都内存泄漏了!!

为什么呢?这个List是在myApplication中的,这个myApplication的生命周期是整个App的生命周期,因此它自然要比单个Activity的生命周期要长。假如我们从一个Ativity A跳到了另一个Activity B,那么A就到了后台,假设这个时候系统内存不足了,想要回收他,却发现有一个和APP生命周期一样长的List还持有他的引用,完了,明明没有用的Activity实例却回收不了,自然就造成了内存泄漏。

所以这种看似优雅的方式,实际上使用不好就极为不优雅。其实解决上述问题的方法也很简单,回收不了是因为List持有的是Activity的强引用,我们只要想办法给他搞成弱引用即可,这里只是提供一种思路,具体怎么实践可以自行考虑。

(2).单例/Context使用不当造成的内存泄漏

由于单例的静态特性使得其生命周期跟应用的生命周期一样长,所以如果使用不恰当的话,很容易造成内存泄漏。我们想想单例有什么特点?在笔者的Android设计模式之——单例模式这篇文章中提到过,除了枚举单例以外,所有的单例都有这么一个特点:内部有一个公有的静态方法,用于返回该类内部创建的实例,既然在一个静态方法中返回单例引用,那么这个引用必然也是静态的。
  OK,我们来看看这个经典的例子,这个例子上网一搜随手粘来,我这里就直接贴了:

public class AppManager {
    private static AppManager instance;
    private Context context;
    private AppManager(Context context) {
        this.context = context;
    }
    public static AppManager getInstance(Context context) {
        if (instance != null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}

这是一个普通的单例模式,当创建这个单例的时候,由于需要传入一个Context,所以这个Context的生命周期的长短至关重要:
①.传入的是Application的Context:这将没有任何问题,因为单例的生命周期和Application的一样长
②.传入的是Activity的Context:当这个Context所对应的Activity退出时,由于该Context和Activity的生命周期一样长(Activity间接继承于Context),所以当前Activity退出时它的内存并不会被回收,因为单例对象持有该Activity的引用。

所以正确的单例应该修改为下面这种方式:

public class AppManager {
    private static AppManager instance;
    private Context context;
    private AppManager(Context context) {
        this.context = context.getApplicationContext();
    }
    public static AppManager getInstance(Context context) {
        if (instance != null) {
            instance = new AppManager(context);
        }
        return instance;
    }
}

这样不管传入什么Context最终将使用Application的Context,而单例的生命周期和应用的一样长,这样就防止了内存泄漏。

OK,上面是别人的代码,我贴完了;对于上面的例子,我们需要注意的是,这个问题的本质不是单例引起的内存泄漏,而是想一个app全局变量中传入了Activity的Context引起的,和上面我们第一个例子中问题的本质如出一辙,这点我们要意识到。另外,还有最重要的一点,“单例的静态特性使得其生命周期跟应用的生命周期一样长”这句话应该怎么理解?或者说,为什么单例的静态特性与应用的生命周期一样长?
  这里强推一篇文章:单例模式讨论篇:单例模式与垃圾回收,这篇文章讨论了这个问题,这里笔者再结合自己的理解谈一谈:

当一个单例的对象长久不用时,会不会被jvm的垃圾收集机制回收?

我们观察上述单例的代码,尤其是这段:

public static AppManager getInstance(Context context) {
    if (instance != null) {
        instance = new AppManager(context);
    }
    return instance;
}

这段中,instance是该类的静态实例:private static AppManager instance;,我们在获取的时候就是获取的这个静态的instance。首先明确一点,instance是存在于JVM的方法区内(静态域)的,这点我们多次强调过。JVM在垃圾回收的时候,会从GC Roots开始进行可达性分析,那么GC Roots自然不会被回收,可以作为GC Roots的对象有:

  • 虚拟机栈(栈桢中的本地变量表)中的引用的对象。
  • 方法区中的类静态属性引用的对象。
  • 方法区中的常量引用的对象。
  • 本地方法栈中JNI的引用的对象。

可以看到,instance = new AppManager(context);这句中,我们创建的单例对象new AppManager(context)是被静态引用instance所引用,符合第二条,也就是说我们的静态单例是作为一个GC Root的,因此不会被JVM回收。这一点我们在Android内存管理分析总结这篇文章中有说过。
  虽然jvm堆中的单例对象不会被垃圾收集,但是单例类本身如果长时间不用会不会被收集呢?因为jvm对方法区也是有垃圾收集机制的。如果单例类被收集,那么堆中的对象就会失去到根的路径,必然会被垃圾收集掉。对此,笔者查阅了hotspot虚拟机对方法区的垃圾收集方法,JVM卸载类的判定条件如下:

  • 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。

只有三个条件都满足,JVM才会在垃圾收集的时候卸载类。显然,单例的类不满足条件一,因此单例类也不会被卸载。也就是说,只要单例类中的静态引用指向jvm堆中的单例对象,那么单例类和单例对象都不会被垃圾收集。
  综上所述,只有在应用退出的时候,静态单例才会走完它的一生,因此静态单例的生命周期和app的生命周期是一致的。当然,还有一种方法让单例对象回收,那就是人为的断开静态引用instance和new AppManager(context),即instance = null;,当然,这样做没有什么意义。

还有一点需要注意,上面我们谈论的是JVM中单例对象的回收情况。在5.0之后的Android系统中,已经默认用ART虚拟机了,这种虚拟机与JVM有些区别,据说在ART虚拟机的Android版本上,静态单例对象是可以被回收的,当然,这个只是据说,笔者没有自己验证过。

(3).Handler/非静态匿名内部类 造成的内存泄漏

Handler的使用造成的内存泄漏问题应该说最为常见了,平时在处理网络任务或者封装一些请求回调等api都应该会借助Handler来处理,对于Handler的使用代码编写一不规范即有可能造成内存泄漏,如下示例:

public class MainActivity extends AppCompatActivity {
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            //...
        }
    };
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        loadData();
    }
    private void loadData(){
        //...request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
}

这种创建Handler的方式会造成内存泄漏,由于mHandler是Handler的非静态匿名内部类的实例(同第二大点中的成员内部类),所以它持有外部类Activity的引用,我们知道消息队列是在一个Looper线程中不断轮询处理消息,那么当这个Activity退出时消息队列中还有未处理的消息或者正在处理消息,而消息队列中的Message持有mHandler实例的引用,mHandler又持有Activity的引用,所以导致该Activity的内存资源无法及时回收,引发内存泄漏。所以另外一种做法为:

public class MainActivity extends AppCompatActivity {
    private MyHandler mHandler = new MyHandler(this);
    private TextView mTextView ;
    private static class MyHandler extends Handler {
        private WeakReference<Context> reference;
        public MyHandler(Context context) {
            reference = new WeakReference<>(context);   //获得MainActivity的一个弱引用
        }
        @Override
        public void handleMessage(Message msg) {
            MainActivity activity = (MainActivity) reference.get();
            if(activity != null){
                activity.mTextView.setText("");
            }
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mTextView = (TextView)findViewById(R.id.textview);
        loadData();
    }

    private void loadData() {
        //...request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
}

reference:
Android 内存泄漏总结
Android性能优化之常见的内存泄漏
单例模式讨论篇:单例模式与垃圾回收

推荐阅读更多精彩内容

  • Android 内存泄漏总结 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏...
    _痞子阅读 1,167评论 0 8
  • Android 内存泄漏总结 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏...
    apkcore阅读 801评论 2 7
  • 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏大家都不陌生了,简单粗俗的讲,...
    DreamFish阅读 442评论 0 5
  • 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏大家都不陌生了,简单粗俗的讲,...
    宇宙只有巴掌大阅读 1,661评论 0 11
  • 罗伯本兹尼,美国著名占星师,作家、诗人、音乐人、社会活动家。运势风格为心理指引。 ———《正念是执念的解药》以下是...
    Monicangel_天歌阅读 389评论 0 0