Java虚拟机(四)垃圾回收的机制和算法

GC

Java虚拟机除了支持了Java的跨平台性之外,最重要的就是虚拟机能够自动进行内存的回收,它不像C++那样,需要析构函数之类的去分配和管理内存,同样的也没有指针这个神奇的玩意,在处理不会再被使用的对象时,Java虚拟机会自动帮我们完成内存的回收。而所谓的内存回收,就叫做GC,也可以叫做垃圾收集,了解如何去完成Java的内存回收,对于我们使用Java,是一件非常重要的事情。

垃圾回收机制

引用计数算法

我想从最简单的开始去描述这一个事物,假如,你需要去判断一个对象是否要被回收,判断的依据是什么呢?一般来说,那就是以后都不会再去使用它了,所以它需要被回收,那么对于这种方式,是使用着什么的计算方法去测试呢?我们可以从synchronized的重入上找思路,是不是可以做出一个计数器,当对象存在引用的时候,就计数器加一,直到对象不被引用,计数器就减一,当对象的计数器归零了,是不是就可以被回收了呢?我们可以做出一个例子看看。

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
//-XX:+PrintGCDetails
//JDK6
public class test {

public Object instance = null;

private static final int _1MB = 1024 * 1024;

/**
* 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];

public static void main(String[] args) {


test objA = new test();
test objB = new test();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}

[GC [DefNew: 2546K->173K(4928K), 0.0029877 secs] 2546K->2221K(15872K), 0.0030177 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System) [Tenured: 2048K->173K(10944K), 0.0106634 secs] 4403K->173K(15872K), [Perm : 424K->424K(12288K)], 0.0107119 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
Heap
def new generation total 4992K, used 91K [0x24450000, 0x249b0000, 0x299a0000)
eden space 4480K, 2% used [0x24450000, 0x24466d20, 0x248b0000)
from space 512K, 0% used [0x248b0000, 0x248b0000, 0x24930000)
to space 512K, 0% used [0x24930000, 0x24930000, 0x249b0000)
tenured generation total 10944K, used 173K [0x299a0000, 0x2a450000, 0x34450000)
the space 10944K, 1% used [0x299a0000, 0x299cb588, 0x299cb600, 0x2a450000)
compacting perm gen total 12288K, used 428K [0x34450000, 0x35050000, 0x38450000)
the space 12288K, 3% used [0x34450000, 0x344bb1a0, 0x344bb200, 0x35050000)
ro space 10240K, 55% used [0x38450000, 0x389d3320, 0x389d3400, 0x38e50000)
rw space 12288K, 55% used [0x38e50000, 0x394f6128, 0x394f6200, 0x39a50000)

这段代码使得两个对象互相引用对方,这样的话就似乎永久不会被销毁了jvm5

但接下来的GC日志告诉我们,事情并不是这样的,在进行自主选择的full GC(充分GC)当中,出现了4403K->173K(15872K),可见的,就算互相引用,最终也会被销毁的,所以GC所使用的的算法,并不是这样。但也许你会说,它们都是变量,变量之间互相引用本来就是可以随意变换的,可能GC在对引用一个常量的时候,才会做出存活判断,而对变量都进行销毁。

可达分析算法

那么我们就使用一种是否引用常量的可达分析算法去判断他们吧

jvm6

这种方法,可以把一个GC roots作为不可被回收的引用池,所有连接在GC roots的都会是一直存活的对象,直到他们不引用任何GC Roots时,才去判断回收。可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象

引用的状态

这样的算法很纯粹,但是太过狭隘,一个对象可否存活,直接用处引用还是未引用的状态去判断。虽然很科学,但是未必会贴合实际,我们更希望当内存空间还足够时,保留它们,而内存空间在进行一次垃圾回收后,仍然未够的话,就放弃它们。而之后,Java对这个对象的引用状态,又增加了很多概念:

  • 强引用:类似于 Object obj = new Object();`创建的,只要强引用在就不回收
  • 软引用:SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。
  • 弱引用:WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。
  • 虚引用:PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

对象的自救

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,就好像判了刑并不会立即处死一样,还有缓刑这个概念,这个倒是挺人性化的。在虚拟机中,一个对象的真正死亡至少要经历两次标记过程:如果对象在进行中可达性分析后发现没有与 GC Roots 相连接的引用链,那他将会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。那么我们来看看,对象如何自救:

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
// * 1.对象可以在被GC时自我拯救。
// * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次

public class test {

public static test SAVE_HOOK = null;

public void isAlive() {
System.out.println("yes, i am still alive ");
}

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize mehtod executed!");
test.SAVE_HOOK = this;
}

public static void main(String[] args) throws Throwable {
SAVE_HOOK = new test();

//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead ");
}

// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead");
}
}
}

finalize mehtod executed!
yes, i am still alive
no, i am dead

可以看到,被测试的对象,在覆盖了Finalizer方法之后,就有了一次暂时不死的机会,让其自救,而在使用了这个方法之后,对象仍然不知死活,那就真的要死了。但是Finalizer方法用系统的开销极大,后面也被废弃了,这里仅仅是谈谈而已。

回收方法区

方法区之前被称之为永久代,这是一个误解,但事实也和所谓的永久代差不多。一般方法区是很少出现回收这个事件,如果出现回收,那么不外乎有两种情况:废弃的常量和无用的类。废弃的常量判断比较简单,那就是这个常量,比如“qwe”,它在堆中不会在有任何实例去引用它,那么它就暂时失去价值了,所以会被销毁。而无用的类的判断方法更为复杂一点,一般要满足一下三点:

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

垃圾收集算法

可达分析算法准确来说是一个判断垃圾是否需要被回收的一种机制,而我们的Jvm,对垃圾的收集,也有着不同的算法。这些算法建立在两个假说身上:

  1. 弱分代假说:绝大多数对象都是朝生熄灭的。
  2. 强分代假说:熬过多次垃圾收集过程的对象就越难以消亡。

这两种假说奠定了垃圾收集器一致性的原则。从而也有了“minor GC”,“MajorGC“,”Full GC“这样不同的回收类型。不同的回收类型对应的不同的分代,在内存块中,也会给对象标明是新生代还是老年代,以此做出区分,这样在每次进行内存回收的时候,能够大大的提高效率。

分代收集算法

现在的虚拟机多是使用这样的机制,把Java堆分成为新生代和老年代,根据各个代的特点,使用不同的算法,比如新生代每次GC,都会有70%~90%的对象被回收,所以使用的是复制算法,而在经历15次回收后,该对象就会进入到老年代。老年代使用的是标记清理或者标记整理算法来进行回收。

标记—清除算法

最早诞生的垃圾收集算法叫做标记清楚算法。

bjqc

这种算法就是把需要被回收的空间标记出来,然后在进行回收,整体而言比较好理解,但是这样的算法有两个不足,一是效率不高,如果内存空间极大,就要进行大量的标记动作,二是空间上会产生大量的碎片。

标记-复制算法

接着就是复制算法,它是为了解决标记清除算法面对大量可回收对象的时候,效率低下的问题。

fzsf

这种算法是把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面。解决前一种方法的不足,但是会造成空间利用率低下。

因为大多数新生代对象都不会熬过第一次 GC。所以没必要 1 : 1 划分空间。可以分一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。

当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间。大小比例一般是 8 : 1 : 1,每次浪费 10% 的 Survivor 空间。但是这里有一个问题就是如果存活的大于 10% 怎么办?这里采用一种分配担保策略:多出来的对象直接进入老年代。

标记-整理算法

这种算法的标记过程和标记清除算法一样,只不过后面的步骤不是直接进行清除,而是让所有存活的对象都向内存空间的一段移动。

bjzlsf

这种算法也是把需要回收的内存打标记,之后再进行整理,把所有存活的对象内存移动至一端,然后直接清理掉边界以外的内存。

HotSpot算法的细节实现

枚举根节点

前面说到使用的是可达分析算法去判断是否存在引用,而在判断的过程中,需要进行枚举根节点操作,去根节点寻找引用链。但是在应用越来越大的时候,可能仅仅方法区就有将近几百兆,要逐个检查他们的是否存在引用。另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为每次要进行枚举根节点的时候,为了线程之间不出错,也为了能够准确的找出所有的引用,会把所有的线程停顿下来,等待所有线程的停顿想必需要大量的时间。

在停顿下来的时候,其实并不需要一个不漏的检查完所有执行上下文和全局的引用位置。在寻找引用的时候,HotSpot还会用到一种名为OopMap的数据结构,一旦类加载动作完成,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译中,也会在特定的位置记录下栈里和寄存器里是哪里的引用。这样,收集器在扫描的时候,就可以直接得知这些信息,并不需要一个不漏地从方法区等GC Roots开始查找。

安全点

因为OopMap的内容变化指令非常多,如果为每一条指令都生成对应的OopMap,将会需要大量的额外空间。但实际上,HotSpot也没有为每一条指令都生成OopMap,而是在程序执行到达Safepoint(安全点)的时候,才暂停下来,准备GC。那如何去判断程序是否到达安全点呢?HotSpot使用主动式中断的思想,当GC需要中断线程的时候,会设置一个标志,各个线程会主动去轮询这个标志,当满足安全中断的条件后,才会停下来进行GC

安全区域

Safe Region(安全区域),可以看作为是扩展了的安全点,当线程执行到安全区域时,会自行标识自己,在这段时候内,如果JVM需要GC,就会自行的进行暂停线程开始GC。

记忆集与卡表

在进行分代收集的时候,新生代和老年代都不是一个独立的个体,它们之间或多或少存在着一些互相引用的问题。如果要对老年代全部进行一次GC Roots,但是老年代却很少有内存是能够回收的,想必开销是非常大的。所以,为了避免这种情况,就在新生代中多了一个名为记忆集的数据结构。

记忆集是一种用于记录从非收集区域指向收集其余的指针集合的抽象数据结构,它也可以选择很多不同记忆精度面对不同的情况。一般选择的是卡精度,也被称为卡表,卡表最简单的形式可以只是一个字节数组,这个数组的每个key都有对应的卡页,一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在跨代指针,就将key值标记为1,并称这个元素变脏了。在GC的时候,只会选择变脏的元素的对应区域进行GCRoots。

写屏障

有其他分代区域中对象的引用了本区域对象时,其对应的元素就会变脏。但这个过程只靠什么去维持的呢?这里可以引入类似于Synchronized的思想,去锁住它,于是便有了写屏障。在更改记忆集的时候,为了原子性得到保障,会使用这个写屏障去维护记忆集,但写屏障不仅仅这有这些作用,它还做到了类似于try-fianlly的操作,只不过这些都被封装起来了,就像Spring里面的AOP一样。

并发的可达性分析

当堆越来越大的时候,试图去进行根节点遍历就会变的困难起来。所以面对这种情况,就引入了多线程里面的概念,我们不将所有的线程都暂停后才进行GC,而是直接随着线程的执行而进行根节点遍历。

但这也引入了新的问题,那就是在多线程情况下的不确定性,比如把新生代要回收的内存区域错误标记成了老年代,这个是可以容忍的,但如果错误的把老年代标记为要被销毁的新生代,那可能会使程序崩溃,为了解决这种事务端,也诞生了两种解决方案:增量更新和原始快照