JVM垃圾收集器

本文索引


关于内存分配和回收策略会在下一篇博文中讲解,本文就主要讲解后面三个关于GC的问题。

对象已经死亡吗?

在里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收之前,第一件事情就是要确定这些对象之中那些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。

引用计数算法

引用计数法的算法是这样的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

虽然客观的说,引用计数算法的实现很简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因就是它很难解决对象之间相互循环引用的问题。

具体的例子如下:

1
2
3
4
5
6
7
8
9
10
11
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}

对象objA和objB都有字段instance,赋值令 objA.instance = objBobjB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象不可能再被访问,但是他们因为互相引用着对方,导致它们的引用计数都不为零,于是引用计数算法无法通知GC回收它们。

可达性分析算法

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

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

再谈引用

在JDK1.2之前,Java中引用的定义很传统:如果reference类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为 强引用、软引用、弱引用、虚引用 四种,这四种引用强度依次逐渐减弱。

强引用

强引用(Strong Reference)就是指在程序代码中普遍存在的,类似Object obj = new Object()这类的引用,我们使用的大部分引用实际上都是强引用。

只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。当内存空间不足,Java虚拟机宁愿抛出 OutOfMemoryError错误,使程序异常终止,也不会随意回收具有强引用的对象来解决内存不足的问题。

软引用

软引用(Soft Reference)是用来描述一些还有用,但是并非必需的对象。

如果内存空间足够,垃圾收集器就不会回收它;如果内存空间不足,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够内存的话,才会抛出内存溢出异常。

弱引用

弱引用(Weak Reference)也是用来描述非必需的对象,但是它的强度更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。

当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。不过由于垃圾收集器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

虚引用

虚引用(Phantom Reference)顾名思义,就是形同虚设,它是最弱的一种引用关系。

一个对象是否具有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。

为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

在程序中一般很少使用弱引用和虚引用,使用软引用的情况比较多,因为软引用可以加速Java虚拟机堆垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemoryError)等问题发生。

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,要真正宣告一个对象死亡,至少要经历两次标记过程:

如果对象在可达性分析后发现没有与GC Roots相连接的引用链,那它将会被标记并且进行第一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。

被判定为需要执行的对象,将会被放在F-Queue队列中进行二次标记。如果对象在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
49
50
51
52
53
54
55
56
57
58
59
/*
* 此代码演示了两点:
* 1.对象可以再被GC时自我拯救
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* */
public class FinalizeEscapeGC {

public String name;
public static FinalizeEscapeGC SAVE_HOOK = null;

public FinalizeEscapeGC(String name) {
this.name = name;
}

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

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

@Override
public String toString() {
return name;
}

public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC("leesf");
System.out.println(SAVE_HOOK);
// 对象第一次拯救自己
SAVE_HOOK = null;
System.out.println(SAVE_HOOK);
System.gc();
// 因为finalize方法优先级很低,所以暂停0.5秒以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead : (");
}

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

运行结果:

1
2
3
4
5
6
leesf
null
finalize method executed!
leesf
yes, i am still alive :)
no, i am dead : (

从上面的运行结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。但是在第二次执行相同代码的时候,却逃脱失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败。

回收方法区

很多人认为方法区是没有垃圾收集器的,虽然在方法区进行垃圾收集的性价比比较低,但是也并不代表在方法区去就一定没有垃圾收集的工作。

永久代的垃圾收集主要回收两部分内容:废弃常量无用的类

回收废弃常量与回收Java堆中的对象非常相似,以字符串为例,如果当前没有任何String对象引用常量池中的该字符串常量,也没有其他地方引用了这个字面量,就说明这个字符串常量为废弃常量。如果这是发生内存回收,而且有必要的话,这个常量就会被系统清理出常量池。

而判定一个类是否为“无用的类”的条件则苛刻的多,类需要同时满足下面三个条件才能算是“无用的类”:

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

虚拟机可以对满足上述三个条件的无用类进行回收,这里说的仅仅是“可以”,并不是必然被回收。

垃圾收集算法


由于垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法又各不相同,因此这里不过多的讨论实现的细节,仅介绍几种算法的思想。

标记-清除算法

最基础的算法就是“标记-清除(Mark-Sweep)”算法,顾名思义,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的标记过程在前一节已经介绍过了,而且之所以说它是最基础的算法,因为后续的收集算法都是基于这种思路,并对其不足进行改进而得到的。

它的不足有两个:

  • 效率问题:标记和清除两个过程的效率都不高。
  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

复制算法是为了解决标记-清除算法的效率问题,它将内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免有点太高。

有统计表示,新生代中的对象98%是“朝生息死”的,所以并不需要按照1:1的比例来划分内存空间。

而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当Eden满了时,触发一次Minor GC,然后将Eden和Survivor中还存活着的对象一次性的复制到另一块Survivor空间上(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生),最后清理掉Eden和刚刚使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。

如此循环往复,如果对象的复制次数达到了16次,该对象就会被送到老年代中。

其次当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保。大对象(就是需要大量连续内存空间的对象)直接进入老年代,因为这样做为了避免大对象分配内存时由于分配担保机制带来的复制而降低效率。

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

标记-整理算法

因为复制算法的缺点,根据老年代的特点,有人提出另一种“标记-整理”算法。

标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法。这种算法把“复制算法”和“标记-整理”结合起来,根据对象存活周期的不同将内存划分为几块,把Java堆划分为新生代和老年代,根据各个年代的特点选择合适的垃圾收集算法。

新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收

垃圾收集器

如果说收集算法时内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器。这个收集器是一个单线程的收集器,但它的“单线程”意义并不仅仅说明它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是他在进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集完成。

新生代采用复制算法,老年代采用标记-整理算法。

虽然现在一个个越来越优秀的收集器出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除。

但实际上到现在为止,它依然是虚拟机运行在Client模式下的默认新生代收集器。它的优点如下:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为(控制参数、收集算法、回收策略等等)都与Serial收集器完全一样。

新生代采用复制算法,老年代采用标记-整理算法。

它是许多运行在Server模式下的虚拟机的首要选择,除了Serial收集器外,只有它能与CMS收集器(真正意义上的并发收集器,后面会介绍到)配合工作。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果。

并发和并行概念的补充

  • 并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行在另一个CPU上。

Parallel Scavenge收集器

Parallel Scavenge收集器收集算法和线程方面与ParNew收集器一样,但Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,因此该收集器也被称为“吞吐量优先”收集器。

该收集器提供了两个参数用于精确控制吞吐量,分别控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

该收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注,这是一个开关参数。当这个参数打开之后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略。这也是该收集器与ParNew收集器的一个重要区别。

Serial Old收集器

该收集器是Serial收集器的老年代版本。它主要的两大用途:

  • 在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。在注重吞吐量以及CPU资源的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。工作过程如图所示。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它是HotSpot虚拟机第一款真正意义上的并发收集器,他第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS收集器是基于“标记-清除”算法实现的,整个过程分为4个步骤:

  1. 初始标记:暂停所有的其他线程,并记录下直接与GC Roots能直接关联的对象,速度很快。
  2. 并发标记:同时开启GC和用户线程,从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长。
  3. 重新标记:暂停所有的其他线程,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  4. 并发清除:开启所有线程,同时GC线程对为标记的区域做清扫。


因为它的性能优点,也称它为并发低停顿收集器。但是它有以下三个明显的缺点:

  • CMS收集器对CPU资源非常敏感。在并发阶段虽然不会导致用户线程停顿,但是会因为占用一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量降低。
  • CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于用户程序在运行,那么自然就会有新的垃圾产生,这部分垃圾被标记过后,CMS无法在当次集中处理它们(为什么?原因在于CMS是以获取最短停顿时间为目标的,自然不可能在一次垃圾处理过程中花费太多时间),只好在下一次GC的时候处理。这部分未处理的垃圾就称为“浮动垃圾”。 这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集。在JDK1.6中,CMS收集器启动阈值已经提升至92%。要是CMS运行期间的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生。
  • 收集结束时会有大量空间碎片产生。因为它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。,所以为了解决这个问题,CMS收集器提供了一个开关参数-XX:+UseCMSCompactAtFullCollection(默认开启),用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程,但是会导致停顿时间变长。

G1收集器

G1收集器是当今收集器技术发展的最前沿成果之一,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。它具备以下特点:

  • 并行与并发:G1能充分利用多CPu、多核环境下的硬件优势,使用多个CPU(或者CPU核心)来缩短Stop-The-Word停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
  • 分代收集:同其他收集器一样保留了分代的概念,但是它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合:不同于CMS的“标记-清除”算法,G1从整体看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法是实现的,但无论如何,这两种算法都意味着G1运行期间不会产生内存空间碎片。
  • 可预测的停顿:这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M浩渺的时间片段内。

G1收集器中Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老生代的概念,但新生代和老生代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间吨经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的由来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set 来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

  1. 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top At Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。这阶段需要停顿线程,但耗时很短。
  2. 并发标记:这阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  3. 最终标记:这阶段则是为了修正在并发标记阶段期间因用户程序继续运行而导致标记产生变化的那一部分标记记录,虚拟机将这段时间变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但可并行执行。
  4. 筛选回收:最后首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行。

垃圾收集器参数总结