(拉钩)Android工程师进阶34讲-02:GC回收机制与分代回收策略

0. 前言

垃圾回收(Garbage Collection),简称GC。Java语言比C语言好的地方在于,不需要手动释放对象的内存,JVM中的垃圾回收器(Garbage Collector)会自动回收。但是:一旦这种自动化机制出错,就需要去深入理解GC回收机制,甚至需要对这些“自动化”的技术实施必要的监控和调节。

上一节介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行入栈和出栈操作,这几个区域内不需要过多考虑回收问题。

而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

1. 什么是垃圾

所谓垃圾就是内存中已经没有用的对象。既然是“垃圾回收”,就必须知道哪些对象是垃圾。Java虚拟机中使用一种叫做 “可达性分析” 的算法来决定对象是否可被回收。

1.1 可达性分析

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

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

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

1.2 GC Root对象

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

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

2. 什么时候回收

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

  • 1、Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统触发一次GC。
  • 2、System.gc():在应用层,可以主动调用API来进行一次GC。

3. 代码验证GC Root的几种情况

现在了解了Java中的GC Root,以及何时触发GC,下面通过几个案例来验证GC Root的情况。在看具体代码前,先了解一个执行Java命令时的参数。

-Xms 初始分配Java运行时的内存大小,如果不指定,默认为物理内存的 1/64。

比如运行如下命令执行HelloWorld程序,从物理内存中分配出200M空间给JVM内存。

1
java -Xms200m HelloWorld

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

代码如下:

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
public class GCRootLocalVariable {
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];

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();
}

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.");
}
}

打印日志:

1
2
3
4
5
6
开始时:
free is 242 M, total is 243 M.
第一次GC完成:
free is 161 M, total is 243 M.
第二次GC完成:
free is 241 M, total is 243 M.

可以看出:

  • 当第一次GC时,g作为局部变量,引用了new出的对象(80M),并且它作为了GC Root,在GC后不会被GC回收。
  • 第二次GC时,method()方法执行完毕后,局部变量g跟随方法消失,不再有引用类型指向该80M对象,所以第二次GC此后80M也会被回收。

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

代码如下:

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
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();
}

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.");
}
}

打印日志:

1
2
3
4
程序开始:
free is 242 M, total is 243 M.
GC完成:
free is 161 M, total is 243 M.

可以看到:

程序刚开始运行时,内存为242M,并分别创建了g对象(40M),同时也初始化g对象内部的静态变量staticVariable对象(80M)。当调用GC时,只有g对象的40M被GC回收掉,而静态变量staticVariable作为GC Root,它引用的80M不会被回收。

3.3 验证活跃线程作为GC Root

代码如下:

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
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(at);
thread.start();
System.gc();
System.out.println("main方法执行完毕,完成GC:");
printMemory();

thread.join();
at = null;
System.gc();
System.out.println("线程代码执行完毕,完成GC:");
printMemory();
}

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.");
}

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();
}
}
}
}

打印日志:

1
2
3
4
5
6
开始前内存情况:
free is 242 M, total is 243 M.
main方法执行完毕,完成GC:
free is 161 M, total is 243 M.
线程代码执行完毕,完成GC:
free is 241 M, total is 243 M.

可以看到:

程序刚开始是242M内存,当调用第一次GC时线程没有执行结束,并且它作为GC Root,所以它所引用的80M内存不会被GC回收掉。thread.join()保证线程结束再调用后续代码,所以当调用第二次GC时,线程已经执行完毕并被设置为null,此时线程已经被销毁,所以之前它所引用的80M此时会被GC回收掉。

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

代码如下:

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
public class GCRootClassVariable {
private static int _10MB = 10 * 1024 * 102;
private byte[] memory;
private GCRootClassVariable classVariable;

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

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();
}

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.");
}
}

打印日志:

1
2
3
4
程序开始:
free is 238 M,total is 243 M.
GC完成:
free is 241 M,total is 243 M.

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

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

4. 如何回收垃圾

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

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

从“GC Root”集合开始,将内存整个遍历一次,保留所有可以被GC Root直接或间接引用到的对象,而剩下的对象都当做垃圾回收,过程分为两步。

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

如下图:

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

4.2 复制算法(Copying)

将现有的内存空间分为两块,每次只是用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清除正在使用的内存块中所有的对象,交换两个内存的角色,完成垃圾回收。

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

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

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

需要先从根节点开始对所有可达对象做一次标记,之后,它并不是简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。因此标记-压缩算法也分为两步:

  • 1、Mark 标记阶段:找到内存中的所有GC Root对象,只要是和GC Root对象直接或间接相连则标记为灰色(即存活对象),否则标记为黑色(即垃圾对象)。
  • 2、Compact 压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。
  • 优点:这种方法避免了碎片的产生,又不需要两块相同的内存空间,因此,性价比高。
  • 缺点:所谓压缩操作,仍需要进行局部对象移动,所以一定程度上降低了效率。

5. JVM分代回收策略

Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是JVM的内存分代策略。

注意,在HotSpot中除了新生代和老年代,还有永久代。

分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经历几次回收仍然存活下来,则将它们转移到老年代中。

5.1 新生代(Young Generation)

新生代的对象存放在新生代中,新生代对象朝生夕死,存活率很低。在新生代中,常规应用进行一次垃圾收集,一般可以回收70%~95%的空间,回收效率很高。

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

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

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

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

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

5.2 老年代(Old Generation)

一个对象如果在新生代存活了足够长时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。

可以使用-XX:PretenureSizeThreshold来控制直接升入老年代的对象的大小,大小这个值的对象会直接分配在老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记-压缩到的回收算法。

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

6. GC Log分析

为了让上层开发人员更加方便的调试Java程序,JVM提供了相应的GC日志。在GC执行垃圾回收事件过程中,会有各种相应的log被打印出来。其中新生代和老年代所打印的日志是有区别的:

  • 新生代GC:这一区域的GC叫做Minor GC。因为Java对象大多数都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC:这一区域的GC叫做Major GC或Full GC。当出现Major GC,经常会伴随至少一次Minor GC。

注意,在有些虚拟机实现中,Major GC和Full GC还有一些区别。Major GC只是代表回收老年代中的内存,而Full GC则代表回收整个堆中的内存,也就是新生代 + 老年代。

接下来通过几个案例分析如何查看GC Log,分析这些GC Log的过程加深对JVM分代策略的理解。

Java命令参数:

命令参数 功能描述
-verbose:gc 显示GC的操作内容
-Xms20M 初始化堆大小为20M
-Xmx20M 设置堆最大分配内存为20M
-Xmn10M 设置新生代的内存大小为10M
-XX:+printGCDetails 打印GC的详细log日志
-XX:SurvivorRatio=8 新生代中Eden区域与Survivor区域的大小比值为8:1:1

使用如下代码,在内存中创建4个byte类型数组来演示内存分配和GC的详细过程。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;

    public static void testAllocation() {
        byte[] a1, a2, a3, z4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[1 * _1MB];
    }

    public static void main(String[] agrs) {
        testAllocation();
    }
}

通过上面的参数,可以看出堆内存总大小为20M,其中新生代占10M,剩下的10M会自动分配给老年代。执行上述代码日志如下:

1
2
3
4
5
6
7
8
9
Heap
PSYoungGen      total 9216K, used 8003K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 97% used [0x00000007bf600000,0x00000007bfdd0ed8,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
  to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
ParOldGen       total 10240K, used 0K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bf600000)
Metaspace       used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

日志中的各字段代表意义如下:

字段 代表含义
PSYoungGen 新生代
eden 新生代中的Eden1区
from 新生代中的S0区
to 新生代中的S1区
ParOldGen 老年代

从日志可以看出:程序执行完之后,a1a2a3a4四个对象都被分配在新生代的Eden区。

如果将测试代码中的a4初始化改为a4 = new byte[2 * _1MB];,则打印日志如下:

1
2
3
4
5
6
7
8
9
10
[GC (Allocation Failure) [PSYoungGen: 6815K->480K(9216K)] 6815K->6632K(19456K), 0.0067344 secs] [Times: user=0.04 sys=0.00, real=0.01 secs]
Heap
PSYoungGen      total 9216K, used 2130K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 26% used [0x00000007bf600000,0x00000007bf814930,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
  to   space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
ParOldGen       total 10240K, used 6420K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 62% used [0x00000007bec00000,0x00000007bf2450d0,0x00000007bf600000)
Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

这时因为在给a4分配内存之前,Eden区已经被占用了6M,已经无法再分配出2M来存储a4对象。因此会执行一次Minor GC。并尝试将存活的a1a2a3复制到S1区。但是S1区只有1M空间,所以无法存储a1a2a3中的任意一个对象。这时,a1a2a3就会被转移到老年代,最后将a4保存在Eden区。所以最终结果是:Eden区占用2M(a4),老年代占用6M(a1a2a3)

通过这个测试案例,也间接验证了JVM的内存分配和分代回收策略。

6.1 再谈引用

上文介绍了,判断对象是否存活是用过GC Root的引用可达性来判断的。但是JVM中的引用关系不止一种。根据引用强度可分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)

引用 GC回收时机 使用示例
强引用 如果一个对象具有强引用,那垃圾回收器绝对不会回收 Object obj = new Object();
软引用 在内存实在不足时,会对软引用进行回收 SoftReference<Object> softObj = new SoftReference();
弱引用 第一次GC回收时,如果垃圾回收器遍历到了此弱引用,就将其回收 WeakReference<Object> weakObj = new WeakReference();
虚引用 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例 不会使用

平时项目中,特别是Android项目,因为有大量的图像(Bitmap)对象,使用软引用的场景比较多。所以重点看一下软引用的使用,不当的使用软引用会导致系统异常。

6.2 软引用常规使用

常规使用代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SoftReferenceNormal {
static class SoftObject {
// 120M
byte[] data = new byte[120 * 1024 * 1024];
}

public static void main(String[] args) {
// 将缓存数据用软引用持有
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());
}
}

执行上述代码,打印日志如下:

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

6.3 软引用隐藏问题

需要注意,被软引用对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会自动被垃圾回收器回收掉。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SoftReferenceTest {
public static class SoftObject {
// 1KB
byte[] data = new byte[1024];

// 100M
public static int CACHE_INITIAL_CAPACITY = 100 * 1024;
// 静态集合保存软引用,会导致这些软引用对象本身无法被垃圾回收器回收
public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();

public static void main(String[] args) {
for (int i = 0; i < CACHE_INITIAL_CAPACITY; i++) {
SoftObject obj = new SoftObject();
cache.add(new SoftReference<>(obj, referenceQueue));
if (i % 10000 == 0) {
System.out.println("size of cache:" + cache.size());
}
}
System.out.println("End!");
}
}
}

上述代码,虽然每个SoftObject都被一个软引用所引用,在内存紧张时,GC会将SoftObject所占用的1KB回收。但是每个SoftReference又被Set所引用(强引用)。执行上述代码结果如下:

限制堆内存大小为4M,最终程序崩溃,但是异常的原因并不是普通的堆内存溢出,而是“GC overhead”。之所以抛出这个错误,是由于虚拟机一会不断回收软引用,回收进行的速度过快,占用的CPU 过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出这个错误。

这里需要做优化,合理的处理方式是注册一个引用队列,每次循环之后将引用队列中出现的软引用对象从cache中移除。如下:

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
public class SoftReferenceTest {
public static class SoftObject {
// 1KB
byte[] data = new byte[1024];
}

public static int removeSoftRefs = 0;
// 100M
public static int CACHE_INITIAL_CAPACITY = 100 * 1024;
// 静态集合保存软引用,会导致这些软引用对象本身无法被垃圾回收器回收
public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();

public static void main(String[] args) {
for (int i = 0; i < CACHE_INITIAL_CAPACITY; i++) {
SoftObject obj = new SoftObject();
cache.add(new SoftReference<>(obj, referenceQueue));
clearUselessReference();
if (i % 10000 == 0) {
System.out.println("size of cache:" + cache.size());
}
}
System.out.println("End, removed soft reference = " + removeSoftRefs);
}

public static void clearUselessReference() {
Reference<? extends SoftObject> ref = referenceQueue.poll();
while (ref != null) {
if (cache.remove(ref)) {
removeSoftRefs++;
}
ref = referenceQueue.poll();
}
}
}

再次运行,结果如下:

可以看出优化后,程序可以正常执行完。并且在执行过程中会动态的将集合中的软引用删除。

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信