Android结合实例讲垃圾回收机制

1,620 阅读12分钟

在讲垃圾回收机制之前,首先我们要明确什么是垃圾?怎么判定垃圾?判定垃圾之后又是怎么回收的?

1.1 什么是垃圾?

所谓垃圾就是内存中已经没有用的对象,那Java虚拟机中是怎么判定对象是垃圾的呢?

1.2 如何判定是垃圾?

1.2.1 引用计数法

这个算法很简单,就是在引用一个对象的时候,计数器+1,在引用对象之后又“解绑”之后,计数器就-1,但是这种算法解决不了AB之间相互引用的问题。

1.2.2 可达性分析算法

可达性分析算法是从离散数学中的图论引入的,JVM 把内存中所有的对象之间的引用关系看作一张图,通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。

image

比如上图中,对象A/B/C/D/E 与 GC Root 之间都存在一条直接或者间接的引用链,这也代表它们与 GC Root 之间是可达的,因此它们是不能被 GC 回收掉的。而对象M和K虽然被对J 引用到,但是并不存在一条引用链连接它们与 GC Root,所以当 GC 进行垃圾回收时,只要遍历到 J/K/M 这 3 个对象,就会将它们回收。

注意:上图中圆形图标虽然标记的是对象,但实际上代表的是此对象在内存中的引用。包括 GC Root 也是一组引用而并非对象

1.3 GC Root 对象

在Java 中,有以下几种对象可以作为GC Root:

  • 1.Java虚拟机栈 (局部变量表)中引用的对象
  • 2.方法区中静态引用指向的对象
  • 3.仍处于存活状态中的线程对象
  • 4.Native方法中JNI引用的对象

1.4 GC Root 对象何时被回收?

不同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收

  • 1.Allocation Failure:在堆内存中分配时,可用内存不足导致对象内存分配失败,系统会触发一次GC。
  • 2.System.gc():在应用层,代码调用此API。

2.1 代码验证

2.1.1 验证虚拟机栈(栈帧中的局部变量)中引用的对象作为 GC Root

public class GCRootLocalVariable {
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];//80M

public static void main(String[] args) {
    System.out.println("开始时:");
    printMemory();
    method();
    System.gc();
    System.out.println("第二次GC完成");
    printMemory();
}

public static void method() {
    GCRootLocalVariable g = new GCRootLocalVariable();
    System.gc();
    System.out.println("第一次GC完成");
    printMemory();
}

/**
 * 打印出当前JVM剩余空间和总的空间大小
 */
public static void printMemory() {
    System.out.print("free is " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + " M, ");
    System.out.println("total is " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + " M, ");
}
}

运行结果:

开始时: free is 241 M, total is 245 M,

第一次GC完成 free is 163 M, total is 245 M,

第二次GC完成 free is 242 M, total is 245 M,

  • 当第一次 GC 时,g 作为局部变量,引用了 new 出的对象(80M),并且它作为 GC Roots,在 GC 后并不会被 GC 回收。

  • 当第二次 GC:method() 方法执行完后,局部变量 g 跟随方法消失,不再有引用类型指向该 80M 对象,所以第二次 GC 后此 80M 也会被回收。

注意:上面日志包括后面的实例中,因为有中间变量,所以会有 1M 左右的误差,但不影响我们分析 GC 过程

2.1.2 验证方法区中的静态变量引用的对象作为 GC Root

public class GCRootStaticVariable {

private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;
private static GCRootStaticVariable staticVariable;
public GCRootStaticVariable(int size) {
    memory = new byte[size];
}
public static void main(String[] args){
    System.out.println("程序开始:");
    printMemory();
    GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
    g.staticVariable = new GCRootStaticVariable(8 * _10MB);
    // 将g置为null, 调用GC时可以回收此对象内存
    g = null;
    System.gc();
    System.out.println("GC完成");
    printMemory();
}
/**
 * 打印出当前JVM剩余空间和总的空间大小
 */
public static void printMemory() {
    System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
    System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}

运行结果:

程序开始: free is 241 M, total is 245 M,

GC完成 free is 163 M, total is 245 M,

可以看出:

  • 当程序刚开始运行时内存为 241M,并分别创建了 g 对象(40M),同时也初始化 g 对象内部的静态变量 staticVariable 对象(80M)。

  • 当第一次 GC:调用 GC 时,只有 g 对象的 40M 被 GC 回收掉,而静态变量 staticVariable 作为 GC Root,它引用的 80M 并不会被回收。

2.1.3 验证活跃线程作为 GC Root

public class GCRootThread {
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];

public static void main(String[] args) throws InterruptedException {
    System.out.println("开始前内存情况:");
    printMemory();
    AsyncTask at = new AsyncTask(new GCRootThread());
    Thread thread = new Thread();
    thread.start();
    System.gc();
    System.out.println("main方法执行完毕,完成GC");
    printMemory();
    thread.join();
    at=null;
    System.gc();
    System.out.println("线程代码执行完毕,完成GC");
    printMemory();
}

private static void printMemory() {
    System.out.print("free is" + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
    System.out.println("total is" + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M");
}

private static class AsyncTask implements Runnable {
    private GCRootThread gcRootThread;

    public AsyncTask(GCRootThread gcRootThread) {
        this.gcRootThread = gcRootThread;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
}

运行结果:

开始前内存情况: free is241M,total is245M

main方法执行完毕,完成GC free is163M,total is245M

线程代码执行完毕,完成GC free is243M,total is245M

可以看出:

  • 当程序刚开始时是 241M 内存,当调用第一次 GC 时线程并没有执行结束,并且它作为 GC Root,所以它所引用的 80M 内存并不会被 GC 回收掉。。

  • 当第二次 GC 时:thread.join() 保证线程结束再调用后续代码,线程已经执行完毕并被置为 null,这时线程已经被销毁,所以之前它所引用的 80M 此时会被 GC 回收掉。

2.1.4 测试成员变量是否可作为 GC Root

public class GCRootClassVariable {
private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;

public GCRootClassVariable(int size) {
    this.memory = new byte[size];
}

private GCRootClassVariable classVariable;


public static void main(String[] args) {
    System.out.println("程序开始:");
    printMemory();
    GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
    g.classVariable = new GCRootClassVariable(8 * _10MB);
    g = null;
    System.gc();
    System.out.println("GC完成");
    printMemory();

}

/**
 * 打印出当前JVM剩余空间和总的空间大小
 */
public static void printMemory() {
    System.out.print("free is " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + " M, ");
    System.out.println("total is " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + " M, ");
}
}

运行结果:

程序开始: free is 241 M, total is 245 M,

GC完成 free is 243 M, total is 245 M,

  • 从上面日志中可以看出当调用 GC 时,因为 g 已经置为 null,因此 g 中的全局变量 classVariable 此时也不再被 GC Root 所引用。
  • 所以最后 g(40M) 和 classVariable(80M) 都会被回收掉。这也表明全局变量同静态变量不同,它不会被当作 GC Root。

上面演示的这几种情况往往也是内存泄漏发生的场景,设想一下我们将各个 Test 类换成 Android 中的 Activity 的话将导致 Activity 无法被系统回收,而一个 Activity 中的数据往往是较大的,因此内存泄漏导致 Activity 无法回收还是比较致命的

3.1 如何进行垃圾回收

由于垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各不相同,因此本课时并不会过多的讨论算法的实现,只是介绍几种算法的思想以及优缺点。

3.1.1 标记清除算法(Mark and Sweep GC)

  • Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  • Sweep 清除阶段:当遍历完所有的 GC Root 之后,则将标记为垃圾的对象直接清除。

image

  • 优点:实现简单,不需要将对象进行移动。
  • 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。

3.1.2 复制算法(Copying)

  • 复制算法之前,内存分为 A/B 两块,并且当前只使用内存 A,内存的状况如下图所示: image

  • 标记完之后,所有可达对象都被按次序复制到内存 B 中,并设置 B 为当前使用中的内存。内存状况如下图所示:

image

  • 优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
  • 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

3.1.3 标记-压缩算法 (Mark-Compact)

  • Mark 标记阶段:找到内存中的所有 GC Root 对象,只要是和 GC Root 对象直接或者间接相连则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
  • Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。

image

  • 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上还是降低了效率。

3.2 JVM分代回收策略

Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般为新生代,老年代注意: 在 HotSpot 中除了新生代和老年代,还有永久代。

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,次区域对象生命周期较短,多次回收不掉的就会转移大老年代中。

3.2.1 新生代

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收 70%~95% 的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。

新生代又可以继续细分为 3 部分:Eden、Survivor0(简称 S0)、Survivor1(简称S1)。这 3 部分按照 8:1:1 的比例来划分新生代。这 3 块区域的内存分配过程如下:

绝大多数刚刚被创建的对象会存放在 Eden 区。如图所示:

image

当 Eden 区第一次满的时候,会进行垃圾回收。首先将 Eden区的垃圾对象回收清除,并将存活的对象复制到 S0,此时 S1是空的。如图所示:

image

下一次 Eden 区满时,再执行一次垃圾回收。此次会将 Eden和 S0区中所有垃圾对象清除,并将存活对象复制到 S1,此时 S0变为空。如图所示:

image

如此反复在 S0 和 S1之间切换几次(默认 15 次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代中。如图所示:

image

3.2.2 老年代

一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代 GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个 512 byte 的 card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生 GC 时,只需要检查这个 card table 即可,大大提高了性能

4.1 引用

上文中已经介绍过,判断对象是否存活我们是通过GC Roots的引用可达性来判断的。但是JVM中的引用关系并不止一种,而是有四种,根据引用强度的由强到弱,他们分别是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)

image

平时项目中,尤其是Android项目,因为有大量的图像(Bitmap)对象,使用软引用的场景较多,不当的使用软引用有时也会导致系统异常。还有内存泄漏的情况中,经常使用WeakReference,所以重点看下软引用SoftReference的和WeakReference的使用场景。

4.1.1 引用软引用常规使用

  public class SoftReferenceDemo {
static class SoftObject {
    byte[] data = new byte[1*1200 * 1024 * 1024];//120M
}

public static void main(String[] args) throws InterruptedException{
    //将缓存数据用软引用持有
    SoftReference<SoftObject> cacheRef = new SoftReference<>(new SoftObject());
    System.out.println("第一次gc前 软引用" + cacheRef.get());
    //进行一次gc后查看对象的回收情况
    System.out.println("第一次gc后 软引用" + cacheRef.get());

    //再分配一个120M的对象 看看缓存对象的回收情况
    SoftObject newSo = new SoftObject();
    System.out.println("再分配120M强引用对象之后 软引用" + cacheRef.get());

}
}

执行:java -Xmx200m SoftReferenceDemo

运行结果:

第一次gc前 软引用com.example.leetcode.SoftReferenceDemo$SoftObject@66d3c617

第一次gc后 软引用com.example.leetcode.SoftReferenceDemo$SoftObject@66d3c617

再分配120M强引用对象之后 软引用 null

首先通过-Xmx将堆最大内存设置为200M。从日志中可以看出,当第一次GC时,内存中还有剩余可用内存,所以软引用并不会被GC回收。但是当我们再次创建一个120M的强引用时,JVM可用内存已经不够,所以会尝试将软引用给回收掉。

4.1.2 引用弱引用常规使用

弱引用一般我们接触的都是在使用Hander的时候,使用静态内部类+弱引用来书写。

Android中使用Handler造成内存泄露的原因

Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
    mImageView.setImageBitmap(mBitmap);
}
}

上面是一段简单的Handler的使用。当使用内部类(包括匿名类)来创建Handler的时候,Handler对象会隐式地持有一个外部类对象(通常是一个Activity)的引用(不然你怎么可能通过Handler来操作Activity中的View?)。而Handler通常会伴随着一个耗时的后台线程(例如从网络拉取图片)一起出现,这个后台线程在任务执行完毕(例如图片下载完毕)之后,通过消息机制通知Handler,然后Handler把图片更新到界面。然而,如果用户在网络请求过程中关闭了Activity,正常情况下,Activity不再被使用,它就有可能在GC检查时被回收掉,但由于这时线程尚未执行完,而该线程持有Handler的引用(不然它怎么发消息给Handler?),这个Handler又持有Activity的引用,就导致该Activity无法被回收(即内存泄露),直到网络请求结束(例如图片下载完毕)。另外,如果你执行了Handler的postDelayed()方法,该方法会将你的Handler装入一个Message,并把这条Message推到MessageQueue中,那么在你设定的delay到达之前,会有一条MessageQueue -> Message -> Handler -> Activity的链,导致你的Activity被持有引用而无法被回收。

解决方案:

static class MyHandler extends Handler {
WeakReference<Activity > mActivityReference;

MyHandler(Activity activity) {
    mActivityReference= new WeakReference<Activity>(activity);
}

@Override
public void handleMessage(Message msg) {
    final Activity activity = mActivityReference.get();
    if (activity != null) {
        mImageView.setImageBitmap(mBitmap);
    }
}
}

WeakReference弱引用,与强引用相对,它的特点是,GC在回收时会忽略掉弱引用,即就算有弱引用指向某对象,但只要该对象没有被强引用指向,该对象就会在被GC检查到时回收掉。对于上面的代码,用户在关闭Activity之后,就算后台线程还没结束,但由于仅有一条来自Handler的弱引用指向Activity,所以GC仍然会在检查的时候把Activity回收掉。这样,内存泄露的问题就不会出现了。

5.1 总结:

JVM 中有关垃圾回收的相关知识点,其中重点介绍了使用可达性分析来判断对象是否可以被回收,以及 3 种垃圾回收算法。虚拟机垃圾回收机制很多时候都是影响系统性能、并发能力的主要因素之一。尤其是对于从事 Android 开发的工程师来说,有时候垃圾回收会很大程度上影响 UI 线程,并造成界面卡顿现象。