jvm

Java基础: GC(二) G1垃圾收集器

Posted by ZhouJ000 on May 4, 2019

Java基础: GC(一) 介绍
Java基础: GC(二) G1垃圾收集器
Java基础: GC(三) G1垃圾收集器优化
Java基础: GC(四) SA调试工具

G1垃圾收集器

G1是最新加到Hotspot虚拟机中的垃圾收集器,是一种压缩型收集器,其基本原则就是首先收集尽可能多的垃圾,因此命名为”Garbage First” GC。G1有增量并行的stop-the-world方式的暂停,它是通过拷贝的方式来实现压缩,同时还有并行的多级并发标记,这有助于将标记、重新标记,以及清除导致的暂停减少到最小程度。在G1中将传统各个代必须相邻的堆布局方式改为由多个不相邻分区组合而成的方式,因此对于一个有效的Java堆,既可以是eden,也可以是survivor,老年代的组成部分,或成为一个巨型分区,甚至是空闲分区

G1 GC在收集暂停过程中回收绝大部分堆分区。唯一例外是多级并发标记期间的清除阶段,在清除阶段中,如果GC G1遇到仅仅只存放了垃圾的分区,它就会立即回收这些分区并将其放回空间分区列表中,因此这些分区的释放工作不用等到下一次垃圾收集暂停时再做了

G1的垃圾收集周期主要有3种类型:年轻代收集周期、多级并发标记周期、混合收集周期;除此之外还有一种单线程的回退暂停full GC,作为GC在垃圾收集遇到转移失败的情况下采取的安全机制(转移失败通常指担保失败、目标空间耗尽、溢出,这种失败通常发生在没有足够的多余空间来晋升对象时,遇到这种情况JVM的GC会先尝试扩展堆的尺寸,其次尝试占用那些对象已经被成功拷贝的分区并更新它们的引用)

年轻代

在一次年轻代的收集中,G1 GC会暂停应用线程,并将存活对象从年轻代eden分区移到survivor分区,或直接晋升到老年代分区,也有可能同时发生这2种情况。对于混合GC,G1 GC同时会将存活对象从大部分“高效率的”(指回收的空间与收集分区的估算开销相比较的出来的比率,即相对于收集一个分区开销所获取的收益,仅仅依赖活跃度计数和收集分区的开销)老年代分区集中到一个或多个空闲分区里,然后这些分区就成为老年代的一部分。在一个收集周期的最后,收集集合CSet里的分区将会释放并归还到空闲分区列表中

大部分特定线程的分配结果都会落到线程的TLAB中,因为独享的Java线程可以用无锁的方式进行空间分配,所以TLAB的分配完成速度也会更快一点。TLAB使用的是G1年轻代中的一部分分区,除非通过命令行指定,一般会根据年轻代初始化尺寸、最大尺寸和暂停时间目标来计算当前空间的大小。默认情况下,年轻代的初始化空间是整个Java堆尺寸的5%(-XX:G1NewSizePercent),年轻代的最大空间是整个Java堆尺寸的60%(-XX:G1MaxNewSizePercent),暂停时间目标为200ms(-XX:MaxGCPauseMillis)

年轻代由2部分组成:eden分区和survivor分区。当JVM从eden分区分配失败,意味着eden分区已经被完全占满,这样就触发一次年轻代收集了。年轻代收集首先把存活的对象从eden分区转移到survivor分区,即拷贝到survivor,从此开始,任何一次年轻代收集都将把存活对象从整个年轻代(无论eden还是survivor分区)晋升到新的survivor分区中,年轻代收集偶尔也会将一些存活对象晋升到老年代分区中,这些对象达到了预先设定的晋升阈值,也被称为”老化”对象(tenuring),对象晋升到survivor分区或老年代分区的过程是在GC线程的晋升本地分配缓存(promotion local allocation buffer,PLAB)中进行,无论是survivor分区还是老年代分区,每个GC线程都有一个PLAB

年轻代收集暂停过程中,G1 GC根据本次收集花费的时间总和来计算以下几个内容:
1、当前年轻代空间需要扩容或缩小的大小
2、已记忆集合(RSet)的空间尺寸
3、当前/最大/最小年轻代容量
4、暂停时间目标
于是在垃圾收集的最后,年轻代空间就会做出相应的调整,通过命令-XX:+PrintGCDetails可以查看输出结果

老年代

在每次年轻代收集过程中,G1 GC维护了每个对象的年龄字段,那些存活对象经历过的年轻代收集次数,称为对象的年龄。G1 GC将那些晋升对象的尺寸总和和它们的年龄信息一起维护到年龄表中。结合年龄表、survivor尺寸、survivor填充容量(-XX:TargetSurvivorRatio,默认50)、可晋升次数(+XX:MaxTenuringThreshold,默认15),JVM将给所有存活对象设置一个恰当的任期阈值,一旦对象超过这个阈值,它们就被晋升到老年代。当这些对象在老年代中死亡,它们的空间或被一次混合回收释放掉,或整个空间都被回收释放,或者作为最后手段的full GC中被释放

对于G1 GC来说,收集是以一个分区为单位的,因此堆分区尺寸(-XX:G1HeapRegionSize)是非常重要的参数,因为它决定了什么尺寸的对象可以放进一个分区,也决定了哪些对象能被称为巨型对象。巨型对象至少占用一个分区的50%甚至更多空间,因此不会使用通常的分配方式,它在老年代的巨型分区里直接分配。由于巨型对象一定是要连续的,而且移动这些对象没有任何意义,而巨型对象的拷贝开销又非常大,所以为了避免在年轻代垃圾收集过程中拷贝这些巨型对象所带来的损失,在老年代中直接分配巨型对象会更好。巨型对象确定其开始位置的成本非常大,而且可能不会享受到任何分配路径的优化收益;另外在收集巨型分区时,在任何巨型分区被完全释放后,它只能在并发收集周期的清除阶段被回收,因此在JDK 8u40时做了一个修改,一旦没有任何外部引用,这些巨型对象可以在年轻代收集中被回收,并释放空间到空闲分区列表;另外肯定的在一次full GC中也可以回收完全释放的巨型分区

混合收集

随着越来越多的对象晋升到老年代,或巨型对象分配到老年代,老年代和Java堆的空间占用也越来越多,为了避免耗尽堆资源,JVM需要启动一个混合的垃圾收集,它在覆盖年轻代分区的同时还覆盖了一部分老年代分区

为了表示出垃圾最多的老年代分区,G1 GC发起了一个并发标记周期,这个过程中GC将对根进行标记,识别出所有的存活对象,同时计算每个分区的活跃度。这就需要在对象分配、晋升、标记周期触发的比率上取得一个微妙的平衡,是的JVM进程不会耗尽Java堆的空间,因此JVM启动时会设置一个占用阈值(-XX:InitiatingHeapOccupancyPercent,IHOP,默认是45,是相对于整个堆尺寸而言的)。当老年代占用比例达到甚至超过IHOP阈值,并发标记周期被触发,在标记结束的时候,G1 GC会计算每个老年代分区的存活对象个数,同样在清除阶段G1 GC会根据老年代分区的”GC效率”定出它们的等级。这时混合垃圾收集就可以开始了,在一次混合收集中,G1 GC不光收集整个年轻代的分区,同时会收集一部分老年代分区,这样那些垃圾最多的老年代分区就被回收掉了

G1的多级并发周期比CMS的多级并发周期少几个阶段

单次的混合收集和年轻代收集是类似的,同时用拷贝的方式对存活对象进行压缩,唯一的区别是在混合收集中包含一部分高效率老年代分区。通过设置可以有不止一个混合收集,这被称为混合收集周期,其只能在达到IHOP并完成并发标记周期之后才能启动

-XX:G1MixedGCCountTarget,是混合GC数量的目标选项,默认为8,它的意义是给标记周期结束之后所能启动混合收集的数目设置一个物理限制,G1 GC根据混合GC数量目标值,对可被收集的候选老年代分区总数进行平均拆分,并将结果设置为每次混合收集所要回收老年代分区的最小数量,即每次混合收集的老年代CSet最小数量 = 混合收集周期将回收的候选老年代分区总数 / G1MixedGCCountTarget

-XX:G1HeapWastePercent,对于控制在一次混合收集周期中回收的老年代分区数有重要作用,默认为5。对于每次混合收集暂停,G1 GC根据那些能被回收的死亡对象的空间计算出可被回收的堆空间大小,一旦G1 GC达到堆废物阈值百分比,G1 GC就不会再启动新的混合收集,同时混合收集周期也将结束。设置堆废物百分比本质上是你愿意浪费一定数量的堆空间,通过它们可以有效提升混合收集周期的频率

收集集合CSet

在任一垃圾收集暂停中,CSet里所有的分区都会被释放,CSet就是一系列分区的集合,也是在垃圾收集暂停过程中被回收的目标。这些候选分区里的所有存活对象,在收集过程中会被转移,然后分区被释放回空闲分区队列中。对于年轻代收集,CSet只能容纳待回收的年轻代分区;另一方面,混合收集不光把所有年轻代分区添加到它的CSet中,同时会添加一些老年代候选分区(基于它们的GC效率)

-XX:G1MixedGCLiveThresholdPercent,默认为一个G1 GC分区的85%,该参数是一个活跃的阈值,也是一个限制,可以将老年代中大多数开销巨大的分区排除在混合收集的CSet之外,这是基于G1 GC设置的一个限制,任何低于这个活跃度阈值的老年代分区都会包含在混合收集的CSet中

-XX:G1OldCSetRegionThresholdPercent,默认为Java堆总大小的10%,该参数设置了每个混合收集暂停所能收集的老年代分区数量的上限,这个阈值依赖于JVM进程的可用Java堆的总大小,同时也被描述为Java堆总尺寸的百分比

已记忆集合RSet

分代垃圾收集器根据对象的年龄将它们分到堆中的不同区域,这些堆中的不同区域也就被称为不同的”代”,分代收集器随后就可以将它大部分的收集工作主要集中在最近被分配的对象上,因为它希望这些对象在死亡后能被尽快找出来。这些堆里的各种代可以被独立收集,独立收集有助于降低响应时间,因为无需再扫描整个内存堆,同时老年代中那些长命的对象也不必被前后拷贝,因此也降低了拷贝和引用更新的开销

为了更好地利用独立收集,许多垃圾收集器为它们各个代维护了已记忆集合(RSet),RSet是一个数据结构,它维护并跟踪外部对收集(在G1中就是单个分区)所拥有单元的引用,于是就没必要通过扫描整个堆来获取此类信息。当G1 GC执行一个stop-the-world式的收集(年轻代或混合收集)时,它会扫描包含在CSet中的分区的RSet,一旦分区内的存活对象被移动,则这些对象的引用也将被更新

1、对于G1 GC来说,在单独年轻代收集或混合收集过程中,年轻代通常是整体回收,这样就无需再跟踪那些指向的对象还存活在年轻代中的引用
2、老年代对年轻代的引用,G1 GC维护了从老年代分区指向年轻代分区的指针。这个年轻代分区被描述为”拥有”RSet,因此这个分区可以被称为RSet拥有分区
3、老年代对老年代的引用,这里老年代中不同分区的指针将被维护在老年代拥有分区的RSetRSet 如上图所示,每个分区只有一个RSet,对于某些应用来说,有可能某个特定的分区(包括其RSet)非常”受欢迎”,于是在这同一个分区甚至同一个位置发生很多更新,这种情况在Java应用中并不少见

G1 GC应对这种”受欢迎”的请求的方法是改变RSet的密度。RSet的密度会分为3个级别:稀少、细粒度、粗粒度。相对于其他不同的分区,”受欢迎”的分区RSet在容纳指针时可能会采用粗粒度的方式,这将影响到这些分区的RSet扫描时间。这3种粒度水平都有一个PRT(per-region-table),提供给所有特殊RSet的抽象容器

G1 GC的分区内部会拆分成多个块,对G1 GC分区来说,堆内存块的最小可用粒度是512字节,也称为一个”卡片”,全局卡片表维护着所有的卡片

当一个指针产生对RSet的拥有分区的引用,包含这个指针的卡片会被记录在PRT中。一个稀少PRT本质上是这些卡片索引的哈希表。这种简单的实现方式使得垃圾收集器有更快的扫描时间

另一方面,细粒度PRT和粗粒度位图采用不同的处理方式。对于细粒度PRT,它的开放哈希表中的每一个记录相当于一个分区(有一个对内拥有分区的引用),这个分区中卡片的索引存储在一个位图里。细粒度PRT有最大值的限制,一旦达到这个限制,粗粒度位图中的一个比特位(称为粗粒度位)就会被设置,一旦粗粒度位被设置,细粒度PRT中的对应记录就会被删除粗粒度位图是一个非常简单的位图,在它里面每个分区对应一个比特位,这样一系列的比特位就意味着对应的分区将包含一个指向拥有分区的引用。于是与设置的比特位相关联的分区就必须通过整体的扫描来找出引用,因此一个粗粒度方式的已记忆集合在垃圾收集器里是扫描最慢的

在任意收集周期中,当扫描已记忆集合RSet与PRT中的卡片时,G1 GC会把相应的记录标记在全局卡片表中,这样可以避免重复扫描卡片。在收集周期的最后这个卡片表会被清空,这在GC的输出中会显示为Clear CT,它会排在GC线程完成并行工作(即外部根扫描、更新和扫描已记忆集合、对象拷贝、终止协议)的输出结果后面。还有一些其他一些序列化的活动,诸如选择和释放CSet、引用处理和排队

并发优化线程与栅栏

随着RSet结构应用而来的是其自身的维护成本,这个成本来自2个方面:写栅栏和并发优化线程

栅栏是一些原生代码的片段,当运行时的某些语句被执行到时,栅栏也将被执行。栅栏在垃圾收集算法中的运用已被广泛认可,于是就拉长了原生指令路径长度,这也是执行栅栏带吧所带来的成本

OpenJDK Hotspot的并行老年代收集器和CMS收集器会使用写栅栏,它会在JVM执行一个对象引用写操作时执行,比如object.field = some_other_object;,这个栅栏将更新一个card-table-type结构来跟踪代间的引用。在minor垃圾收集中会扫描这个卡片表。写栅栏的算法基于快速写栅栏,它将栅栏的开销缩减到只需在编译的代码中增加2个额外指令

G1垃圾收集器使用了一个写前(pre-write)栅栏和一个写后(post-write)栅栏。前者是在实际应用赋值发生之前被执行,后者在赋值之后被执行。一旦一个引用被更新,G1垃圾收集器就会执行一个写栅栏,和上面的例子一样。写栅栏指令序列的开销非常昂贵,同时应用的吞吐量也会根据栅栏代码的复杂度而相应降低。跨分区的引用更新需要被捕获在拥有分区的RSet中,如果应用的更新是跨分区的更新,G1 GC会把它们找出来,这样栅栏工作执行的数量也会最小。G1 GC包含一个过滤技术,它使用一个简单的判断,当引用更新发生在同一个分区时,它就评估为零。只要发生一个跨分区更新,G1 GC会将相应的卡片加入到一个缓冲区序列,这块缓冲区被称为”更新日志缓冲区”或”脏卡片队列”

并发优化线程只专注于一个目标,就是通过扫描日志缓冲区中的记录的卡片来维护已记忆集合,并为那些分区更新已记忆集合。一旦更新日志缓冲区达到了它的容量上限,那它就退休了,同时会分配一个新的日志缓冲区。卡片排队就会在新的缓冲区中进行。退休的缓冲区被放置到一个全局列表中。一旦优化线程发现这个全局表中有记录存在,它们就开始并发地处理这些退休的缓冲区

优化线程永远是活跃的,哪怕最初它们只有一小部分会起作用。G1 GC用分层的方式处理并发优化线程的调度,它会增加更多线程来跟上被填满的日志缓冲区的数量。可以通过以下几个选项设置激活阈值:-XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone、-XX:G1ConcRefinementRedZone。如果并发优化线程不能跟上缓冲区的数量,mutator(修改器)线程就会被加进来提供帮助。在那时候,mutator线程会停掉它们的工作来帮助并发优化线程处理完填满的日志缓冲区。mutator线程在垃圾收集器的术语中被称为Java应用线程,因此当并发优化线程不能跟上缓冲区的数量时,Java应用会被挂起,直到日志缓冲区被处理完,所以必须采取措施避免此类场景

并发标记

根据前面介绍的G1垃圾收集器的分区以及每个分区的活跃度计数,可以知道需要一个增量式完全并发标记算法。Taiichi Yuasa为增量式标记清除垃圾收集器开发了一个算法,在里面使用了STAB的标记算法。Yuasa的STAB标记优化主要针对标记 —— 清除垃圾收集器的并发标记阶段。STAB标记算法非常适用于G1垃圾收集器的分区块的堆结构,同时还解决了CMS垃圾收集器算法的主要烦恼 —— 重新标记暂停时间很长时间的潜在风险

G1垃圾收集器设置了一个标记阈值,描述总体Java堆大小的百分比,默认为45%,可以通过-XX:InitiatingHeapOccupancyPercent进行设置,一旦达到这个阈值就会触发并发标记周期。标记任务被拆分到各个块中,只要mutator线程是活跃的,那么大部分工作就可以被并发执行,其目的就是在Java堆达到满负荷之前就完成对整个堆的标记

STAB算法创建了一个对象图,它是堆的一个逻辑快照,STAB标记确保并发标记阶段开始时所有垃圾对象都能通过快照被鉴别出来。我们将并发标记阶段分配的对象认为是存活对象,但它们没有被跟踪,因此也降低了标记的开销。这个技巧确保所有在并发标记阶段开始时存活着的对象都会被标记和跟踪,同时在标记周期内所有通过并发mutator线程新分配的对象都会被标记为存活对象,所有也不会被回收

标记数据结构仅仅包含2个位图:previous和next。previous位图保存了最近一次完成的标记信息。并发标记周期会创建并更新next位图。随着时间的推移,previous标记信息会越来越过时,最终在标记周期结束的时候,next位图就会把previous位图覆盖掉。和previous位图、next位图一样,每个G1垃圾收集器分区都有2个top-at-mark-start(TAMS,标记开始顶部)字段,分别被称为previous TAMS(PTAMS)和next ATMS(NTAMS),ATMS字段非常有助于确定那些在标记周期内分配的对象 tams

并发标记阶段

绝大部分的标记任务块是并发进行的,少量任务会在STW的暂停过程中被完成

初始标记

初始标记阶段,因为要把Java堆中所有能被根直接可达的对象(也称为根对象)都标记出来,所以mutator线程(即Java应用线程)都会被暂停

根对象是指那些能从Java堆外部访问到的对象,比如像原生栈对象,以及JNI(Java Native Interface)本地或全局对象

因为mutator线程都会被暂停,所以初始标记阶段是一个stop-the-world式的阶段,另外因为年轻代收集为stop-the-world方式的同时会跟踪根,那么不管从便利性还是时效性的角度来说,在年轻代收集的同时进行初始标记是非常合适的,这也被称为”借道”(piggybacking)。在初始标记暂停过程中,每个分区的NTAMS值都被设置到分区的顶部,这个过程会一直重复,直到堆中的所有分区都被处理完

根分区扫描

在每个分区设置完TAMS之后,应用线程就会被重新启动起来,然后G1垃圾收集器就与应用线程同时并发的工作。为了确保标记算法的正确性,所有在初始标记和年轻代收集中被拷贝到survivor分区的对象,都需要被扫描并被看做是标记根。因此G1垃圾收集器开始扫描survivor分区,任何被survivor分区所引用的对象都将被标记。也正因为如此,这种方式下被扫描的survivor分区也被称为“根分区”

并发标记

并发标记阶段是并发方式且多线程的,可以使用-XX:ConcGCThreads来设置并发线程数,默认情况下G1垃圾收集器会将这个线程总数设为并行垃圾收集线程数(-XX:ParallelGCThreads)的四分之一,JVM会在虚拟机启动时把并行垃圾收集器线程数计算出来。并发线程在一个时刻只扫描一个分区,同时通过”手指”指针优化了获取分区的方式。”手指”指针的优化与CMS垃圾收集器中优化方式非常类似

G1垃圾收集器会用一个写栅栏来执行SATB并发标记算法所要求的动作,如果一个应用改变了它的对象图,那么在标记开始时可达的对象和快照中的一部分对象,可能会被覆盖掉,直到一个标记线程发现它们并跟踪它们。因此SATB标记算法要求变更应用线程将指针变更之前的值记录在一个SATB日志队列或缓冲区中。这也被称为”并发标记/SATB写前栅栏”,因为栅栏代码在更新前执行。写前栅栏记录了对象引用字段变更前的值,于是并发标记就可以找出那些被覆盖的对象

比如进行x.f=y的赋值操作,采用的写前栅栏伪代码为:

if (marking_is_active) {
	pre_val = x.f;
	if (pre_val != NULL) {
		satb_enqueue(pre_val);
	}
}

在初始标记过程中,marking_is_active只是一个线程局部变量的简单判断,当标记开始时被设为true,当标记动作没有激活,这个检查项就会限制后续栅栏代码继续执行,可以减少开销。因为这个是线程的局部变量并且值可能会被多次读取,它就和那些独立检查项一样被放入缓冲区中,进一步降低栅栏的开销。stab_enqueue()方法首先会尝试将以前的值排列到一个线程局部变量缓冲区中,也就是SATB缓冲区。一个SATB缓冲区的初始尺寸为256条记录,每个应用线程都有一个SATB缓冲区,如果SATB中没有多余的空间存放pre_val,JVM运行时就会被调用,当前线程的这个SATB缓冲区退休并放入专门存放SATB缓冲区的全局列表中,然后再给线程分配一个新的SATB缓冲区,并记录pre_val。并发标记线程会定期检查和处理这些”被填满”的缓冲区来标记那些被记录的对象

在标记阶段,那些在全局列表中的SATB缓冲区会逐个被处理,并通过在标记位图中设置对应的标记位来标记每个被记录的对象(如果对象位于”手指”之后,那就把它推送到一个本地标记栈中)。然后根据标记位图的分片里的标记位,扫描标记对象的引用字段,在标记位图中设置更多标记位,如有必要的话还会推送对象进栈

存活数据计算是标记操作的一个附加产物。只要一个对象被标记,同时就会被计算(即它的字节数会被计入分区的总数)。只有NTAMS以下的对象会被标记和计算。在这个阶段的最后,下一次标记的位图会被清空,这样下一次标记周期开始时它就可以直接开始工作了。这是和应用线程并发执行的

JDK 8u40有一个新的命令行选项-XX:ClassUnloadingWithConcurrentMark,它使类可以在并发标记时被卸载。因此并发标记可以跟踪类并计算它们的存活度。在重新标记阶段,那些不可达的类就会被卸载

重新标记

重新标记阶段是最后一个标记阶段。在这个stop-the-world阶段中,G1垃圾收集器会处理掉所有剩下的SATB日志缓冲区和所有更新,同时G1垃圾收集器还会找出所有未被访问的存活对象。在JDK 8u40版本,重新标记阶段还是stop-the-world方式的,因为Java应用线程负责更新SATB日志缓冲区并且其本身拥有这些缓冲区。这样就需要最后一个STW的暂停来覆盖所有存活的数据,同时安全的完成存活数据的计算。为了减少这个暂停的时间开销,可以使用多个GC线程来并行处理这些日志缓冲区(-XX:ParallelGCThreads)。另外引用处理也是重新标记阶段的一个组成部分(弱引用、软引用、虚引用或者最终引用的处理开销都会导致长时间的重新标记)

清除

在清除阶段,2个标记位图交换了它们的角色,即next标记位图称为previous位图(最近的标记周期已经结束,同时next标记位图包含了相应的标记信息),previous标记位图将成为next位图(它将在下一个周期中被当做当前标记位图)。类似的,PTAMS和NTAMS也会交换角色

清除阶段的3个主要贡献分别是:识别所有空闲分区;整理堆分区,为混合垃圾回收识别出高效率的老年代分区;RSet梳理。当前的启发式算法会根据活跃度和已记忆集合的尺寸对分区定义不同等级。因为收集那些有很多存活对象的分区,代价是非常巨大的,因为拷贝的操作成本很高;同时因分区”受欢迎”特性,拥有巨大已记忆集合的分区的开销对收集来说也是非常巨大的。这样做的目的就是优先收集或转移那些被认为成本比较小(存活对象少、不是那么受欢迎)的候选分区

识别每个分区里存活对象的一个好处是遇到一个完全空闲的分区时,它的已记忆集合可以立即被清理,同时这个分区可以立即被回收并释放到空闲队列中,而不在需要被放进垃圾收集器”高效率的”分类队列里等待(混合)垃圾收集阶段的回收。RSet梳理也有助于发现无用的引用,比如标记发现某个卡片上所有对象都已死亡,那这个卡片的记录就会从”拥有的”RSet中被清除

转移失败与full GC

有时候G1 GC在试图从一个年轻代分区拷贝存活对象或从一个老年代分区转移存活对象时,无法找到可用的空闲分区。这种失败在GC日志中被记录为to-space exhausted,该错误的持续时间会在日志中显示为Evacuation Failure时间。还有一种情况是分配巨型对象时在老年代中无法找到足够的连续分区。这时G1垃圾收集器会尝试增加Java堆的使用量。如果堆的扩展不成功,G1就会触发安全措施的机制,同时求助于串行(单线程)full收集

在full GC过程中,一个单线程会对整个堆的各个代的所有分区(无论开销是否昂贵)做标记、清除以及压缩操作,收集结束后,现在的堆就只纯粹包含存活对象,同时所有的代都被压缩过了。串行full收集单线程的特性,以及收集需要跨越整个堆的事实,是的full GC注定是一个非常昂贵的收集,特别是堆尺寸特别巨大的情况

在JDK 8u40前,只有在full GC时才可能卸载类