《深入理解Java虚拟机》之GC

垃圾收集器与内存分配策略

先思考一下GC需要完成的3件事情:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

前面介绍了 Java 内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的每一个栈帧分配多少内存基本上在类结构确定下来时就已知的。而 Java 堆和方法区则不一定,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC 和后面所说的内存分配关注的就是这部分内存。

对象是否“存活”

引用计数算法

给对象添加一个引用计数起,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1;任何时刻计数器值为0的对象就是不可能再被使用的。但是主流的 Java 虚拟机里面没有选用引用计数器算法来管理内存,其中最主要的原因的就是它很难解决相互循环引用的问题。举个例子,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

//假设在这里发生GC,objA和objB是否能被回收?
System.gc();
}
}

运行结果显示,Java 虚拟机并没有因为两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是使用引用计数算法来判断是否存活的。

可达性分析算法

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

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

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

再谈引用

在 JDK 1.2之后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用。

  1. 强引用就是指在代码中普遍存在的,类似“Object obj = new Object()”这类的引用。只要强引用存在,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
  3. 弱引用也是用来描述非必需对象的,但它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK 1.2之后提供了 WeakReference 类来实现弱引用。
  4. 虚引用也称为幽灵引用或者幻影引用。它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。JDK 1.2之后提供PhantomReference 类来现实虚引用。

生存还是死亡

即使在可达性分析算法中不可达的对象,也不是“非死不可”的,这时候它们暂时处于“缓行”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析算法后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的 Finalize 线程去执行它。这里所谓的执行是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在 finalize() 方法中执行缓慢,活着发生了死循环,将很可能会导致 F-Queue 队列的其它对象永远处于等待,甚至导致整个内存回收系统崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后GC将对 F-Queue 队列中的对象进行第二次小规模的标记,如果对象要在 finalize() 方法中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将会被移除“即将收回”的集合;如果对象这个时候还没有逃脱,那基本上它就真的被回收了。例子如下:

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
public class FinalizeEscapeGC {
public static FinalizeEscapeGC 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 method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(Stirng[] args) throws Throwable {
SAVA_HOOK = new FinalizeEscapeGC();

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

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

运行结果如下:

1
2
3
finalize method executed!
yes, i am still alive!
no, i am dead

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

回收方法区

Java 虚拟机规范确实说过不要求虚拟机在方法区实现垃圾回收,而且在方法区中进行垃圾收集的性价比一般比较低:在堆中,尤其在新生代,常规应用进行一次垃圾收集一般可以回收70%-95%的空间,而永久代的垃圾收集则远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收 Java 堆中的对象非常相似。以常量池中字面量的回收为例,例如一个字符串“abc”已经进入了常量池,但是当前系统中没有一个 String 对象引用常量池中的“abc”常量,也没有其它地方引用这个字面量,如果这时方式内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中其它类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否是“废弃常量”比较简单,而要判断一个类是否是“无用的类”的条件则相对苛刻许多,类需要同时满足下面3个条件才能算是“无用的类”:

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

虚拟机可以对满足上面3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类回收,HotSpot 虚拟机提供了 -Xnoclassgc参数进行控制,还可以使用其它参数查看类加载和卸载信息。

垃圾收集算法

标记-清除算法

最基础的收集算法是“标记-收集(Mark-Sweep)算法”,如同它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一进行回收所有被标记的对象,它的标记过程在上一节讲述对象标记判定时已经介绍过了。它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片过大可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够大的连续内存而不得不提前触发另一次垃圾收集动作。执行过程如下:

复制算法

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

现在的商业虚拟机都是采用这种算法来回收新生代,IBM 公司的专门研究表明,新生代的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收内存时,Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 空间上,最后清理掉 Eden 和 刚才使用过的Survivor空间。HosSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。当然我们没有办法保证每次回收都只有不多余10%的对象存活,当 Survivor 空间不够用时,需要依赖其它内存(这里指的是老年代)进行分配担保。

标记-整理算法

复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代中一般不能直接选用这种算法。

根据老年代的特点,有人提出了一种“标记-整理”算法。标记过程仍然和“标记-清除”算法一样。但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端点边界以外的内存,“标记-整理”算法的过程如下:

分代收集算法

这种算法只是根据对象存活周期的不同将内存分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

HotSpot 算法实现

枚举根节点

从可达性分析中从 GC Roots节点查找引用链为例,可作为 GC Roots 的节点主要在全局性的引用(例如常量或者类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

另外,可达性分析对执行时间敏感还体现在 GC 停顿上,因为这项工作确保在一个能保持一致性的快照中进行--这里的“一致性”是指在分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中引用关系还在不断变化的情况。

目前主流的 Java 虚拟机使用的都是准确式 GC,所以当执行系统停下来后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。HotSpot 实现中,是使用一组称为 OopMap 的数据结构来达到这个目的的,在类加载完的时候,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用,这样 GC 就可以直接得知这些信息了。

安全点

在 OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,但另一个问题就来了:可能导致引用关系变化。或者说 OopMap 内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那么就需要大量的额外空间,这样 GC 的空间成本将会变的很高。

实际上,HotSpot 只是在“安全点(Safepoint)”记录了这些信息。Safepoint 的选定既不能太少以致于 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。一般具有方法调用、循环跳转、异常跳转功能的指令才会产生 Safepoint。

对于 Safepoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上在停顿下来。这里有两种方法:抢先式中断和主动式中断。抢先式中断不需要线程的执行代码主动配合,在 GC 发生时,首先把所有的线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式。

而主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域

Safepoint 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safepoint。但是程序不执行的时候(就是没有分配 CPU 时间,典型的例子就是线程处于 Sleep 状态或者 Blocked 状态)呢?这时候线程无法响应 JVM 的中断请求,“走”到安全的地方去中断挂起,JVM 也显然不太可能等待线程重新被分配 CPU 时间。这种情况就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域的任何地方 GC 都是安全的。当线程执行到 Safe Region 中的代码时,首先标志自己已经进入了 Safe Region,那样,当在这段时间里 JVM 要发起 GC时,就不用管标志自己为 Safe Region 状态的线程了。当线程要离开 Safe Region 时,它要检查系统是否已经完成了根节点枚举(或者是整个 GC 过程),如果完成了,那线程就继续执行。否则就必须等待直到收到可以安全离开 Safe Region 的信号为止。

垃圾收集器

这里讨论的收集器基于 HotSpot 虚拟机,这个虚拟机包含的所有收集器如下图:

Serial 收集器

Serial 收集器是一个单线程的收集器,但这个“单线程”的意义并不仅仅说明它只会使用一个 CPU 或者一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其它所有的工作线程,知道它收集结束。

但是它也有着优于其它收集器的地方:简单而高效(与其它收集器的单线程比),对于限定的单个 CPU 环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew 收集器基本上就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、对象分配规则、回收策略等都与 Serial 收集器完全一样。

ParNew 收集器是许多运行在 Server 模式下的虚拟机首选的新生代收集器,其中有一个与性能无关但是很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS(后面介绍) 收集器配合工作。

ParNew 收集器在单 CPU 环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百的保证可以超越 Serial 收集器。当然,随着可以使用的 CPU 的数量增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(譬如32个)的环境中,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

Parallel Scavenge 收集器

Parallel Scavenge 收集器的特点是它的关注点与其它收集器不同,CMS 等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(就是 CPU 用于运行用户代码的时间和 CPU 总消耗时间的比值)。

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

由于与吞吐量关系密切,Parallel Scavenge 收集器也经常被称为“吞吐量优先”收集器。除了上述两个参数外还有一个参数 -XX:+UseAdaptiveSizePolicy 值得关注。这是一个开关参数,当这个参数打开之后,就不需要手动指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例大小(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大的吞吐量,这种调节方式称为 GC 自适应的调节策略。这也是 Parallel Scavenge 收集器和 ParNew 收集器的一个重要区别。

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,同样是一个单线程的收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,那么它主要有两大用途:一种是在 JDK 1.5 及以前版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

Parallel Scavenge 的老年代版本,使用“标记-整理”算法。在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS 收集器

CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。基于“标记-清除”算法。运行过程包括下面四个步骤:

  1. 初始标记
  2. 并发标记
  3. 重新标记
  4. 并发清除

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一个 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是在进行 GC Roots Tracing 的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

虽然 CMS 具有并发收集、低停顿的优点,但是还远远达不到完美的程度,它有以下3个明显的缺点:

  1. CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量+3)/4,也就是当 CPU 在4个以上时,并发回收时垃圾收集线程不少于25%的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足4个(譬如2个)时,CMS 对用户程序的影响就可能变得很大。为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”的 CMS 收集器变种,所做的事情和单 CPU 年代 PC 机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让 GC 线程、用户线程交替运行,尽量减少 GC 线程独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些。实践证明,效果一般,目前版本已被声明为“deprecated”。
  2. CMS 收集器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。CMS 并发清理阶段用户线程还在运行,伴随着程序自然就还会有新的垃圾产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理它们,只好留待下一次收集处理。这一部分垃圾就称为“浮动垃圾”。由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留足够的内存空间给用户线程使用,因此 CMS 收集器不能像其它收集器那样等到老年代机会完全被填满了再进行收集,需要预留一部分空间提供提供并发收集时的程序运作使用。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这时启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
  3. CMS 基于“标记-清除”算法实现的,就意味着收集结束后会有大量空间碎片产生。为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。虚拟机还提供了另外一个参数:-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的 Full GC 之后,跟着来一次压缩的(默认为0,表示每次进入 Full GC 都进行碎片整理)。

G1 收集器

与其它 GC 收集器相比, G1具备以下特点:

  1. 并行和并发。
  2. 分代收集。
  3. 空间整合:与 CMS 的“标记-清理”算法不同,G1从整体上看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着 G1 运行期间不会产生内存碎片。
  4. 可预测的停顿:这是 G1 相比 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿之外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不超过 N 毫秒,这几乎已经是实时 Java (RTSJ)的垃圾收集器的特征了。

在 G1 之前的收集器进行收集的范围都是整个新生代或者老年代,而使用 G1 收集器时,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分 Region (不需要连续)的集合。

G1 之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在优先的时间内可以获得尽可能高的收集效率。

在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 中每个 Region 都有一个对应的 Remembered Set,虚拟机发现在对Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中,如果是,便通过 CardTable 把相关引用信息记录到被引用的对象所属的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不会对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作, G1 的运行大致分为以下几个步骤:

  1. 初始标记。
  2. 并发标记。
  3. 最终标记。
  4. 筛选回收。

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

内存分配和回收策略

对象的内存分配,往大方向讲,就是在堆上分配(但是也可能经过 JIT 编译后被拆散为标量类型并间接的在栈上分配),对象主要分配在新生代的 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况也可能会直接分配到老年代中,分配的规则并不是百分之百固定的。其细节取决于使用哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数。下面所述都是在使用 Serial/Serial Old收集器下的内存分配和回收的策略。

对象优先在 Eden 区分配

  • Minor GC :指发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也非常快。
  • Full GC/Major GC:指发生在老年代的 GC,出现了 Major GC,经常会伴随着至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程),Major GC 的速度一般会比 Minor GC 慢10倍以上。

大多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够的内存空间进行分配时,虚拟机发起一次 Minor GC。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个 -XX:pretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存复制(新生代是采用复制算法收集内存)。

长期存活的对象将进入老年代

虚拟机为每个对象定义了一个对象年龄计数器。如果对象在 Eden 区出生并经过第一次 Minor GC 后仍然存活,并且能够被 Survivor 区容纳的话,将被移动到 Survivor 区,并且对象年龄设为1。像这样每过一个 Minor GC,对象年龄就加一,当对象年龄增加到一定程度(默认15岁),就会被晋升到老年代。对象晋升到老年代的年龄阀值,可以使用参数 -XX:MaxTenuringThreshold 设置。

动态对象年龄判断

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升到老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或者等于该年龄的对象就可以直接晋升到老年代。

空间分配担保

在发生 Minor GC 前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么就会继续检查老年代的最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,如果小于,或者 HandlePromotionFailure 设置为不允许担保失败,那这时也要改为进行一次 Full GC。