JVM与DVM(二)GC回收与分代

GC主要关注堆和方法区


Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。而堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

GC Root 也是一组引用而并非对象。

在 Java 中,有以下几种对象可以作为 GC Root:
  • Java 虚拟机栈(局部变量表)中的引用的对象。(方法结束后,局部变量系统会回收的)
  • 方法区中静态引用指向的对象。
  • 仍处于存活状态中的线程对象。
  • Native 方法中 JNI 引用的对象。
什么时候回收
  • Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。

  • System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC。

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


验证虚拟机栈(栈帧中的局部变量)中引用的对象作为 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
class GCRootTest {
private byte[] memory = new byte[8 * 1024 * 1024 * 10];
public static void main(String[] args) {
printMemory();
fun1();
System.gc();
printMemory();
}
private static void fun1() {
GCRootTest gcRootTest = new GCRootTest();
System.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, ");
}
}

free is 242 M, total is 245 M,
第一次gc
free is 163 M, total is 245 M,
第二次gc
free is 243 M, total is 245 M,

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

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


验证方法区中的静态变量引用的对象作为 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
public class GCRootTest {
private byte[] memory = new byte[8 * 1024 * 1024 * 10];
private static GCRootTest gcRootTest;

public static void main(String[] args) {
printMemory();
GCRootTest g = new GCRootTest();
g.gcRootTest = new GCRootTest();
printMemory();
g=null;
System.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, ");
}
}

free is 244 M, total is 245 M,
free is 84 M, total is 245 M,
free is 163 M, total is 245 M,

静态变量 gcRootTest 作为 GC Root,它引用的 80M 并不会被回收。


验证活跃线程作为 GC Root

猜想:线程结束,局部变量会被回收,但之所以使用System.gc()是因为可以马上进行回收,达到测试效果

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
52
53
54
55
public class GCRootTest {

private byte[] memory = new byte[8 * 1024 * 1024 * 10];

public static void main(String[] args) {

try {
printMemory();
MyRunnable r = new MyRunnable(new GCRootTest());
Thread thread = new Thread(r);
thread.start();
System.out.println(System.currentTimeMillis());
System.gc();
printMemory();

thread.join();
System.out.println(System.currentTimeMillis());
r = null;
System.gc();
printMemory();


} catch (InterruptedException e) {
e.printStackTrace();
}

}

public static class MyRunnable implements Runnable {
private GCRootTest gcRootTest;
public MyRunnable(GCRootTest gcRootTest) {
this.gcRootTest = gcRootTest;
}

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

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 242 M, total is 245 M,
1654268214086
free is 163 M, total is 245 M,
1654268216087
free is 243 M, total is 245 M,

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


测试成员变量是否可作为 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
public class GCRootTest {
private byte[] memory = new byte[8 * 1024 * 1024 * 10];
private GCRootTest gcRootTest;

public static void main(String[] args) {
printMemory();
GCRootTest g = new GCRootTest();
g.gcRootTest = new GCRootTest();
printMemory();
g=null;
System.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, ");
}
}

free is 242 M, total is 245 M,
free is 82 M, total is 245 M,
free is 243 M, total is 245 M,

表明全局变量同静态变量不同,它不会被当作 GC Root。


如何回收垃圾

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

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

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

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

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

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

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

JVM分代回收策略

Java 虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是 JVM 的内存分代策略。注意: 在 HotSpot 中除了新生代和老年代,还有永久代
分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。

新生代中因为要进行一些复制操作,所以一般采用的 GC 回收算法是复制算法。

新生代又可以继续细分为 3 部分:Eden、Survivor0(简称 S0)、Survivor1(简称S1)。这 3 部分按照 8:1:1 的比例来划分新生代。这 3 块区域的内存分配过程如下:
绝大多数刚刚被创建的对象会存放在 Eden 区。

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

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

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

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

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

参考文章:
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc?id=1856