概述

垃圾收集(Garbage Collection,GC),它不是Java语言的伴生产物,它的历史比Java还要久远。

人们主要思考GC需要完成的3件事情:

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

发展到现在,内存的动态分配与内存回收技术已经相当成熟。那么我们为什么还要去了解GC和内存分配呢?答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要介入GC的运作过程了。

Java的内存回收主要关注堆和方法区。程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭:栈中的栈帧随着方法的进入和退出而有条不紊地执行着出站和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

对象死亡的判定方法(哪些对象需要回收?)

要进行垃圾回收,那么我们应该需要回收哪些对象呢?如何判定一个对象是否需要回收?

当下比较流行的两个判定方法是:引用计数器法可达性分析法

引用计数算法

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

优点:实现简单,效率高。

缺点:无法解决对象之间互相循环引用的问题。

案例:微软的 COM 技术、使用 ActionScript 3 的 FlashPlayer、Python 语言。

可达性分析算法

思路:通过一系列的 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明对此对象是不可用的。此算法可以解决对象之间相互引用的问题。

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

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

HotSpot 算法实现(什么时候回收?)

枚举根节点

枚举根节点就是使用可达性分析算法找出与 GC Roots 节点项链的对象(不会被回收掉),可以作为 GC Roots 的对象包括:

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

Stop The World 的原因

使用可达性分析时,在通过 GC Roots 节点找出引用链这个操作时,必须在一个能确保一致性的快照中执行——这里 “一致性” 的意思是指整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况。即使是 CMS 收集器,在枚举根节点时也必须要 “Stop The World”。

快速完成对象枚举

在 HotSpot 中,使用一组 OopMap 的数据结构来标记对象引用的位置,目前主流的虚拟机都是准确式 GC,这样,在类加载完成之后,HotSpot 就把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,在 OopMap 的协助下,HotSpot 可以快速且准确的按成 GC Roots 枚举。

安全点

什么是安全点

OopMap 的内容变化的指令非常多,如果为每一条指令都生成对应的 OopMap,那会需要大量的额外空间,这样 GC 的空间成本将会变得很高。

实际上,HotSpot 也的确没有为每条指令都生成 OopMap,只是在 “特定的位置” 记录了这些信息,这些信息成为安全点(SafePoint),即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。Safepoint 的选定既不能太少以致于让 GC 等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

如何选择安全点

安全点的选定是以 “是否具有让程序长时间执行的特征” 为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行” 的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生 SafePoint。

安全点暂停的方式

对于 SefePoint,另一个需要考虑的问题是如何在 GC 发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。

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

安全区域

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

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

垃圾清除算法(如何回收这些对象?)

这里介绍目前比较流行的几种垃圾收集算法的思想。

标记-清除算法

这是一种最基础的收集算法,如同它的它的名字一样,算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记算法就是使用可达性分析算法来判定的。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

它的不足主要有两个:

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

复制算法(多用于新生代)

它将可用内存按容量分为大小相等的两块,每次只是用其中一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过得内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要一动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

代价:将内存缩小为了原来的一半,代价有点高。

现在商业虚拟机都是采用这种收集算法来回收新生代,但是不是讲内存一分为二。IBM 公司研究表明,新生代中的对象 98% 是 “朝生夕死” 的,所以并不需要按照 1:1 的比例进行划分内存空间,而将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Servivor,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性的复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80% + 10%),只有 10% 的空间会被 “浪费”,当然,98%的对象可回收知识一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其它内存(这里指老年代)进行分配担保。

如果另一块 Survivor 空间没有足够的空间存放上一次新生代手机下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。关于新生代进行分配担保的内容,在后面讲解垃圾收集器执行规则时还会再详细讲解。

标记-整理算法(多用于老年代)

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会大大降低。所以老年代中一般不能直接选用这种算法。

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

分代收集算法

当前比较流行的垃圾收集都采用 “分代手机” 算法,这种算法并没有什么新思想,只是根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的垃圾收集算法。新生代中,每次收集都会有大量的对象死去,只有少量的存活,就采用复制算法,而老年代中的对象存活率比较高,就必须采用 “标记-清除” 或者 “标记-整理” 算法来进行回收。

垃圾收集器

这里的垃圾收集器就是上面说的垃圾回收算法的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定。

目前有 7 中不同的收集器:

  1. Serial(新生代)
  2. ParNew(新生代)
  3. Parallel Scavenge(新生代)
  4. Serial Old(老年代)
  5. Parallel Old(老年代)
  6. CMS(老年代)
  7. G1(新生代 + 老年代)

下面展示了哪些虚拟机可以搭配使用:

  • Serial + Serial Old
  • Serial + CMS
  • ParNew + Serial Old
  • ParNew + CMS
  • Parallel Scavenge + Serial Old
  • Parallel Scavenge + Parallel Old
  • G1

注意:Serial Old 可以作为 CMS 的后备选择,当 CMS 不能工作时,Serial Old 可以顶上去。

可以发现(G1 收集器除外):

  1. Serial Old 收集器可以与任何年代代收集器搭配使用。
  2. CMS 收集器只能和 Serial 或 ParNew 搭配使用,Parallel Scavenge 则不行。
  3. Parallel Old 收集器只能和 Parallel Scavenge 搭配使用。

没有万能的收集器,只有针对具体应用的最适合的收集器。下面我们来一一介绍这些收集器的特性。

Serial 收集器

Serial 收集器是最基本的、发展历史最悠久的收集器,它是 JDK 1.3.1 之前虚拟机新生代的唯一选择。它是一个单线程的收集器,进行垃圾收集时,它还必须暂停其它所有的工作线程(“Stop The World”),直到收集结束。

优点

单线程带来的优点就是简单而高效,没有线程交互的开销。在 Client 模式下,新生代的内存往往不是很大,收集时的停顿时间可以完全控制在几十毫秒最多一百毫秒以内,是可以接受的。所以,对于 Client 模式下的虚拟机,Serial 收集器往往是一个很好的选择。

缺点

发生收集时会 “Stop The World“。从 JDK1.3 开始,HotSpot 虚拟机开发团队为消除或减少工作线程因内存回收而导致的停顿的努力一直进行着,从 Serial 到 Parallel 再到 CMS,乃至 G1,用户线程的停顿时间不断的缩短,但是仍然没有办法完全消除(不包括 RTSJ 中的收集器)。

ParNew 收集器

ParNew 收集器就是 Serial 收集器的多线程版本。它包括了 Serial 收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure)、收集算法、Stop The World、对象分配规则、回收策略等。唯一的区别就是 它使用了多线程

该收集器是运行在 Server 模式下新生代的首选收集器。有一个与性能无关的原因就是除了 Serial 收集器外,目前只有它能与 CMS 收集器配合使用。

ParNew 收集器默认开启的收集线程数量与 CPU 的数量相同,在 CPU 非常多的环境下,可以使用 -XX:ParallelGCThreads 参数限制垃圾收集的线程数。

并发和并行

在谈论垃圾收集器的上下文中,两个词可以解释为:

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

Parallel Scavenge 收集器

它是一个新生代收集器,新生代也是使用 “复制算法”,也是使用多线程收集。

特点

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

Parallel Scavenge 收集器提供了两个参数用于精确的控制吞吐量,分别是:

  1. 控制最大垃圾收集停顿时间的:-XX:MaxGCPauseMillis
  2. 直接设置吞吐量大小的:-XX:GCTimeRatio

MxGCPauseMillis

该参数允许的值是一个大于 0 的毫秒数,收集器将极可能的保持内存回收花费的时间不超过设定值。但是不要仍未如果把这个参数的值设置的稍小一点就能使系统的垃圾收集变快,GC 停顿时间的缩短是以牺牲吞吐量和新生代空间来换取的。

GCTimeRatio

该参数值是一个 0 到 100 的整数(不包括 0 和 100),也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。如果把值设置为 19,那么允许的最大 GC 时间就占总时间的 5%(即 1 /(1 + 19)),默认值是 99,就是允许最大 1% (即 1 /(1 + 99))的垃圾收集时间。

-XX:+UseAdaptiveSizePolicy

这是一个开关参数,当这个参数打开时,就不需要手工指定新生代的大小(-Xmn)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:OretenureSizeThreshold)等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC自适应的调整策略

自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要的区别。

Serial Old 收集器

它就是 Serial 收集器的老年代版本,同样也是一个单线程的收集器,使用 “标记-整理” 算法。这个收集器的主要意义也是给 Client 模式下的虚拟机使用,如果在 Server 模式下,它由两个用途:

  1. 在 JDK 1.5 以及之后的版本中与 Parallel Scavenge 收集器搭配使用。
  2. 作为 CMS 收集器的后备预案。

Parallel Old 收集器

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

在 JDK 1.6 之后才开始提供,在此之前,新生代的 Parallel Scavenge 收集器只能选择 Serial Old(PS MarkSweep)收集器作为老年代收集器,由于单线程的老年代收集无法充分利用服务器多 CPU 的处理能力,所以 Parallel Scavenge 收集器被 Serial Old “拖累” 了。

直到 Parallel Old 收集器出现后,“吞吐量优先” 收集器有了比较名副其实的应用组合。

CMS 收集器

这是一种以获取最短停顿时间为目标的收集器,它是基于 “标记-清除” 算法实现的。

收集过程分为 4 个步骤:

  1. 初始标记(CMS initial mark)—— Stop The World
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)—— Stop The World
  4. 并发清除(CMS concurrent sweep)

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

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

CMS 收集器的缺点

1. 对 CPU 资源非常敏感

因为在并发标记阶段,它虽然不会导致应用程序变慢,但是会因为占用了一部分 CPU 资源而导致应用程序变慢,总吞吐量降低。CMS 默认启动的回收线程数是(CPU 数量 + 3)/ 4,回收线程占用的CPU资源随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个时,CMS 对用户程序的影响就可能变得很大。

2. 无法处理浮动垃圾

由于垃圾收集阶段用户程序还需要运行,那也就需要预留足够的内存空间给用户线程使用,因此 CMS 收集器不会像其它收集器那样等到老年代几乎填满了再启动收集。在 JDK 1.6 的默认设置中, 当老年代使用了 92% 的时候垃圾收集就会被激活,如果需要可以通过改变 -XX:CMSInitiatingOccupancyFraction 的值来改变触发百分比。

可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。Why?

如果 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这样虚拟机就会启动后备方案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就长了,所以说 -XX:CMSInitiatingOccupancyFraction 的值不要设置的太高。

3. 收集结束后会有大量内存碎片

因为它是基于 “标记-清除” 算法实现的收集器。内存碎片过多会给大对象的分配带来麻烦,往往老年代还有很大的空间,但是无法找到足够大的连续内存空间分配当前对象,不得不触发一次 Full GC,为了解决这个问题,CMS 提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认是开启的),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片整理过程,内存整理的过程是无法并发的,空间碎片的问题没有了,但停顿时间不得不变长。还有一个参数是:-XX:CMSFullGCsBeforeCompaction,它来控制执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值是 0,表示每次进入 Full GC 时都进行碎片整理)。

G1 收集器

它是被设计出来用来替代 CMS 收集器的。

特点:

  1. 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop The World 停顿的时间,部分其它收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
  2. 分代收集:分代概念在 G1 中依然得以保留,虽然 G1 不需要其它收集器配合就可以管理整个 GC 堆,但它采用不同的方式去处理新老对象。
  3. 空间整合:与 CMS 的 “标记-清理” 算法不同,G1 从整体上看是基于 “标记-整理” 算法实现的收集器,从局部上看是基于 “复制” 算法实现的,但无论如何,这两种算法都以为着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长久运行。
  4. 可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

新生代和老年代

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

可预测的停顿时间模型

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

这里只对 G1 收集器做个简单的介绍,想更详细的了解 G1 收集器,就去百度吧,哈哈哈。。。

摘自: 周志明的《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版

05-23 13:54