Java - Garbage First概貌

Serial GC是HotSpot的第一个垃圾回收器。Parallel和Concurrent Mark Sweep在2002年推出,是JDK 1.4.2的一部分。这三个回收器大概对应三个GC用例:最小化内存占用和并发开销、最大化应用吞吐量和最小化GC暂停时间。有人可能会问,为什么需要一个新的像G1这样的回收器?在回答问题之前,让我们澄清一下经常使用的术语并对比一下这些回收器。我们先简单回顾一下这四个HotSpot垃圾回收器,看G1和其他三个有什么不同。

术语

  • parallel:多线程垃圾回收。HotSpot的GC,差不多所有的多线程GC操作都是由JVM内部线程处理的。但是,G1的回收器的一些background GC工作可以由应用程序的线程处理
  • stop-the-world:所有的Java应用程序线程在一个GC事件(或者阶段)期间停止运行
  • concurrent:垃圾回收器活动的同时,Java应用还在继续执行

一个垃圾回收器可以由一个术语或者这三个术语的组合来描述。比如,一个parallel concurrent的回收器是多线程的(parallel部分),同时程序还在执行(concurrent部分)。

Parallel GC

Parallel GC是一个parallel stop-the-world回收器,当GC发生的时候,它停止所有的程序线程,使用多线程执行GC工作。这样,GC工作很有效率,不会被中断。这通常是最小化相对于应用程序工作时间的GC总时间的最好的办法。但是,由GC引起的程序的一些暂停可能会比较长。
Parallel GC的young代和old代回收都是parallel和stop-the-world的。Old代回收也执行压缩(compaction)工作。压缩就是移动对象,把他们紧密地排列,不浪费空间,这样可以优化堆空间。但是,压缩会占用大量的时间,时间和堆的大小以及old代的live对象数量相关。
HotSpot刚引入Parallel GC的时候,只有young代使用parallel stop-the-world回收器。Old代的回收还是单线程的stop-the-world。刚引入Parallel GC的时候,可以使用-XX:+UseParallelGC打开Parallel GC。
刚引入Parallel GC的时候,服务器需要吞吐量优化。当时,堆的大小一般在512MB-2GB之间,所以Parallel GC的暂停时间相对比较短,延迟要求没现在这么高。对于Web应用,可以忍耐超过一秒的暂停时间,甚至可以忍耐3-5秒。
随着堆尺寸变大,old代的存活对象越来越多,同时,硬件也在进步,可以使用更多的线程。于是,Parallel GC增加了多线程的old代回收。
Java 6以后,有了新的命令行选项-XX:+UseParallelOldGC。打开它的时候,也打开了parallel young代回收。
JDK 7u4以后,-XX:+UseParallelOldGC成了默认GC和Parallel GC的正常模式。Java 7u4以后,使用-XX:+UseParallelGC也就打开了-XX:+UseParallelOldGC。同样,使用-XX:+UseParallelOldGC也就打开了-XX:+UseParallelGC。
下来情况下,Parallel GC是一个好的选择:

  • 程序的吞吐量比延迟重要。比如批量处理程序就是一个很好的例子,因为它是非响应式的。而且,你希望它越快越好
  • 如果能满足最坏的延迟需求,Parallel GC会带来最好的吞吐量。最坏的延迟需求包括最坏的暂停时间和暂停发生的频率。比如,一个程序的延迟需求是:每两个小时最多有一次超过500毫秒的暂停时间,而且全部暂停时间不超过3秒
    一个交互式的程序,如果数据量小,当发生一次Parallel GC的full GC事件时,也能够满足最坏延迟的需求,那么就应该使用Parallel GC。
    因为full GC必须标记(mark)整个堆,和压缩old代空间,所以,堆空间变大,会导致暂停时间变长。
    下图显示了应用线程被停止,GC线程执行回收工作。本图内有八个parallel GC线程和八个应用线程。不过,大多数程序的应用线程数量会超过GC线程的数量。
    Parallel GC

Serial GC

Serial GC类似Parallel GC,但是它的工作使用单线程。单线程允许不太复杂的GC实现,也不需要多少外部运行时数据结构。它的内存占用是几个回收器中最低的。Serial GC更可能产生长时间暂停。
客户端VM和嵌入式一般使用Serial GC,可以使用-XX:+UseSerialGC打开。
Serial GC

Concurrent Mark Sweep (CMS) GC

为了相应低暂停时间的呼声,开发了CMS GC。因为,在有些场景下,牺牲一些吞吐量,大大减少暂停时间是可以接受的。
CMS GC里,young GC类似Parallel GC,有parallel stop-the-world。注意,你可以配置CMS GC,young代回收只使用单线程,不过,在Java 8被弃用,在Java 9被删除。
Parallel GC和CMS GC的主要不同是old代回收。对于CMS GC,old代回收尝试避免应用线程的长暂停。为此,CMS的old代回收的大部分工作是和应用线程并发的,只有一些比较少的短GC同步暂停(initial-mark和remark阶段)。CMS的初始实现,initial-mark和remark阶段都是单线程的,后来就都是多线程的了。使用-XX:+CMSParallelInitialMark和-XX:CMSParallelRemarkEnabled选项,可以打开多线程模式。当使用-XX:+UseConcurrentMarkSweepGC时,自动打开这俩选项。
young代回收和old代回收可能同时进行。此时,old代并发回收被young代回收中断,young代回收完成后立即恢复。CMS GC默认的young代回收一般被称为ParNew。
下图显示,在young GCs、CMS的initial-mark和remark阶段、old代GC stop-the-world阶段,应用线程被停止了。CMS GC的old代回收开始于stop-the-world initial-mark阶段。一旦初始标记完成,并发标记阶段开始,此时应用现场可以执行。一旦并发标记完成,CMS线程开始执行并发pre-cleaning。如果有足够的硬件线程,CMS线程不会对应用线程的执行造成太多的影响。但是,如果硬件线程利用率比较高,CMS线程和应用线程会竞争CPU资源。一旦并发pre-cleaning完成,开始stop-the-world remark阶段。该remark阶段标记前几个阶段时遗漏的对象。当remark完成,开始并发sweeping,释放死亡对象的空间。
CMS GC

CMS GC的一个挑战是如何调整它,这样并发工作可以在堆空间被耗尽前完成。因此,需要找到合适的时间来开始并发工作。一般来说,同样的程序,CMS会比Parallel GC多占用10-20%的堆空间。这是短暂停时间的代价之一。
CMS GC的另一个挑战是,如何处理old代的碎片。当old代的对象之间空间小,或者来自young代的对象没有有效的洞足以容纳它的时候,碎片就产生了。CMS并发回收不执行压缩。找不到合适的洞,会导致CMS蜕变到使用Serial GC的full GC,导致一个长暂停。还有,CMS产生的碎片是不可预测的。一些程序可能永远不会体验full GC,而另一些程序可能会经常体验。

Garbage First (G1) GC

G1采用稍微不同的办法,解决了Parallel、Serial和CMS的许多问题。G1把堆分成很多区,大多数GC操作可以在一个区内执行而不是在整个堆或者整个代内。
G1的young代是区的集合,这样就不需要是一个连续的内存块。类似地,old代也是区的集合。没有必要在JVM发布的时候确定哪个区是old或者young代。事实上,随着时间的推移,映射到G1区的虚拟内存在代之间变化。一个G1区可以被指定成young,后来,在young代回收以后,变成了未使用的区-既可以成为young代,也可以诚意old代。
在本文的剩余部分,术语有效区被用来标识未使用的和有效的区。G1的young回收是parallel stop-the-world,这样,当回收的时候会暂停所有的应用线程,而GC使用多线程执行。当一个young代回收发生时,整个young代都被回收。G1的old代回收和其他回收器不同。它不需要整个old待回收。而是在某一时刻,只有old代的一个子集在回收。而且,该子集的回收是和young回收同时进行的(mixed GC)。
和CMS类似,比如old代耗尽的时候,也有回收和压缩整个old代的安全失败(fail-safe)问题。
G1的old代回收,忽略了回收的安全失败问题,某些阶段是parallel stop-the-world,另一些是parallel concurrent。就是说,一些阶段是多线程的,而且停止所有应用线程,而另一些是多线程的,而且和应用线程一块运行。
当达到或者超过堆的占用阈值时,G1就开始执行一个parallel stop-the-world initial-mark阶段,发起一个old代回收。initial-mark阶段和下一个young GC同时执行。一旦initial-mark完成,一个并发多线程标记阶段开始标记old代的所有存活的对象。当并发标记阶段完成,一个parallel stop-the-world remark阶段开始,标记错过的对象。remark阶段结束,G1有这些old区的全部标记信息。如果old区内没有任何活的对象,就不用再做回收工作了。所以,G1能标识要回收的最佳的old代集(CSet)。
区是否包含在CSet内,基于能释放多少空间和G1的暂停时间目标。确定CSet以后,G1在下来的几次young代回收的时候回收CSet。
G1的每个被回收的区,不管它是young代还是old代,它的存活对象被移到一个有效的区。当活的对象都被移走,该区就是有效区了。
被移到新区的对象,他们的虚拟地址是紧挨着的。对象之间没有碎片。就这样,G1完成了old代的压缩。
由于G1基于区执行GC操作,特别适合大堆。
最大暂停时间发生在young和mixed回收的时候,所以G1允许设置GC暂停时间目标。G1尝试自适应调整堆的大小来满足指定的暂停时间目标。小的暂停时间目标,小的young代和大的总堆大小,会让old代相对比较大。

G1的设计

前面提到了,G1把堆分成区。区的大小和堆的大小相关,必须是2的幂,最小1MB,最大32MB。所有的区的大小相同,而且在JVM执行期间不会改变大小。区大小的计算基于初始和最大堆的平均值,区的平均数是2000左右。比如,16GB的堆(-Xmx16g -Xms16g),一个区的大小就是8MB。
如果初始和最大堆大小相差很远,或者如果堆非常打,可能有超过2000个区。如果堆很小,就不会有2000个区。
每个区有相关记忆集(包含指向区的指针的位置集合,简称RSet)。RSet的总量有限,但是,区的数量直接影响HotSpot的内存占用。RSet会占用1-20%的堆空间。
G1有几类区。有效区是未使用的区,Eden区构成了young代的eden space,survivor区构成了young代的survivor space。所有的eden和survivor区的集合就是young代。eden或者survivor区的数量可以改变。old代里主要是old区。humongous区也是old代的一部分,包含的一个对象占用了区的一半以上。JDK 8u40之前,humongous区作为old代的一部分被回收。JDK 8u40开始,某些humongous区被当作young代回收。
区可以被用做任何目的,意味着不需要把堆分成连续的young和old代段。G1的启发式算法会估算young代由多少区组成,并且尽量在GC暂停时间目标内回收。当程序开始分配对象,G1选择一个有效的区,把它指定为eden区,开始从它里面分内存块给Java线程。一旦这个区满了,另一个未使用的区被指定为eden区。这样的过程一直持续,直到到达eden区的最大值,开始young GC。在young GC期间,所有的young区,eden和survivor,都被回收。其中的所有的活的对象被移到一个新的survivor区或者一个old代区。当对象移动的目标区满的时候,有效区被标记为survivor或者old代。
在一个GC之后,如果old代空间到达或者超过了初始的堆占用阈值,G1初始化一个old代回收。可以使用-XX:InitiatingHeapOccupancyPercent控制占用阈值,默认值是45%。
如果在标记阶段发现old代区内没有活的对象,G1可以提前回收该区,该区被加到有效区集合。包含活的对象的old区会在未来的mixed GC时回收。G1使用多个并发标记线程。为了不占用太多CPU时间,标记线程以突发(bursts)方式工作-在给定的时间槽(slot)内执行尽可能多的工作,然后休息,允许Java线程执行。

Humongous Objects

G1特殊处理大对象分配,一个humongous对象就是大于等于50%区大小的对象(包括对象头,32位虚拟机和64位虚拟机的对象头大小不同)。可以使用JOL(Java Object Layout tool)获取给定的HotSpot VM的对象的头大小。
分配humongous对象的时候,G1定位一组连续的区域,其中第一个区叫“humongous start”区,其他区叫“humongous continues”区。如果没有足够的有效区,G1通过full GC压缩堆。
Humongous区是old代的一部分,但是只包含一个对象。如果在并发标记阶段发现该对象不是活的,包含该对象的所有的区就被提前回收了。G1的一个挑战是短命的humongous对象回收得不够及时。从JDK 8u40开始,有时候,humongous区可以在young回收时回收。
避免频繁地分配humongous对象可以提高程序性能。

Full Garbage Collections

G1的Full GC的算法和Serial GC的一样。当发生full GC的时候,会完全压缩整个堆。记住,G1的full GC是单线程执行的,会导致很长的暂停时间。

Concurrent Cycle

G1的并发周期包括几个阶段:initial marking、concurrent root region scanning、concurrent marking、remarking和cleanup。除了cleanup阶段,其他阶段统称“marking the live object graph”。

  • initial-mark阶段:用来收集所有的GC roots。根是对象图的起始点。要从应用线程手机根引用,线程必须停下来,这样initial-mark阶段是stop-the-world。G1的初始标记是young GC暂停的一部分,因为young GC要收集所有的根
  • concurrent root region scanning阶段:标记操作也必须扫描和跟踪survivor区的所有对象的引用。在此阶段,所有的Java线程是允许执行的,应用不会暂停。但是,在下一次GC开始前,扫描必须完成。这是因为,新的GC会生成新的survivor对象集合,和初始标记的survivor对象不同
  • concurrent marking阶段:大多数标记工作都发生在本阶段。多线程协作扫描活的对象图。所有的Java线程也是允许执行的,没有暂停,但是程序的吞吐量会降低。
  • remark phase阶段:完成所有的标记工作,会stop-the-world,一般来说,暂停时间比较短
  • cleanup阶段:在本阶段,任何不包含活动对象的区被回收,被加到有效区列表。这些区不包含young或者mixed GC

标记阶段必须完成,以找出全部活的对象,来决定哪些区被放到mixed GCs。G1里,mixed GCs是释放内存的首要机制。如果在有效区用完之前,未完成标记阶段,G1就回到full GC释放内存。

Heap Sizing

G1的堆大小总是区大小的倍数。和其他GC一样,G1在-Xms和-Xmx之间动态伸缩堆大小。
碰到下列情况,堆空间可能增长:

  • full GC期间,经过计算,可能增长堆空间
  • 当young或者mixed GC发生的时候,G1计算GC执行的时间和应用执行的时间。如果和-XX:GCTimeRatio相比,GC花了太多的时间,就增加堆空间(减少GC频率)。-XX:GCTimeRatio的默认值是9,其他的HotSpot回收器的默认值是99。GCTimeRatio的值越大,堆空间增长起来就越激进。
  • 如果一个对象(包括humongous对象)分配失败,G1会尝试增加堆空间来满足对象的分配,而不是立即回到full GC
  • 当GC请求一个新区来移动对象,G1会尝试增加堆空间来满足对象的分配,而不是立即回到full GC
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值