Java G1 GC 垃圾回收深刻浅出

1. G1概览 算法

G1 GC 全称是Garbage First Garbage Collector,垃圾优先垃圾回收器,如下简称G1。G1是HotSpot JVM的短停顿垃圾回收器。其实关于G1的论文早在2004年就有了,可是G1是在2012年4月发布的JDK 7u4中才实现。从长期来讲,G1旨在取代CMS(Concurrent Mark Sweep)垃圾回收器。G1从JDK9开始已经做为默认的垃圾回收器。若是对于应用程序来讲停顿时间比吞吐量更重要,G1是很是合适的选择。服务器

整体来讲G1具备以下特色:数据结构

  • G1仍旧是分代(年轻代,老年代)的垃圾回收器
  • G1实现了两种垃圾回收算法。
  • 年轻代垃圾回收具备Stop-The-World,并行,和经过对象复制实现压缩的特色
  • 老年代垃圾回收具备并发标记,逐步压缩的特色,而且老年代回收前须要先进行一次年轻代的回收。 

 

2. G1垃圾回收过程多线程

2.1.  G1垃圾回收过程概述 并发

G1垃圾回收过程主要包括三个:性能

  • 年轻代回收(young gc)过程
  • 老年代并发标记(concurrent marking)过程
  • 混合回收过程(mixed gc)。

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;当堆内存使用达到必定值(默认45%)时,开始老年代并发标记过程;标记完成立刻开始混合回收过程。spa

举个例子:我曾经工做的一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。线程

下面将会详细介绍这个三个过程。指针

 

2.2.  G1的内存结构 日志

理解垃圾回收机制,必须先了解G1的内存结构,内存结构以下图:

 

尽管G1堆内存仍然是分代的,可是同一个代的内存再也不采用连续的内存结构。这个是如何实现的呢?

这里有三个关于内存的概念:代,区和内存分段。

G1把堆内存分为年轻代和老年代。年轻代分为Eden和Survivor两个区,老年代分为Old和Humongous两个区。代和区都是逻辑概念。

G1把堆内存分为大小相等的内存分段,默认状况下会把内存分为2048个内存分段,能够用-XX:G1HeapRegionSize调整内存分段的个数。好比32G堆内存,2048个内存分段每段的大小为16M。这至关于把内存化整为零。内存分段是物理概念,表明实际的物理内存空间。

每一个内存分段均可以被标记为Eden区,Survivor区,Old区,或者Humongous区。这样属于不一样代,不一样区的内存分段就能够没必要是连续内存空间了。

新分配的对象会被分配到Eden区的内存分段上,每一次年轻代的回收过程都会把Eden区存活的对象复制到Survivor区的内存分段上,把Survivor区继续存活的对象年龄加1,若是Survivor区的存活对象年龄达到某个阈值(好比15,能够设置),Survivor区的对象会被复制到Old区。复制过程是把源内存分段中全部存活的对象复制到空的目标内存分段上,复制完成后,源内存分段没有了存活对象,变成了可使用的空的Eden内存分段了;而目标内存分段的对象都是连续存储的,没有碎片,因此复制过程能够达到内存整理的效果,减小碎片。Humongous区用于保存大对象,若是一个对象占用的空间超过内存分段的一半(好比上面的8M),则此对象将会被分配在Humongous区。若是对象的大小超过一个甚至几个分段的大小,则对象会分配在物理连续的多个Humongous分段上。Humongous对象由于占用内存较大而且连续会被优先回收。

 

2.3.  Remembered Set

理解回收过程,须要先了解记忆集合(Remembered Set),如下简称RS。为了在回收单个内存分段的时候没必要对整个堆内存的对象进行扫描(单个内存分段中的对象可能被其余内存分段中的对象引用)引入了RS数据结构。RS使得G1能够在年轻代回收的时候没必要去扫描老年代的对象,从而提升了性能。每个内存分段都对应一个RS,RS保存了来自其余分段内的对象对于此分段的引用。对于属于年轻代的内存分段(Eden和Survivor区的内存分段)来讲,RS只保存来自老年代的对象的引用。这是由于年轻代回收是针对所有年轻代的对象的,反正全部年轻代内部的对象引用关系都会被扫描,因此RS不须要保存来自年轻代内部的引用。对于属于老年代分段的RS来讲,也只会保存来自老年代的引用,这是由于老年代的回收以前会先进行年轻代的回收,年轻代回收后Eden区变空了,G1会在老年代回收过程当中扫描Survivor区到老年代的引用。

RS里的引用信息是怎么样填充和维护的呢?简而言之就是JVM会对应用程序的每个引用赋值语句object.field=object进行记录和处理,把引用关系更新到RS中。可是这个RS的更新并非实时的。G1维护了一个Dirty Card Queue。对于应用程序的引用赋值语句object.field=object,JVM会在以前和以后执行特殊的操做以在dirty card queue中入队一个保存了对象引用信息的card。在年轻代回收的时候,G1会对Dirty Card Queue中全部的card进行处理,以更新RS,保证RS实时准确的反映引用关系。那为何不在引用赋值语句处直接更新RS呢?这是为了性能的须要,RS的处理须要线程同步,开销会很大,使用队列性能会好不少。

 

2.4.  年轻代回收过程(Young GC

JVM启动时,G1先准备好Eden区,程序在运行过程当中不断建立对象到Eden区,当全部的Eden区都满了,G1会启动一次年轻代垃圾回收过程。年轻代只会回收Eden区和Survivor区。首先G1中止应用程序的执行(Stop-The-World),G1建立回收集(Collection Set),回收集是指须要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区全部的内存分段。而后开始以下回收过程:

第一阶段,扫描根。

根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RS记录的外部引用做为扫描存活对象的入口。

第二阶段,更新RS。

处理dirty card queue中的card,更新RS。此阶段完成后,RS能够准确的反映老年代对所在的内存分段中对象的引用。

第三阶段,处理RS。

识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

第四阶段,复制对象。

此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象若是年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。

第五阶段,处理引用。

处理Soft,Weak,Phantom,Final,JNI Weak 等引用。

 

2.5.  G1老年代并发标记过程(Concurrent Marking

当整个堆内存(包括老年代和新生代)被占满必定大小的时候(默认是45%,能够经过-XX:InitiatingHeapOccupancyPercent进行设置),老年代回收过程会被启动。具体检测堆内存使用状况的时机是年轻代回收以后或者houmongous对象分配以后。老年代回收包含标记老年代内的对象是否存活的过程,标记过程是和应用程序并发运行的(不须要Stop-The-World)。应用程序会改变指针的指向,并发执行的标记过程怎么能保证标记过程没有问题呢?并发标记过程有一种情形会对存活的对象标记不到。假设有对象A,B和C,一开始的时候B.c=C,A.c=null。当A的对象树先被扫描标记,接下来开始扫描B对象树,此时标记线程被应用程序线程抢占后停下来,应用程序把A.c=C,B.c=null。当标记线程恢复执行的时候C对象已经标记不到了,这时候C对象实际是存活的,这种情形被称做对象丢失。G1解决的方法是在对象引用被设置为空的语句(好比B.c=null)时,把原先指向的对象(C对象)保存到一个队列,表明它多是存活的。而后会有一个从新标记(Remark)过程处理这些对象,从新标记过程是Stop-The-World的,因此能够保证标记的正确性。上述这种标记方法被称为开始时快照技术(SATB,Snapshot At The Begging)。这种方式会形成某些是垃圾的对象也被当作是存活的,因此G1会使得占用的内存被实际须要的内存大。

具体标记过程以下:

  1. 先进行一次年轻代回收过程,这个过程是Stop-The-World的。

     老年代的回收基于年轻代的回收(好比须要年轻代回收过程对于根对象的收集,初始的存活对象的标记)。

  2. 恢复应用程序线程的执行。

  3. 开始老年代对象的标记过程。

   此过程是与应用程序线程并发执行的。标记过程会记录弱引用状况,还会计算出每一个分段的对象存活数据(好比分段内存活对象所占的百分比)。

  4. Stop-The-World。

  5. 从新标记(Remark)。

   此阶段从新标记前面提到的STAB队列中的对象(例子中的C对象),还会处理弱引用。

  6. 回收百分之百为垃圾的内存分段。

   注意:不是百分之百为垃圾的内存分段并不会被处理,这些内存分段中的垃圾是在混合回收过程(Mixed GC)中被回收的。

   因为Humongous对象会独占整个内存分段,若是Humongous对象变为垃圾,则内存分段百分百为垃圾,因此会在第一时间被回收掉。

  7. 恢复应用程序线程的执行。

 

2.6.  混合回收过程(Mixed GC

并发标记过程结束之后,紧跟着就会开始混合回收过程。混合回收的意思是年轻代和老年代会同时被回收。并发标记结束之后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认状况下,这些老年代的内存分段会分8次(能够经过-XX:G1MixedGCCountTarget设置)被回收。混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法彻底同样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。 

因为老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。而且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。若是垃圾占比过低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不必定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是容许整个堆内存中有10%的空间被浪费,意味着若是发现能够回收的垃圾占堆内存的比例低于10%,则再也不进行混合回收。由于GC会花费不少的时间可是回收到的内存却不多。

 

2.7.  Full GC

Full GC是指上述方式不能正常工做,G1会中止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会很是差,应用程序停顿时间会很长。要避免Full GC的发生,一旦发生须要进行调整。何时回发生Full GC呢?好比堆内存过小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到full gc,这种状况能够经过增大内存解决。

 

3. 其余概念 

3.1.  线程本地分配缓冲区(TLAB: Thread Local Allocation Buffer

因为堆内存是应用程序共享的,应用程序的多个线程在分配内存的时候须要加锁以进行同步。为了不加锁,提升性能每个应用程序的线程会被分配一个TLAB。TLAB中的内存来自于G1年轻代中的内存分段。当对象不是Humongous对象,TLAB也能装的下的时候,对象会被优先分配于建立此对象的线程的TLAB中。这样分配会很快,由于TLAB隶属于线程,因此不须要加锁。

 

3.2.  GC“提高”线程本地分配缓冲区(PLAB: Promotion Thread Local Allocation Buffer

前面提到过,G1会在年轻代回收过程当中把Eden区中的对象复制(“提高”)到Survivor区中,Survivor区中的对象复制到Old区中。G1的回收过程是多线程执行的,为了不多个线程往同一个内存分段进行复制,那么复制的过程也须要加锁。为了不加锁,G1的每一个线程都关联了一个PLAB,这样就不须要进行加锁了。

 

3.3.  Remembered Set 粒度

其实RS的存储分三种粒度,前面提到的Card是最小的一种粒度。粒度的存在是由于某些内存分段中的对象可能很热门,被来自很是多的区的对象所引用,为了不保存太多的数据,会以更大的粒度来保存这些引用,好比最大的粒度是用一个bitmap来保存其余内存分段对RS所对应的内存分段的引用。每个内存分段对应一个bit,若是bit为0表示该bit对应的内存分段中没有引用,为1表示有引用。这种方式会减小RS的数据,可是会增长扫描和标记时的开销,由于须要扫描全部bit为1的内存分段中的对象以肯定具体是来自哪一个对象的引用。 

 

后续文章会分析G1 GC的日志,介绍常见的G1 GC性能问题和经常使用的G1 GC参数调优。

 

 

做者公众号(码年)扫码关注:

 

 

 

参考文献:

G1 Garbage Collector Details and Tuning (Simone Bordet)

Java Performance Companion (Charlie Hunt, Monica Beckwith, Poonam Parhar, Bengt Rutisson 

相关文章
相关标签/搜索