【Java】垃圾收集(Garbage Collection)

Posted by 西维蜀黍 on 2019-03-26, Last Modified on 2025-04-23

背景

程序计数器、虚拟机栈和本地方法栈 3 个区域随线程而生,随线程而灭。

栈中的栈帧随着方法的进入和推出,而进行着出栈和进栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来就已知的。

因此这几个区域的内存分配和回收都具有确定性,在方法结束或线程结束时,内存就跟随着回收了。

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


对于从事 C 和 C++ 程序开发的开发人员来说,在内存管理领域,他们既是拥有最高权利的皇帝,也是从事最基础工作的劳动人民 —–既拥有每一个对象的所有权,又担负着每一个对象从生命开始到终结的维护责任。 对于 Java 程序员来说,虚拟机的自动内存分配机制的帮助下,不再需要为每一个 new 操作去写配对的 delete/free 代码,而且不容易出现内存泄露和内存溢出问题,看起来由虚拟机管理内存一切都很美好。不过,也正是因为 Java 程序员把内存控制的权利交给 Java 虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排查错误将会是一项异常艰难的工作。

判断对象已死 - 哪些内存需要回收

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

引用计数算法(Reference Counting)

很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加 1;当引用失效时,计数器减 1;任何时刻计数器都为 0 的对象就是不可能再被使用的。

引用计数算法的实现简单,判断效率也很高,在大部分情况下它都是一个不错的算法。但是 Java 语言中没有选用引用计数算法来管理内存,其中最主要的一个原因是它很难解决对象之间相互循环引用的问题。

实验

/** 
 * 执行后,objA和objB会不会被GC呢? 
 */  
public class ReferenceCountingGC {  
    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) {  
        ReferenceCountingGC objA = new ReferenceCountingGC();  
        ReferenceCountingGC objB = new ReferenceCountingGC();  
        objA.instance = objB;  
        objB.instance = objA;  
  
        objA = null;  
        objB = null;  
  
        //假设在这行发生了GC,objA和ojbB是否被回收  
        System.gc();  
    }  
}  

在 testGC () 方法中,对象 objA 和 objB 都有字段 instance,赋值令 objA.instance=objB 及 objB.instance=objA,除此之外,这两个对象再无其他引用。

实际,上这两个对象都已经不能再被访问,但是它们因为相互引用着对象方,因为它们的引用计数都不为 0,于是引用计数算法无法通知 GC 收集器回收它们。

0.193: [GC 4418K->256K(61504K), 0.0046018 secs]
0.198: [Full GC 256K->160K(61504K), 0.0125962 secs]

在运行结果中,可以看到 GC 日志中包含 "4418K->256K",老年代从 4418K (大约 4M,其实就是 objA 与 objB) 变为了 141K,意味着虚拟并没有因为这两个对象相互引用就不回收它们,这也证明虚拟并不是通过通过引用计数算法来判断对象是否存活的。

大家可以看到对象进入了老年代,但是大家都知道,对象刚创建的时候是分配在新生代中的,要进入老年代默认年龄要到了 15 才行,但这里 objA 与 objB 却进入了老年代。这是因为 Java 堆区会动态增长,刚开始时堆区较小,对象进入老年代还有一规则,当 Survior 空间中同一代的对象大小之和超过 Survior 空间的一半时,对象将直接进行老年代。

可达性分析算法(Reachability Analysis)/ 根搜索算法(GC Roots Tracing)

可达性分析算法(Reachability Analysis)是通过判断对象的引用链(Reference Chain)是否可达来决定对象是否可以被回收

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。

下图对象 object5, object6, object7 虽然有互相判断,但它们到 GC Roots 是不可达的,所以它们将会判定为是可回收对象。

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

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 Native 方法引用的对象;

不同的引用

无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与 “引用” 有关。

在 JDK 1.2 之前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。

对于如何描述一些 “食之无味,弃之可惜” 的对象就显得无能为力。

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。

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

对象标记过程

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

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

这里所谓的 “执行” 是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在 finalize () 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致 F-Queue 队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

finalize () 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记:

  • 如果对象要在 finalize () 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出 “即将回收” 的集合;
  • 如果对象这时候还没有逃脱,那它就真的离死不远了。

回收方法区

很多人认为方法区(或者 HotSpot 虚拟机中的永久代)是没有垃圾收集的,Java 虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的 “性价比” 一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收 70%〜95% 的空间,而永久代的垃圾收集效率远低于此。

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

回收废弃常量与回收 Java 堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串 “abc” 已经进入了常量池中,但是当前系统没有任何一个 String 对象是叫做 “abc” 的,换句话说是没有任何 String 对象引用常量池中的 “abc” 常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个 “abc” 常量就会被系统 “请” 出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。


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

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

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

在大量使用反射、动态代理、CGLib 等 bytecode 框架的场景,以及动态生成 JSP 和 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

什么时候进行垃圾回收?

为了增大垃圾收集的效率,所以 JVM 将堆进行分代,分为不同的部分。

如何分代?

如图所示,虚拟机中的共划分为三个代:新生代(Young Generation)、老年代(Tenured Generation)和持久代(Permanent Generation)。其中持久代主要存放的是 Java 类的类信息,与垃圾收集要收集的 Java 对象关系不大。新生代和老年代的划分是对垃圾收集影响比较大的。

新生代(Young Generation)

所有新生成的对象首先都是放在新生代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。

新生代分三个区。一个 Eden 区,两个 Survivor 区 (一般而言)。大部分对象在 Eden 区中生成。

需要注意,Survivor 的两个区是对称的,没先后关系。所以同一个区中,可能同时存在从 Eden 复制过来对象和从前一个 Survivor 复制过来的对象。而复制到年老区的只有从第一个 Survivor 区过来的对象。而且,Survivor 区总有一个是空的。同时,根据程序需要,Survivor 区是可以配置为多个的(多于两个),这样可以增加对象在新生代中的存在时间,减少被放到老年代的可能。

老年代(Tenured Generation)

在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。

持久代(Permanent Generation)

持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过 -XX:MaxPermSize=<N> 进行设置(JDK1.8 后被元空间取代)。

在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间(Metaspace)中。元空间存储类的元信息,静态变量和常量池等放入堆中。

注意,元空间使用的是物理内存,而不再使用 JVM 内存。

什么情况下触发垃圾回收

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC 有两种类型:Young GC(Minor GC)和 Full GC(Major GC)。

Young GC(Minor GC)

一般情况下,对象在新生代 Eden 区中分配。

当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Young GC(Minor GC),对 Eden 区域进行 GC,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区。

然后整理 Survivor 的两个区。这种方式的 GC 是对新生代的 Eden 区进行,不会影响到老年代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。

因而,一般在这里需要使用速度快、效率高的算法,使 Eden 区能尽快空闲出来。

注意 young GC 中有部分存活对象会晋升到老年代,所以 young GC 后,老年代的占用量通常会有所升高。

新生代通常存活时间较短通常基于 Copying 算法进行回收,所谓 Copying 算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在 Eden 和 FromSpace 或 ToSpace 之间 copy。新生代采用空闲指针的方式来控制 GC 触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发 GC。当连续分配对象时,对象会逐渐从 Eden 到 Survivor,最后到老年代。

Old GC

老年代与新生代不同,老年代对象存活的时间比较长、比较稳定,因此通常采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并、要么标记出来便于下次进行分配,总之目的就是要减少内存碎片带来的效率损耗。

由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。

Full GC(Major GC)

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

1 调用 System.gc ()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

2 老年代空间不足

老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3 空间分配担保失败

使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。

垃圾收集算法 - 如何回收

背景

在介绍垃圾收集算法之前,我们先介绍一下进行回收时的一些基本操作

Step 1: Marking 标记

第一步就是标记,也就是垃圾收集器会找出那些需要回收的对象所在的内存和不需要回收的对象所在的内存,并把它们标记出来,简单的说,也就是先找出垃圾在哪。

所有堆中的对象都会被扫描一遍,以此来确定回收的对象,所以这通常会是一个相对比较耗时的过程。

Step 2: Normal Deletion

垃圾收集器会清除掉上一步标记出来的那些需要回收的对象区域。

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

Step 2a: Deletion with Compacting 压缩

由于简单的清除可能会存在碎片的问题,所以又出现了压缩清除的方法,也就是先清除需要回收的对象,然后再对内存进行压缩操作,将内存分成可用和不可用两大部分。

垃圾收集算法

标记 - 清除(Mark-Sweep)算法

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

之所以说标记 - 清除算法是最基础的收集算法,说因为后续的收集算法都是基于这种思路,并对其不足进行改进而得到的。

不足

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

复制算法

它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,也不会产生碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

只是这种算法的代价是将可使用的内存缩小为一半,代价较大。

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是 “朝生夕死” 的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。

当回收时,将 Eden 和 Survivor 中还存活着的对象一次性的复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被 “浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保就好比我们去银行借款,如果我们信誉很好,在 98% 的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量的偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

标记 - 整理算法

背景

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

思想

根据老年代的特点,有人提出了另外一种 “标记 - 整理” 算法,其实这里的标记 - 整理就是在标记 - 清除算法中加了一步:整理。即就是 “标记 - 整理 - 清除” 算法。前边都是一样,先标记处可达对象(存活对象),但是后续步骤不是直接对可回收对象进行清除,而是让所有存活的对象向一端移动,然后直接清除掉端边界以外的内存。

较 “标记 - 清除” 算法而言,多了一步移动(整理)的过程,效率低。

分代收集算法 (Generational Collection)

当前商业虚拟机的垃圾收集都采用 “分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同,将内存划分为几块。一般是把 Java 堆分为新生代(Young generation)和老年代(Tenured generation),这样就可以根据各个年代的特点采用最适当的收集算法。

  • 新生代(Young generation):每次垃圾收集时都发现大批对象死去,对象存活率低。选用复制算法。
  • 老年代(Tenured generation):对象存活率高,没有额外的内存空间对它进行分配担保,就必须选用 “标记 - 清除” 或 “标记 - 整理” 算法进行回收。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,所以不同的厂商、不同版本的虚拟机可以根据需要提供不同的收集器。我们这里讨论的收集器是基于 JDK1.7 Update 14 之后的 HotSpot 虚拟机,其包含的所有收集器如下图所示:

图中上下两个区域分别代表收集器是属于新生代收集器还是老年代收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。

Serial 收集器

Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器。在 JDK1.3.1 前,是 HotSpot 新生代收集的唯一选择。

Serial 收集器是一个单线程的收集器,但它的 “单线程” 的意义并不仅仅是说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Sun 将这件事情称之为 “stop the world”),直到它收集结束。

“Stop the world” 听上去有点酷,这项工作实际上是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户的正常工作的线程全部停掉,这对很多应用来说都是难以接受的。

对于 “Stop The World” 带给用户的不良体验,虚拟机的设计者们表示完全理解,但也表示非常委屈:“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?” 这确实是一个合情合理的矛盾,虽然垃圾收集这项工作听起来和打扫房间属于一个性质的,但实际上肯定还要比打扫房间复杂得多啊!

Serial /Serial Old 收集器运行示意图如下(注意是只有一个线程在收集):

从 JDK 1.3 开始,一直到现在最新的 JDK 1.7,HotSpot 虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,从 Serial 收集器到 Parallel 收集器,再到 Concurrent Mark Sweep(CMS)乃至 GC 收集器的最前沿成果 Garbage First(G1)收集器,我们看到了一个个越来越优秀(也越来越复杂)的收集器的出现,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括 RTSJ 中的收集器)。寻找更优秀的垃圾收集器的工作仍在继续!

写到这里,笔者似乎已经把 Serial 收集器描述成一个 “老而无用、食之无味弃之可惜” 的鸡肋了,但实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集之外,其与行为包括 Serial 收集器都可用的所有控制参数(例如 :-XX:SurvivorRatio、-XX:PretenureSizeThreshold\ -XX:HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回售策略等都与 Serial 收集器完全一样。

在现实上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如下图:

ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server,模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

在 JDK1.5 时期,HotSSpot 推出了一款在强交互应用中,几乎可称为有划时代意义的垃圾收集器–CMS 收集器(Concurent Mark Sweep)。这款收集器是 HoSpot 虚拟机中第教真正意义上的并发(Comcuren)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,用前面那个例子的话来说,就是做到了在你的妈打扫房间的时候你还能一边往地上扔纸屑。

不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Paralel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。 ParNew 收集器也是使用 - XX:+UseConcMarkSwcepGC 选项后的默认新生代收集器,也可以使用 - XX:+UseParNewGC 选项来强制指定它。

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

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和 ParNew 都一样,那它有什么特别之处呢?

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),如果虚拟机总共运行需要 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;而高吞吐量则可用最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

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

MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集 300MB 新生代肯定比收集 500MB 快吧,这也直接导致垃圾收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。

GCTimeRatio 参数的值应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。如果把此参数设置为 19,那允许的最大 GC 时间就占总时间的 5%,默认值为 99,就是允许最大 1% 的垃圾收集时间。

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

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,它是一个单线程收集器,使用 “标记-整理” 算法。这个收集器的主要意义也是被 Client 模式下的虚拟机使用。

在 server 模式下,它主要还有两大用途:

  • 一个是在 JDK1.5 及之前的版本中与 Parallel Scavenge 收集器搭配使用;
  • 另外一个就是作为 CMS 收集器的后备元,在并发收集发生 Concurrent Mode Failure 的时候使用。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记-整理” 算法。

这个收集器是在 JDK1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器外别无选择。由于单线程的老年代收集器在服务端应用性能上 “拖累”,即便使用 Parallel Scavenge 也未必能在整体应用上获得吞吐量最大化的效果,又因为老年代集中无法充分利用服务器多 CPU 的处理能力,在老年代很呆而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合 “给力”。

直到 Parallel Old 收集器出现后,“吞吐量优先” 收集器终于有了比较名副其实的应用组合,在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS(Concurrent Mark Sweeps)收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

目前很大一部分的 Java 应用都集中在互联网站或 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

从名字(包含 “Mark Sweep”)上就可以看出 CMS 收集器是基于 “标记 - 清除” 算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为 4 个步骤,包括:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

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

由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发地执行的。通过下图可以比较清楚地看到 CMS 收集器的运作步骤中并发和需要停顿的时间。

CMS 是一款优秀的收集器,它的最主要优点在名字上已经体现出来了:并发收集、低停顿,Sun 的一些官方文档里面也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但是 CMS 还远达不到完美的程度,它有以下三个显著的缺点:

  • CMS 收集器对 CPU 资源非常敏感。其实,面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用 了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量 + 3)/ 4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程最多占用不超过 25% 的 CPU 资源。但是当 CPU 不足 4 个时(譬如 2 个),那么 CMS 对用户程序 的影响就可能变得很大,如果 CPU 负载本来就比较大的时候,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,这也 很让人受不了。为了解决这种情况,虚拟机提供了一种称为 “增量式并发收集器”(Incremental Concurrent Mark Sweep /i-CMS)的 CMS 收集器变种,所做的事情和单 CPU 年代 PC 机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记和并发清理的时候让 GC 线程、用户线程交替运行,尽量减少 GC 线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,速度下降也就没有那么明 显,但是目前版本中,i-CMS 已经被声明为 “deprecated”,即不再提倡用户使用。

  • CMS 收集器无法处理浮动垃圾(Floating Garbage),可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在本次 收集中处理掉它们,只好留待下一次 GC 时再将其清理掉。这一部分垃圾就称为 “浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即还需要预留足够的 内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使 用。在默认设置下,CMS 收集器在老年代使用了 68% 的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数 -XX:CMSInitiatingOccupancyFraction 的值来提高触发百分比,以便降低内存回收次数以获取更好的性能。CMS 需要较大的内存空间去运行垃圾收集,此时用户程序也在运行,要是 CMS 运行期 间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这时候虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 - XX:CMSInitiatingOccupancyFraction 设置 得太高将会很容易导致大量 “Concurrent Mode Failure” 失败,性能反而降低。

  • 还有最后一个缺点,在本节在开头说过,CMS 是一款基于 “标记 - 清除” 算法实现的收集器,如果读者对前面这种算法介绍还有印象的话,就可能想到这 意味着收集结束时会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续 空间来分配当前对象,不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个 - XX:+UseCMSCompactAtFullCollection 开关参数,用于在 “享受” 完 Full GC 服务之后额外免费附送一个碎片整理过程,内存整理的过程是无法并发的。空间碎片问题没有了,但停顿时间不得不变长了。虚拟机设计者们还提供了另外一个 参数 - XX: CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不压缩的 Full GC 后,跟着来一次带压缩的。

G1(Garbage First)收集器

G1(Garbage First)收集器是当前收集器技术发展的最前沿成果,在 JDK1.6_Updata14 中提供了 Early Access 版本的 G1 收集器以供适用。

G1 收集器是垃圾收集器理论进一步发展的产物,它与前面的 CMS 收集器相比有两个显著的改进:

  1. G1 收集器是基于 “标记-整理” 算法实现的收集器,也就是说它不会产生碎片,这对于长时间运行的应用系统来说比较重要。
  2. 它可以非常精确地控制停顿,既能让使用者明确指定爱一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java 的垃圾收集器的特征了。

G1 收集器可以实现在基本不牺牲吞吐量的前提下完成低停顿的内存回收,这是由于它能够极力地避免完全区域的垃圾收集,之前的收集器进行收集的范围都是整个新生代或老年代,而 G1 将整个 Java 堆划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。区域划分及优先级的区域回收,保证了 G1 收集器在有限的时间内可以获得可以获得最高的收集效率。

内存分配与回收策略

Java 技术体系中所提倡的 自动内存管理 最终可以归结为自动化地解决了两个问题: 给对象分配内存 以及 回收分配给对象的内存

对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过 JIT 编译后被拆散为标量类型并间接地在栈上分配)。

对象主要分配在新生代的 Eden 区,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象优先在 Eden 区中分配

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

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

分析

虚拟机提供了 - XX : PrintGCDetails 这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  */
public static void testAllocation() {
     byte[] allocation1, allocation2, allocation3, allocation4;
     allocation1 = new byte[2 * _1MB];
     allocation2 = new byte[2 * _1MB];
     allocation3 = new byte[2 * _1MB];
     allocation4 = new byte[4 * _1MB];  // 出现一次Minor GC
 }

运行结果:

上面代码的 testAllocation () 方法中,尝试分配 3 个 2MB 大小和 1 个 4MB 大小的对象,在运行时通过 - XMs20M、-XMx20M、-Xmn10M 这 3 个参数限制了 Java 堆大小为 20MB,不可扩展,其中 10MB 分配给新生代,剩下的 10MB 分配给老年代。-XX : SurvivorRatio=9 决定了新生代中 Eden 区与一个 Survivor 区的空间比例是 8:1,从输出的结果也可以清晰的看到 “eden space 8192K、from space 1024K、to space 1024K” 的信息,新生代总可用空间为 9216KB(Eden 区 + 1 个 Survivor 区的总容量)。

执行 testAllocation () 中分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次 GC 发生的原因是给 allocation4 分配内存的时候 i,发现 Eden 已经被占用了 6MV,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。

这次 GC 结束后,4MB 的 allocation4 对象顺利分配在 Eden 中,因此程序执行完的结果是 Eden 占用 4MB(被 allocation4 占用),Survivor 空闲,老年代被占用(被 allocation1、allocation2、allocation3 占用)。通过 GC 日志可以证实这一点。

大对象直接进入老年代

所谓 “大对象” 就是指一个需要占用大量连续存储空间的对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,更坏的消息则是遇到一群 “朝生夕死” 的 “短命大对象”,写程序的时候应当避免。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来 “安置” 它们。

当发现一个大对象在 Eden 区 + Survior1 区中存不下的时候就需要分配担保机制把当前 Eden 区 + Survior1 区的所有对象都复制到老年代中区。

我们知道,一个大对象能够存入 Eden 区 + Survior1 区中的概率比较小,发生分配担保机制的概率比较大,而分配担保需要涉及到大量的复制,就会造成效率低下。

因此,对于大对象我们直接把他放到老年代中去,从而就能避免大量的复制操作。

生命周期较长的对象进入老年代

老年代用于存储生命周期较长的对象,那么如何判断一个对象的年龄呢?

新生代中每个对象都有一个年龄计数器,如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活并且能被 Survior 容纳的话,将被移动到 Survior 空间中,并且对象年龄设为 1。对象在 Survior 区中每 “熬过” 一次 MinorGC,年龄就增加 1 岁。当它的年龄增加到一定程度(默认为 15 岁),将会被晋升到老年代中。

使用 - XXMaxTenuringThreshold 设置新生代的最大年龄。设置该参数后,只要超过该参数的新生代对象都会被转移到老年代中去。

动态对象年龄判定

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

分配担保策略

在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象空间。如果这个条件成立,那么 minor GC 可以确保是安全的。

如果上述条件不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 MinorGC,尽管这次 MinorGC 是有风险的;如果小于或者 HandlePromotionFailure 设置不允许冒险,那这时要更改为一次 Full GC。通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。

这个过程就是分配担保。那么冒险是冒了什么风险?

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survior 空间来作为轮换备份。因此当出现大量对象在 MinorGC 后仍然存活的情况 (最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survior 无法容纳的对象直接进入老年代。

而老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段。也就是说,如果某次 MinorGC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败 (HandlePromotion Failure)。如果出现了担保失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。

这个规则在 JDK6 Update24 之后变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 MinorGC,否则将进行 Full GC。

Reference