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

1. 一些探讨

目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了自动化的时代,为什么还需要去了解 GC 和内存分配呢?当需要排查各种内存溢出、内存泄露问题时,当垃圾收集器称为系统达到高并发量的瓶颈时,我们就需要对这些技术进行必要的监控和调节

早年使用垃圾回收机制去完成需求,渐渐的,垃圾回收反而称为了瓶颈,这个时候就需要关注垃圾回收了。

对于线程私有的程序计数器、虚拟机栈、本地方法栈,它们随着线程而生,线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出入栈的操作。每一个栈帧中分配多少内存基本上在类结构确定下来的时候就已知了。因此这些区域的内存分配和回收都具备确定性。

Heap方法区则不一样。一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存是动态的,垃圾收集器所关注的也是这部分内存。

比如有多少常量(方法区),多少对象(堆),这些在运行时才能知道。

1.1 对象消亡

对象有没有消亡有两个判断标准

  1. 引用计数器,这有漏洞,互相引用就回收不了
  2. 可达性分析,主流

引用计数算法:给对象添加一个引用计数器,初始是0,有一个地方引用就加1,失效就减1,当引用计数器的数值为0,mnior gc 的时候回回收对象,如果不为0,则标志位加1,到15就进入老年代。

主流 JVM 因为循环引用的原因不选择该方法,但仅仅是语言层面的问题,只要能做到一个是弱引用,就可以解决了。

可达性分析算法:从一些的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所过的路径称为引用链 Reference Chain ,但一个对象到 GC Roots 没有任何引用链想连,那么这些对象是不可达的,可以回收。

可以作为 GC Roots 的对象都是可预期的,它们就像风筝的线,不管风筝怎么摇晃,只要抓住线就好了。

可以作为 GC Roots 的对象包括下面几种:

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

1.2 再谈引用

Jdk1.2 前, Java 对于引用的定义比较传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用

我们除了描述被引用没有被引用两种状态外,还希望描述这样一类对象:当内存空间还够,则能保留在内存中;如果内存空间在进行 GC 后还是紧张,则可以抛弃这些对象。事实上,很多系统的缓存功能都符合这样的应用场景。

Jdk1.2 后,我们对引用进行了扩充,将引用分为了强引用、软引用、弱引用、虚引用,这4种引用强度依次递减。

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

强引用:地位不可撼动,绝对的实力派。
软引用:正所谓食之无味,弃之可惜,JVM 实在忍不住了才会收拾它们。
弱引用:必死无疑。
虚引用:或者浪费空气,死去浪费土地。

1.3 生存还是死亡

在可达性分析中即使是不可达的对象,也并非是非死不可,它们只是被缓刑了,期间需要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接引用链,那么它会被第一次标记,并且进行一次筛选,这里筛选的条件是此对象是否有必要执行一次 finalize() 方法,当对象没有覆盖该方法,或者该方法已经被虚拟机调用过,虚拟机将这两种情况视作“没有必要执行 GC” 。如果这个方法被判定有必要执行 finalize() 方法,那么这个对象将被放置在一个叫做 F-Queue 的队列中,并在稍后由 JVM 自动建立的、低优先级的 Finalizer 线程去执行它,但并不承诺它运行结束,这样做的原因是,如果一个对象在 finalize() 方法中执行缓慢,或者死循环,将有可能导致整个 F-Queue 队列中其他对象永久处于等待,甚至整个内存回收系统崩溃。
  2. finalize() 方法是对象躲过死亡的最后机会,接着 GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果对象在 finalize() 方法中成功拯救自己——只要重新与引用链上任何一个对象建立连接,比如把 this 赋值给某个类变量或者对象的成员变量,那么在第二次标记的时候将把它移出,如果没有逃离,那么就真的被回收了。

任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 不会再次执行,也就是说,它只能自救一次,或者说,第二次标记直接省略了已经执行过该方法的对象。

值得一提的是, finalize() 方法运行代价高昂,不确定性大不建议使用,它的功能在 try...finally 语句中可以做的更好更及时,它是对 C 语言的一种妥协。

JVM 自动创建的 Finalizer 线程优先级低,无法保证执行时讯,并且也不保证即使得到执行,也一定等待其返回,因为其是利用 F-Queue 队列加创建独立 Finalizer 线程执行的方式。

1.4 回收方法区

很多人认为方法区没有 GC , JVM 中确实说过可以不要求 JVM 在方法区中实现垃圾收集,并且在方法区中进行 GC 的性价比低得一塌糊涂。

HotSpot 中永久代(即方法区)的 GC 包括两部分:常量、类。常量还好,只要没有引用,在必要的时间,它会被清除,接口、方法、字段的符号引用也类似,而无用的类的判定略显复杂,需要满足以下三者,才算是“无用的类”:

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

满足以上三条,才算是可以回收,还不一定回收。在大量使用反射、动态代理、GCLib 等 ByteCode 框架、动态生成 JSP 以及 OSGI 这类平凡自定义 ClassLoader 的场景中都需要 JVM 具备类写在的功能,保证永久代不会溢出。

2. 垃圾收集算法

2.1 标记—清除算法

Mark - Sweep 算法,算法分为标记清除两个步骤,首先编辑出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

不足之处:1. 效率,标记和清除的效率都不高; 2.空间,标记清除之后会产生大量不连续的内部碎片。

2.2 复制算法

为了解决效率问题,一种称为 Copying 的收集算法出现了,它可以进内存按容量划分为大小相等的两块,每次使用其中一块,当这一块用完了,将存活的对象复制到另外一块上,然后将使用过的内存空间清理,这样使得每次都是对半区进行内存收集,不用考虑内存碎片的问题,只要一动指针,按顺序分配内存即可,实现简单,运行高效。

不足之处:内存缩小一半,代价太高。

现在商业 JVM 都采用这种 GC 算法来回收新生代,新生代中对象 98% 是朝生夕死,所以不需要 1:1 来划分内存空间,而是内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor ,当回收时,将 Eden 和 Survivor 中存活的对象一次性复制到另外一块 Survivor 上,最后清理掉 Eden 和刚用过的 Survivor 空间, HotSpot 默认 Eden 和 Survivor 的比例是8:1,所以只有10%的内存会被浪费,我们无法保证每次回收都只有不多于10%的对象存活,当 Survivor 空间不够时,需要依赖其他内存(老年代)进行分配担保

如果另外一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

这会导致当有大量临时大对象,例如一些序列化字节数组,会直接跳过 survivor ,到老年代中,导致平凡 full gc 。

2.3 标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,根据老年代的特点,有人提出了 Mark-Compact 算法,标记过程和标记清除一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一段移动,然后进行清理。

2.4 分代收集算法

当前商业 JVM 的 GC 都采用 Generational Collection 算法,只是根据对象存活周期的不同将内存划分为几块,一般是把 Java Heap 分为新生代和老年代,新生代中每次收集都发现大量死亡对象,那就使用复制算法;而老年代因为对象存活率高,就使用标记—清理或者标记—整理算法进行回收。

3. HotSpot 算法实现

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

另外,可达性分析对执行时间的敏感还体现在 GC 停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致 GC 进行时必须停顿所有 Java 执行线程( Sun 将这件事情称为“ Stop The World ”)

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

普通对象指针(Ordinary Object Pointer)

GC 时为什么要 stop the world?为了确保标记对象是否可达的准确性。即使是 cms 收集器如其名: concurrent mark sweep 并发标记清理也会短暂的 stop the world 。

  • 初始标记:标记能关联到的对象 很快 stw 。
  • 并发标记:标记对象是否可达 很耗时 但是不会 stw 。
  • 重新标记 修正标记结果 很短 stw 。
  • 并发清除:基于标记结果直接清理对象耗时不会 stw 。

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

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

JVM 什么时候开始 GC ?
程序执行时只有在到达安全点(safe point)时才能暂停下来开始GC。

那哪些地方出现安全点呢
遇到方法调用,循环调整,异常跳转等指令时会产生安全点。

如何在GC发生时让所有线程都“跑”到最近的安全点上再停顿下来
有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。最常用的是主动式中断,即GC需要中断线程时,设置一个标志位,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

当线程处于 Sleep 状态或者 Blocked 状态时,无法响应JVM的中断请求,此时需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

4. 垃圾收集器

垃圾收集器是内存回收的具体实现。下面讨论的收集器基于 Jdk1.7 update14 后的 HotSpot 虚拟机。上图的连线代表组合。需要尤其值得注意的是 CMS 和 G1 分类器。

虽然我们是在对各个收集器进行比较,但并非为了挑选出一个最好的收集器。

4.1 Serial 收集器

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。

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

虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉。

“你妈妈在给你打扫房间的时候,肯定也会让你老老实实地在椅子上或者房间外待着,如果她一边打扫,你一边乱扔纸屑,这房间还能打扫完?”这确实是一个合情合理的矛盾。

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

听起来 Serial 收集器已经到老而无用,食之无味弃之可惜的地步了,而它的优点就是简单高效,对于限定单个 CPU 的环境,该收集器没有其他线程交互的开销,就是专心垃圾回收。因此, Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择(默认新生代收集器)

4.2 ParNew 收集器

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

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

在 Jdk1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器—— CMS 收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发(Concurrent)收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,现在妈妈打扫我能同时扔纸屑了~

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

CMS 是追求单次停顿时间短,不 care 总停顿时间;

Parallel Scavenge 是追求高吞吐,也就是总停顿时间短;

在 full gc 的时候,这两者是冲突的。

该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。它默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多(譬如32个,现在 CPU 动辄就4核加超线程,服务器超过32个逻辑CPU的情况越来越多了)的环境下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

在这停顿,关于什么是并发并行

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

4.3 Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器……看上去和ParNew都一样。

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)

吞吐量 = 运行用户代码时间 /(运行用户代码时间 +垃圾收集时间)

我们通过下面两个指令控制吞吐量:

  • -XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间
  • -XX:GCTimeRatio 直接设置吞吐量大小,运行用户代码与垃圾回收时间的比例,比如设置19,则吞吐量是 19/20=95% 。

由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。

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

只需要把基本的内存数据设置好(如 -Xmx 设置最大堆),然后使用 MaxGCPauseMillis 参数(更关注最大停顿时间)或 GCTimeRatio (更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别

4.4 Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法

这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

如果在 Server 模式下,那么它主要还有两大用途:一种用途是在 Jdk1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配,另一种用途就是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

4.5 Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法 。这个收集器是在Jdk1.6 中才开始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。原因是,如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep) 收集器外别无选择。

由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合“给力”。

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

4.6 CMS 收集器

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

CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记(CMS initial mark) stop the world
  • 并发标记(CMS concurrent mark)时间长
  • 重新标记(CMS remark)stop the world
  • 并发清除(CMS concurrent sweep)时间长

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

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

  • 占用了一部分线程(或者说 CPU 资源),默认回收线程数是 “CPU 数 + 3 / 4” 而导致应用程序变慢,总吞吐量会降低;
  • CMS 收集器无法处理浮动垃圾( Floating Garbage 并发清理阶段产生的垃圾),可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生;

也是因为垃圾收集阶段用户线程还是运行的,也就需要预留内存空间给用户进程:

  • 在 Jdk1.5 默认下,老年代使用了 68% 就会激活 CMS 收集器,可以使用 -XX:CMSInitatingOccupancyFraction 值来提高触发百分比;
  • 在 Jdk1.6 下,CMS 收集器的启动阈值被提升到了 92% ,如果运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败;于是临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX:CMSInitiatingOccupancyFraction 设置得太高很容易导致大量 “Concurrent Mode Failure” 失败,性能反而降低。
  • CMS 是基于“标记—清除”算法,会产生大量空间碎片。往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次 Full GC 。

为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启的),用于在CMS收集器顶不住要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程是无法并发的。

空间碎片问题没有了,但停顿时间不得不变长。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction ,这个参数是用于设置执行多少次不压缩的 Full GC 后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

4.7 G1 收集器

Garbage-First 是当今收集器技术发展的前沿成果之一。从 Jdk 7u4 开始移除了 Experimental 的标识。它是一款面向服务端应用的垃圾收集器。目的是替换 CMS 收集器。特点如下:

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

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

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

在 G1 收集器中,Region 之间的对象引用包括其他收集器中新生代和老年代之间的对象引用,都是使用 Remembered Set 来避免全 Heap 扫描的。每个 Region 都有一个 Remembered Set 来对应,JVM 在对 Reference 类型的数据进行写操作的时候,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否在不同的 Region 中(比如新生代引用老年代)。

如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remember Set 之中,当进行内存回收的时候,在 GC 根节点的枚举范围中加入 Remember Set 即可保证不对全堆进行扫描也不会遗漏。

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

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

对 CMS 收集器运作过程熟悉的读者,一定已经发现 G1 的前几个步骤的运作过程和CMS有很多相似之处。

初始标记阶段仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。

并发标记阶段是从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。

最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。

最后在筛选回收阶段首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,从 Sun 公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region ,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1 与 CMS 相比,虽然它们都立足于低停顿时间,CMS 仍然是我现在的选择,但是随着 Oracle 对 G1 的持续改进,我相信 G1 会是最终的胜利者。如果你现在采用的收集器没有出现问题,那就没有任何理由现在去选择 G1 ,如果你的应用追求低停顿,那 G1 现在已经可以作为一个可尝试的选择,如果你的应用追求吞吐量,那 G1 并不会为你带来什么特别的好处。

4.8 阅读 GC 日志

最前面的数字是 GC 发生的时间,这个数字的含义是从 JVM 启动以来的时间。

然后是 GC 和 Full GC 说明了垃圾回收的类型,如果是 Full 字样的,代表发生了 Stop-The-World 。

上面的 ParNew 新生代收集器的日志有 Full 标识,这一般是因为分配担保失败之类的问题,所以导致了 STW ,如果是调用 System.gc() 方法那么显示的将是 Full GC (System) 。

接着说第一张图,后面的 [DefNew 、 [Tenured 、 [Perm 标识了 GC 发生的位置,这里显示的区域名称与使用的 GC 收集器密切相关,上面样例所使用的 Serial 收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是 ParNew 收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用 Parallel Scavenge 收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

3324K -> 152K(3712K) 含义是 GC 前该区域已使用容量 -> GC 后该内存区域已使用容量(该区域总容量),方括号之外的 3324K->152K(11904K) 表示 GC 前堆使用量 -> GC 后堆使用量(堆总容量)

再后面, 0.0025925 secs 表示该区域所占用的时间,单位是秒。有的收集器会给出具体时间,分别给出用户态和内核态时间。

4.9 常用参数总结

5. 内存分配和回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。现在我们再一起来探讨一下给对象分配内存的那点事儿。

5.1 对象优先分配在 Eden 分配

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

虚拟机提供了 -XX:+PrintGCDetails 这个收集日志参数,告诉虚拟机在发生来收集行为时打印内存回收日志。

下面是一个小例子:-Xms20M、-Xmx20M、-Xmn10M这3个参数限制了 Java 堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8 决定了新生代中 Eden 区与一个 Survivor 区的空间比例是8∶1,从输出的结果也可以清晰地看到 “eden space 8192K、from space 1024K、to space 1024K” 的信息,新生代总可用空间为9216KB(Eden 区 + 1个 Survivor 区的总容量)。

第一个参数 -Xms 指定 jvm 初始内存,第二个参数 -Xmx 指定 jvm 最大可用内存,第三个参数 -Xmn 指定新生代大小。
我们可以推算出 eden 8m、 survivor 1m (一个)

一个非常重要的点:关于什么是 Minor GC 和 Full 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倍以上。

比如我们执行如下代码:

1
2
3
4
5
6
7
8
9
10
private static final int _1MB = 10241024;
/**
* 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
}

解读一下,我们首先分配给 allocation123 各2m的空间,最后给 allocation4 剩下的4m空间,我们知道我们的新生区可用空间只有9m,所以现在我们会发生 Minor GC ,并且执行的是分配担保机制,提前转移到老年代中。

5.2 大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的 byte[] 数组就是典型的大对象)。

大对象,不仅仅在于占用空间大,还要是连续空间。

虚拟机提供一个 -XX:PretenureSizeThreshold 参数,令大于这个值的对象直接在老年代分配,这样避免了新生区的大量内存复制。

这个参数仅仅对 Serial 和 ParNew 收集器有效,Parallel Scavenge 收集器不认识这个参数。如果遇到必须使用此参数的场景,可以考虑使用 ParNew 和 CMS 的组合。

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

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

5.4 动态对象年龄判定

不是永远必须达到指定年龄才能晋升到老年代。 如果在 Survivor 空间中相同年龄所有对象大小的总和大于Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

有点拗口,也就是年龄为 N 的所有对象占用空间超过一半,则年龄大于等于 N 的对象将直接进入老年代。

什么对象进入老年代:

  1. 天生的老年代,即定一个需要连续的内存空间的大对象(数组);
  2. 上年纪的老人,即 jvm 设置的参数 MaxTenuringThreshold 的阈值,超过该阈值则进入老年代;
  3. 普遍低龄化,当survivor to 和survivor from中有某一个年龄占有该内存总量的一半或者更多,该年龄就作为进入老年代的年龄阈值。

5.5 空间分配担保

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

关于冒险的解释:

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

换句话说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败,如果出现,只好重新发起一个 Full GC 。

即使这样兜兜转转,我们还是将 HandlePromotionFailure 开关打开,避免 Full GC 次数过于频繁。

Jdk1.6 Update24 后,规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会发生 Minor GC,否则进行 Full GC 。