《深入理解JVM & G1 GC》【3】

《深入理解JVM & G1 GC》【2】

G1 GC应用示例

G1 GC给我们提供了很多的命令行选项,也就是参数,这些参数一类以布尔类型打头,“+”表示启用该选项,“-”表示关闭该选项。另一类采用数字赋值,不需要布尔类型打头。

选项解释及应用

首先在cmd命令行模式下输入java -X,,如C:UsersAdministrator> java -X,输出如代码如下:

-XX:+PrintGCDetails

该选项用于记录GC运行时的详细数据信息并输出,是最基本、使用最普遍的一个选项这个选项适用于所有GC,输出内容主要包括新生成对象占用内存大小以及耗费时间、各个年龄代的情况、每次回收的对应数据等。

-Xloggc

如果想要以文件形式保存这些GC日志,可以在启动参数中输入-XX:+PrintGCDetails -verbose:gc -XLoggc:gc.log,运行后我们会发现生成了一个 gc.log文件。

-Xloggc:example_gc.log (设置垃圾回收日志打印的文件,文件名称可以自定义)

-XX:initialHeapSize和-XX:MaxHeapSize就是我们比较熟悉的-Xms和-Xmx,它们允许我们指定JVM的初始和最大堆内存大小-XX:+UseCompressedClassPointers、XX:+UseCompressedOops
以及-XX:-UseLargePagesIndividualAllocation这三个选项和OOP有关。OOP的全称是Ordinary Object Pointer,即普通对象指针。通常64位JVM消耗的内存会比32位的大1.5倍,这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址)。对于那些将要从32位平台移植到64位的应用来说,平白无故多了1/2的内存占用,作为开发者一定不愿意看到这种场景。所以,从JDK1.6 update4开始,64 bit JVM正式支持了-XX:+UseCompressedOops这个可以压缩指针,起到节约内存占用的选项。CompressedOops的实现方式是在机器码中植入压缩与解压指令,可能会给JVM增加额外的开销。-XX:+UseCompressedClassPointers选项是在JDK8出现的,也是在永久区消失之后出现的新的选项,主要用于对类的元数据进行压缩。-XX:UseLargePagesIndividualAllocation和oops是一起使用的,在大页内存使用发生时这个选项也会自动启用。

-XX:+PrintGCApplicationStoppedTime

打印垃圾回收期间程序暂停的时间,如果使用该选项,会输出GC造成应用程序暂停的时间。一般和-XX:+PrintGCApplicationConcurrentTime组合起来一起使用,这样比较有利于查看输出。

-XX:ConcGCThreads

这个选项用来设置与Java应用程序线程并行执行的GC线程数量,默认为GC独占时运行线程的1/4。这个选项设置过大会导致Java应用程序可以使用的CPU资源减少,如果小一点则会对应用程序有利,但是过小就会增加GC并行循环的执行时间,反过来减少Java应用程序的运行时间(因为独占期时间拉长)。

-XX:G1HeapRegionSize

这是G1GC独有的选项,它是专门针对Region这个概念的对应设置选项,后续GC应该会继续采用 Region这个概念。 Region的大小默认为堆大小的1/200,.也可以设置为1MB、2MB、4MB、8MB、16MB,以及32MB,这六个划分档次。

增大Region块的大小有利于处理大对象。前面介绍过,大对象没有按照普通对象方式进行管理和分配空间,如果增大Region块的大小,则一些原本走特殊处理通道的大对象就可以被纳入普通处理通道了。这就好比我们在机场安检,飞行员、空姐可以走特殊通道,乘客如果也搞特殊化,一部分人去特殊通道处理,那么特殊通道就得増加几个,相应的普通通道就得减少了,对效率就起了降低作用。反之,如果Region大小设置过小,则会降低G1的灵活性,对于各个年龄代的大小都会造成分配问题。

-XX:G1HeapWastePercent

这个选项控制G1 GC不会回收的空闲内存比例,默认是堆内存的5%。G1 GC在回收过程中会回收所有Region的内存,并持续地做这个工作直到空闲内存比例达到设置的这个值为止,所以对于设置了较大值的堆内存来说,需要采用比较低的比例,这样可以确保较小部分的内存不被回收。这个很容易理解,城市越大就越容易出现一些死角,出于性能的原因可以不去关注那里,但是这个比例不能大。

-XX:G1MixedGCCountTarget

老年代Region的回收时间通常来说比年轻代Region稍长一些,这个选项可以设置一个并行循环之后启动多少个混合GC,默认值是8个。设置一个比较大的值可以让G1 GC在老年代Region回收时多花一些时间,如果一个混合GC的停顿时间很长,说明它要做的事情很多,所以可以增大这个值的设置,但是如果这个值过大,也会造成并行循环等待混合GC完成的时间相应的增加。

当占用内存超过InitiatingHeapOccupancyPercent阀值时, 最多通过多少次Mixed GC来将内存控制在阀值之下。

-XX:+G1PrintRegionLivenessInfo

由于开启这个选项会在标记循环阶段完成之后输出详细信息,专业一点的叫法是诊断选项,所以在使用前需要开启选项UnlockDiagnosticVMOptions。这个选项启用后会打印堆内存内部每个Region里面的存活对象信息,这些信息包括使用率、RSet大小、回收一个Region的价值(Region内部回收价值评估,即性价比)。

这个选项输出的信息对于调试堆内Region是很有效的,不过对于一个很大的堆内存来说,由于每个 Region信息都输出了,所以信息量也是挺大的。

-XX:G1ReservePercent

每个年龄代都会有一些对象可以进入下一个阶段,为了确保这个提升过程正常完成,我们允许G1GC保留一些内存,这样就可以避免出现“ to space exhausted”错误,这个选项就是为了这个用途。

这个选项默认保留堆内存的10%。注意,这个预留内存空间不能用于年轻代

对于一个拥有大内存的堆内存来说,这个值不能过大,因为它不能用于年轻代,这就意味着年轻代可用内存降低了。减小这个值有助于给年轻代留出更大的内存空间、更长的GC时间,这对提升性能吞吐量有好处。

-XX:+G1SummarizeRSetStats

和GIPrintRegionLivenessInfo选项一样,这个选项也是一个诊断选项,所以也需要开启UnlockDiagnosticVMOptions选项后才能使用,这也就意味着-XX:+UnlockDiagnosticVMOptions选项需要放在-XX:+G1SummarizeRSetStats选项的前面。

这个选项和-XX:G1SummarizePeriod一起使用的时候会阶段性地打印RSets的详细信息,这有助于找到RSet里面存在的问题。

-XX:+G1TraceConcRefinement

这是一个诊断选项。如果启动这个诊断选项,那么并行Refinement线程相关的信息会被打印。注意,线程启动和结束时的信息都会被打印。

这里提到了Refinement线程,我们来提前梳理这个概念。请看每一代GC对应的GC线程:

Garbage CollectorWorker Threads Used
Parallel GC ParallelGCThreads
CMS GC ParallelGCThreads
ConcGCThreads
G1 GC ParallelGCThreads
ConcGCThreads
G1ConcRefinementThreads

上面列出了三类GC线程,分别是ParallelGCThreads、ConcGCThreads和G1ConcRefinementThreads。关于这三个线程的区别:

名称选项控制作用
ParallelGC Thread -XX:ParallelGCThreads GC的并行工作线程,专门用于独占阶段的工作,比如拷贝存活对象
ParallelMarkingThreads -XX:ConcGCThreads 并行标记阶段的并行线程,它由一个主控(Master)线程和一些工作(Worker)线程组成,可以和应用程序并行执行
G1ConcurrentRefinementThreads -XX:G1ConcRefinementThreads 和应用程序一起运行,用于更新RSet,如果ConcurrentRefinementThreads没有设置,那么默认为ParallelGCThreads+1

-XX:+G1UseAdaptiveConcRefinement

这个选项默认是开启的。它会动态地对每一次GC中XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone、-XX:G1ConcRefinementRedZone的值进行重新计算。

并行Refinement线程是持续运行的,并且会随着update log buffer积累的数量而动态调节。前面说到的三个配置选项-XX:G1ConcRefinementGreenZone、-XX:G1ConcRefinementYellowZone、-XX:G1ConcRefinementRedZone,是被用来根据不同的 buffer使用不同的Refinement线程,目的就是为了保证 Refinement线程一定要尽可能地跟上update log buffer产生的步伐。但是这个Refinement线程不是无限增加的,一旦出现 Refinement线程跟不上update log buffer产生的速度、update log buffer开始出现积压的情况,Mutator线程(即应用业务线程)就会协助Refinement线程执行RSet的更新工作。这个 Mutator线程实际上就是应用业务线程,当业务线程去参与Rset修改时,系统性能一定会受到影响,所以需要尽力去避免这种状况。

-XX:GCTimeRatio

这个选项代表Java应用线程花费的时间与GC线程花费时间的比率。通过这个比率值可以调节Java应用线程或者GC线程的工作时间,保障两者的执行时间.

HotSpot VM转换这个值为一个百分比,公式是100/(1+GCTimeRatio),默认值是9,表示花费在GC工作量上的时间占总时间的10%。

-XX:+HeapDumpBeforeFullGC/-XX:+HeapDumpAfterFullGC

这个选项启用之后,在Full GC开始之前有一个hprof文件会被创建。建议这个选项和-XX:+HeapDumpAfterFullGC一起使用,可以通过对Full GC发生前后的Java堆内存进行对比,找出内存泄漏和其他问题。

获取full GC前后的heap dump

-XX:InitiatingHeapOccypancyPercent

该选项的默认值是45,表示G1 GC并行循环初始设置的堆大小值,这个值决定了一个并行循环是不是要开始执行。它的逻辑是在一次GC完成后,比较老年代占用的空间和整个Java堆之间的比例。如果大于这个值,则预约下一次GC开始一个并行循环回收垃圾,从初始标记阶段开始。这个值越小,GC越频繁,反之,值越大,可以让应用程序执行时间更长。不过在内存消耗很快的情况下,我认为早运行并行循环比晚运行要好,看病要趁早

-XX:+UseStringDeduplication

该选项启动Java String对象的去重工作。JDK8u20开始引入该选项,默认为不启用。我们知道一个判断Java String对象值是否一样的语句“Stringl equals(String2)tue”,如果开启了该选项,并且如果两个对象包含相同的内容,即返回“tue”,则两个String对象只会共享一个字符数组。这个选项是G1GC独有的,也可以和其他GC一起使用。

延伸一点我们的知识面,一个去重对象的必备条件有如下三点:

  • Java.lang String对象的一个实例。
  • 这个对象在年轻代堆区间。
  • 这个对象的年龄达到去重年龄代,或者这个对象已经在老年代堆区间并且对象年龄比去重年龄小。选项-XX:StringDeduplicationAgeThreshold设置了这个年龄界限。

前面介绍过的可修改和不可修改字符串的处理方式有所不同,不可修改字符串默认就是去重的,在插入到HotSpot VM的String Table时已经注明了是去重的,这样就避免了HotSpot服务器JIT编译优化措施。

-XX:StringDeduplicationAgeThreshold

这个选项是针对-XX:+UseStringDeduplication选项的,默认值是3。它的意思是一个字符串对象的年龄超过设定的阈值,或者提升到G1 GC老年代Region之后,就会成为字符串去重的候选对象,去重操作只会有一次。

-XX:+PrintStringDeduplicationStatistics

这个选项挺有用的,能够帮助我们通过读取输出的统计资料来了解是否字符串去重后节约了大量的堆内存空间,默认是关闭的,就是说不会输出字符串去重的统计资料。

-XX:+G1UseAdaptiveIHOP

JDK9提供的新的选项。这个选项的作用是通过动态调节标记阶段开始的时间,以达到提升应用程序吞吐量的目标,主要通过尽可能迟地触发标记循环方式来避免消耗老年代空间。

这个选项的值在VM刚开始启动时和-XX:InitiatingHeapOccupancyPercent的值一样,如果出现标记循环阶段内存不够用,则它会自动调节大小,确保标记循环启用更多的堆内存。

注意,-XX:+G1UseAdaptiveIHOP这个选项会在JDK9里默认启用,即-XX:InitiatingHeapOccupancyPercent和XX:+GIUseAdaptivelHOP在JDK9之后只需要启用一个就可以了。

JDK8环境下运行该选项会输出:“Unrecognized VM option ‘G1UseAdaptivelHOP’”

确定初始堆占用率
启动堆占用百分比(Initiating Heap Occupancy Percent, IHOP)是触发初始标记回收的阈值,它被定义为老年代大小的百分比
默认情况下,G1通过在标记周期中观察标记需要多长时间以及在老年代中通常分配多少内存来自动确定最佳IHOP。这个特性称为自适应IHOP。如果这个特性是活动的,那么选项 -XX:InitiatingHeapOccupancyPercent 确定初始值作为当前老年代代大小的百分比,只要没有足够的观测值来很好地预测启动堆占用阈值。 使用 -XX:-G1UseAdaptiveIHOP 选项关闭 G1的此行为。 在这种情况下, -XX:InitiatingHeapOccupancyPercent 的值总是决定这个阈值。

-XX:+MaxGCPauseMills

这个选项比较重要。它设置了G1的目标停顿时间,单位是ms,默认值为200ms。这个值是一个目标时间,而不是最大停顿时间。G1 GC尽最大努力确保年轻代的回收时间可以控制在这个目标停顿时间范围里面,在G1GC使用过程中,这个选项和-Xms、Xmx两个选项一起使用,它们三个也最好在JVM启动时就一起配置好。

-XX:+MinHeapFreeRatio

这个选项设置堆内存里可以空闲的最小的内存空间大小,默认值为堆内存的40%。当空闲堆内存大小小于这个设置的值时,我们需要判断-Xms和-Xmx这两个值的初始化设置值,如果-Xms和-Xmx不一样,那么我们就有机会扩展堆内存,否则就无法扩展。

-XX:+MaxHeapFreeRatio

这个选项设置最大空闲空间大小,默认值为堆内存的70%。这个选项和上面那个最小堆内存空闲大小刚好相反,当大于这个空闲比率时,G1 GC会自动减少堆内存大小。需要判断-Xms和-Xmx这两个值的初始化设置值,如果-Xms和-Xmx不一样,那么就有机会减小堆内存,否则就无法减小。

-XX:+PrintAdaptiveSizePolicy

这个选项决定是否开启堆内存大小变化的相应记录信息打印,即是否打印这些信息到GC日志里面。这个信息对于Parallel GC和G1 GC都很有用。

-XX:+ResizePLAB

GC使用的本地线程分配缓存块采用动态值还是静态值进行设置是由这个选项决定的,它默认是开启的,这个设置对应的是GC在提升对象时是否会调整PLAB的大小。

这个选项大家还是慎用,据说会出现性能问题,启用后可能会增加GC的停顿时间。当应用开启的线程较多时,最好使用-XX:ResizePlaB来关闭PLAB()的大小调整,以避免大量的线程通信所导致的性能下降。

-XX:+ResizeTLAB

Java应用线程使用的本地线程分配缓存块采用动态值还是静态值进行设置是由这个选项决定的,它默认是开启的,即TLAB值会被动态调整。

关于 TLAB 的一些分析总结

0.292017.05.05 17:48:30 字数 2,192 阅读 7,575

简书 占小狼
转载请注明原创出处,谢谢!

本文由臧秀涛撰稿,经过 R 大润色,由占小狼倾情分享,这些分析总结道出了 TLAB 的来龙去脉,不得不说 R 大语言基本功真是大写的服字。

在 JVM 研究群里,占小狼同学发来一篇文章:JVM 源码分析之线程局部缓存 TLAB

大家对相关概念还有有些疑问,RednaxelaFX、你假笨等朋友就此做了很多分享。

以下内容主要根据大家的问题和 RednaxelaFX、你假笨的分享整理。

TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,均摊对 GC 堆(eden 区)里共享的分配指针做更新而带来的同步开销。

TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分配指针 top 撞上分配极限 end 了),就新申请一个 TLAB,而在老 TLAB 里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从 TLAB 分配出来的,而只关心自己是在 eden 里分配的。

TLAB 简单来说本质上就是三个指针:start,top 和 end (实际实现中还有一些额外信息但这里暂不讨论)。
其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住 eden 里的一块空间说其它线程别来这里分配了哈。而 top 就是里面的分配指针,一开始指向跟 start 同样的位置,然后逐渐分配,直到再要分配下一个对象就会撞上 end 的时候就会触发一次 TLAB refill。

要注意 TLAB 这个词其实有两层意思:一个是指存在管理 Java 线程的元数据对象 JavaThread 里的 ThreadLocalAllocBuffer 对象,它持有上述三个指针,仅用于管理用而不存储对象自身;另一个是指在 eden 中分配出来的、被一个线程的 ThreadLocalAllocBuffer 所管理的一块空间,这才是实际存放对象的地方。本讨论不特地指出的时候会自由混用这两层意思,把它们当作一个整体来看待。

TLAB refill 包括下述几个动作:

  • 将当前 TLAB 抛弃(retire)掉。这个过程中最重要的动作是将 TLAB 末尾尚未分配给 Java 对象的空间(浪费掉的空间)分配成一个假的 “filler object”(目前是用 int[] 作为 filler object)。这是为了保持 GC 堆可以线性 parse(heap parseability)用的。
  • 从 eden 新分配一块裸的空间出来(这一步可能会失败)
  • 将新分配的空间范围记录到 ThreadLocalAllocBuffer 里
    TLAB refill 不成功(eden 没有足够空间来分配这个新 TLAB)就会触发 YGC。

注意 “撞上” 指的是在某次分配请求中,top + new_obj_size >= end 的情况,也就是说在被判定 “撞上” 的时候,top 常常离 end 还有一段距离,只是这之间的空间不足以满足新对象的分配请求 new_obj_size 的大小。这意味着在触发 TLAB refill 的时候,有可能会浪费掉位于该 TLAB 末尾的一部分空间:该 TLAB 已经占用了这块空间所以其它线程无法在这里分配 Java 对象,但该 TLAB 要 refill 的话它自己也不会在这块空间继续分配 Java 对象,从应用层面看这块空间就浪费了。

每次分配 TLAB 的大小不是固定的,而是每个线程根据该线程启动开始到现在的历史统计信息来自己单独调整的。如果一个线程上跑的代码的内存分配速率非常高,则该线程会选择使用更大的 TLAB 以达到均摊同步开销的效果,反之亦然;同时它还会统计浪费比例,并且将其放入计算新 TLAB 大小的考虑因素当中,把浪费比例控制在一定范围内。

GC 很重要的一点是对 heap parseability 的依赖。GC 做某些需要线性扫描堆里的对象的操作时,需要知道堆里哪些地方有对象而哪些地方是空洞。一种办法是使用外部数据结构,例如 freelist 或者 allocation BitMap 之类来记录哪里有空洞;另一种办法是把空洞部分也假装成有对象,这样 GC 在线性遍历时会看到一个 “对象总是连续分配的” 的假象,就可以以统一的方式来遍历:遍历到一个对象时,通过其对象头记录的信息找出该对象的大小,然后跳到该大小之后就可以找到下一个对象的对象头,依此类推。HotSpot 选择的是后者的做法,假装成有对象的这种东西就叫做 filler object(填充对象)

实现代码上,

TLAB 的慢速分配和重新申请空间的逻辑在这里:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/gc_interface/collectedHeap.cpp#l264

申请好了空间并且 zero 完之后就会设置进 TLAB 里:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/gc_interface/collectedHeap.cpp#l304

TLAB 里的 filler object 是这样用的:http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/cf85f331361b/src/share/vm/memory/threadLocalAllocBuffer.cpp#l110

CollectedHeap 里的裸 allocate 动作是不关心分配的东西是什么类型的,只管在 GC 堆里看有没有地方可以分配,有的话 bump 分配指针并返回 bump 前的指针。TLAB 从 eden 重新分配空间就是问 CollectedHeap 再 allocate 一块这样的裸的空间,然后把这块空间的首尾记录到自己的 start 和 end 里去。

另外建议把 TLAB 翻译为线程私有分配区,而不是线程局部分配缓存这样词对词的直译。毕竟 TLAB 并不是一个缓存,而且它的重点也不是局部,而是让那个分配指针成为线程私有的东西

无论是加锁还是 CAS,HotSpot 的共享堆分配都是用碰撞指针(pointer bumping / bump-the-pointer)来做的。加锁跟 bump 不在一个层面上,不应该并列。锁或者 CAS 只是同步的机制,实际想要做的事情都一样是 bump pointer。如果在不需要与其它线程竞争的条件下,bump pointer 就不用同步保护。

例如在 TLAB 里,又例如在 PLAB 里,又例如在共享部分但在 safepoint 中没有竞争的情况下。

PLAB 也是个非常有趣的东西,提到 TLAB 的话也可以顺带说下 PLAB。HotSpot 里的 TLAB 是只在 eden 里分配的,用于给新建的小对象用。(本来其实也有考虑让 TLAB 在任意位置分配,但后来没实现)。PLAB 则是在 old gen 里分配的一种临时的结构。就是笨神说的 promotion LAB。

在多 GC 线程并行做 YGC 的时候,大家都要为了晋升对象而在 old gen 里分配空间,于是 old gen 的分配指针就热起来了。大量的竞争会使得并行度降低,所以跟 TLAB 用同样的思路,old gen 在处理 YGC 的晋升对象的分配也一样可以用(GC)线程私有的分配区。这就是 PLAB。另外在 CMS 里 old gen 的剩余空间不是连续的,而是有很多空洞。这些剩余空间是通过 freelist 来管理的。

如果 ParNew 要把对象晋升到 CMS 管理的 old gen,不优化的话就得在 freelist 上做分配。于是就可以通过类似 PLAB 的方式,每个 GC 线程先从 freelist 申请一块大空间,然后在这块大空间里线性分配(bump pointer)。这样就既降低了对分配指针 / freelist 的竞争,又可以降低 freelist 分配的频率而转为用线性分配。

 
   

-XX:+ClassUnloadingWithConcurrentMark

这个选项开启在G1 GC并行循环阶段卸载类,尤其是在老年代的并行回收阶段,默认是开启的。这个选项开启后会在并行循环的重标记阶段卸载JVM没有用到的类,这些工作也可以放在Full GC里面去做,但是提前做了有很大的好处。但因为开启它意味着重标记阶段的GC停顿时间会拉长,这时候我们就要判断性价比了,如果GC停顿时间比我们设置的最大GC停顿目标时间还长,并且需要卸载的类也不多,那还是关闭这个选项吧。

-XX:+ClassUnloading

默认值是Ture,决定了JVM是否会卸载所有无用的类,如果关闭了这个选项,无论是并行回收循环,还是Full GC,都不会再卸载这些类了,所以需谨慎关闭。

-XX:+UnlockDiagnosticVMOptions

这个选项决定是否开启诊断选项,默认值是False,即不开启在GC里面有一些选项称之为诊断选项(Diagnostic Options),通过-XX:+PrintFlagsFinal 和XX:+Unlock。DiagnosticVMOptions这两个选项组合起来运行,就可以输出并查看这些选项。

-XX:+UnlockExperimentalVMOptions

除了之前说的诊断选项以外,JVM还有一些叫作试验选项(Experimental Options),这些选项也需要通过XX:+UnlockExperimentalVMOptions这个选项开启,默认是关闭的。

和诊断选项一样,也可以和-XX:+PrintFlagsFinal选项联合使用,即-XX:+PrintFlagsFinal和-XX:+UnlockExperimental VMOptions这两个选项联合使用时可以输出日志,输出的日志已经包含在了前一个选项-XX:+UnlockDiagnosticVMOptions的运行输出里,这里就不再重复。

总的来说,这些试验选项对整体应用性能可能会有些好处,但是它们并没有经历完整的测试环节,所以称为试验选项。

-XX:+UnlockCommercialFeatures

这个选项判断是否使用 Oracle特有的特性,默认是关闭的。

有一些属性是Oracle公司针对Oracle的Java运行时独有的,没有被包含在OpenJDK里面。举个例子,比如说 Oracle的监控和管理工具Java Mission Control,它有一个特性叫作Java Flight Recorder,这个特性作为Java Mission Control的一部分,属于事件回收框架,可以被用来显示应用程序和JVM的底层信息。

深入G1 GC

G1 GC概念简介

背景知识

G1使用了全新的分区算法,其特点如下所示:

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,可以有效利用多核的计算能力
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
  • 分代GC:G1依然是一个分代收集器,但是和之前的各类回收器不同,它同时兼顾了年轻代和老年代。对比其他回收器,它们或者工作在年轻代,或者工作在老年代。
  • 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS那样只是简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
  • 可预见性:由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。

随着G1 GC的出现,GC从传统的连续堆内存布局逐渐走向了不连续内存块布局,这是通过引入Region概念实现的,也就是说,由一堆不连续的Region组成了堆内存。其实也不能说是不连续的,只是它从传统的物理连续逐渐改变为逻辑上的连续,这是通过Region的动态分配方式实现的,可以把一个Region分配给Eden、Surviⅳvor、老年代、大对象区间、空闲区间等区间的任意一个,而不是固定它的作用,因为越是固定,越是呆板。

G1的区间设计灵感

在G1中,堆被平均分成若干个大小相等的区域(Region)。每个Region都有个关联的Remembered Set(简称RS),RS的数据结构是Hash表,里面的数据是Card Table(堆中每512byte映射在card table 1byte)。简单地说,RS里面存在的是Region中存活对象的指针。当Region中数据发生变化时,首先反映到Card Table中的一个或多个Card上,RS通过扫描内部的Card Table得知Region中内存使用情况和存活对象。在使用Region过程中,如果Region 被填满了,分配内存的线程会重新选择一个新的Region,空闲Region被组织到一个基于链表的数据结构(LinkedList里面,这样可以快速找到新的Region。

G1 GC分代管理

年轻代

除非我们显示地通过命令行方式声明了年轻代的初始化值和最大值的大小,否则,一般来说,初始化值默认是整个Java堆大小的5%(通过选项-XX:G1NewSizePercent设置),最大值默认是整个Java堆大小的60%(通过选项-XX:G1MaxNewSizePercent设置)。

回收集合及其重要性

任何一次垃圾回收都会释放CSet里面的所有区间。一个CSet由一系列的等待回收的区间所组成。在一次垃圾回收过程中,这些回收候选区间的存活对象会被整体评估,并且在回收结束后这些区间会被加入到空闲区间队列(LinkedList队列)。在一次年轻代回收过程中,CSet只会包含年轻代区间,而在一个混合回收过程中,CSet会在年轻代区间基础上再包含一些老年代区间,这就是新增的混合回收概念,不再对年轻代和老年代完全切分。

G1 GC提供了两个选项用于帮助选择进入CSet的候选老年代区间:

  • -XX:G1MixedGCLiveThresholdPercent:JDK8u45默认值为一个G1 GC区间的85%。这个值是一个存活对象的阈值,并且起到了从混合回收的CSet里排除一些老年代区间的作用,即可以理解为G1 GC限制CSet仅包含低于这个阈值(默认85%)的老年代区间,这样可以减少垃圾回收过程中拷贝对象所消耗的时间。
  • -XX:G1OldCSetRegionThresholdPercent:JDK8u45默认值为整个Java堆区的10%。这个值设置了可以被用于一次混合回收暂停所回收的最大老年代区间数量。这个阈值取决于JVM进程所能使用的Java堆的空闲空间。

RSet及其重要性

一个RSet是一个数据结构,这个数据结构帮助维护和跟踪在它们单元内部的对象引用信息,在G1 GC里,这个单元就是区间(Region),也就是说,G1 GC里每一个RSet对应的是一个区间内部的对象引用情况。有了RSet,就不需要扫描整个堆内存了,当G1 GC执行STW独占回收(年轻代、混合代回收)时,只需要扫描每一个区间内部的RSet就可以了。因为所有RSet都保存在CSet里面,即Region-RSet-CSet这样的概念,所以一旦区间内部的存活对象被移除,RSet里面保存的引用信息也会立即被更新。这样我们就能够理解RSet就是一张虚拟的对象引用表了,每个区间内部都有这么一张表存在,帮助对区间内部的对象存活情况、基本信息做有序高效的管理。`

G1 GC的年轻代回收或者混合回收阶段,由于年轻代被尽可能地设计为最大量的回收,这样的设计方式减少了对于RSet的依赖,即减弱了对于年轻代里面存储的跟踪引用信息的依赖程度,进而减弱了多余RSet的消耗。G1 GC只在以下两个场景依赖RSet。

  • 老年代到年轻代的引用:G1 GC维护了从老年代区间到年轻代区间的指针,这个指针保存在年轻代的RSet里面。
  • 老年代到老年代的引用:G1 GC维护了从老年代区间到老年代区间的指针,这个指针保存在老年代的RSet里面。

每一个区间只会有一个RSet由于对于对象的引用是基于Java应用程序的需求的,所以有可能会出现RSet内部的“热点”,即一个区间出现很多次的引用更新,都出现在同一个位置的情况。

对于一个访问很频繁的区间来说,这样的方式会影响RSet的扫描时间。

注意,区间(Region)并不是最小单元,每个区间会被进一步划分为若干个块(Chunks)。在G1 GC区间里,最小的单元是一个512个字节的堆内存块(Card)。G1 GC为每个区间设置了一个全局内存块表来帮助维护所有的堆内存块,如下图所示:

当一个指针引用到了RSet里面的一个区间时,包含该指针的堆内存块就会在PRT里面被标记。如果需要快速地扫描一张数据表,最好的方式是建立索引,一个粗粒度的PRT就是基于哈希表建立的。对于一个细粒度的PRT来说,哈希表内部的每一个入口对应一个区间,而区间内部的内存块索引也是存储在位图里面的。当细粒度PRT的最大值被突破的时候,我们就会开始采用粗粒度方式处理PRT。

在垃圾回收过程中,当扫描RSet并且内存块确实存在于PRT里时,G1 GC会在全局堆内存块数据表里标记对应的入口,这种做法避免了重新扫描这个内存块。G1 GC会在回收循环阶段默认清除内存堆表,在GC线程的并行工作(主要包括根外部扫描、更新和扫描RSet、对象拷贝、终止协议等)完成之后紧跟着的就是清除堆内存表标记(Clear CT)阶段。Update RS和Scan RS对应的是RSet的更新和扫描动作。

RSet的作用是很明显的,但是在使用过程中我们也遇到了写保护和并行更新线程的维护成本。

OpenJDK HotSpot的并行老年代和CMS GC都在执行JVM的一个对象引用写操作时使用了写保护机制,如代码object field = some_other_object。还记得我们对于每个区间是采用针对最小单元堆内存块进行管理的吗?这个写保护机制也会通过更新一个类似于堆内存块表的数据结构来跟踪跨年代引用。堆内存表在最小垃圾回收时会被扫描。写保护算法基于Urs Holzle的快速写保护算法,这个算法减少了编译代码时的外部指令消耗。

当跨越区间的更新发生的时候,G1 GC会将这些对应的堆内存块放入一个缓存,我们可以称这个缓存为“更新日志缓存”,写入该缓存的方式和写入队列的方式一样。G1 GC会使用一个专门的线程组去维持RSet信息,它们的职责是扫描“更新日志缓存”,然后更新RSet。JDK8u45采用选项-XX:G1ConcRefinementThreads设置这个线程组的数量,如果你没有设置,那么默认采用-XX:ParallelGCThreads选项。

一旦“更新日志缓存”达到了最大可用,它会被放入全局化的满载队列并启用一个新的缓存块。一旦更新线程在全局满载队列里面发现了入口,它们就开始并行处理整个满载缓存队列。

G1 GC针对并行更新线程采用的是分层方法,为了保证更新速度会加入更多的线程,如果实在跟不上速度,Java应用程序线程也会加入战斗,但尽量不要出现这样的情况,这种情况是发生了线程窃取,会造成应用程序花费了本可以用于自身程序算法运行的能力。

并行标记循环

并行标记循环的过程是初始标记阶段→根区间扫描阶段→并行标记阶段→重标记阶段→清除阶段,其中一部分是可以与应用程序并行执行的,一部分是独占式的。

1.初始标记阶段

这个阶段是独占式的,它会停止所有的Java线程,然后开始标记根节点可及的所有对象。这个阶段可以和年轻代回收同时执行,这样的设计方式主要是为了加快独占阶段的执行速度。

在这个阶段,每一个区间的NATMS值会被设置在区间的顶部。

2.根区间扫描阶段

设置了每个区间的TAMS值之后,Java应用程序线程重新开始执行,根区间扫描阶段也会和Java应用程序线程并行执行。基于标记算法原理,在年轻代回收的初始标记阶段拷贝到幸存者区间的对象需要被扫描并被当作标记根元素,相应地,G1 GC因此开始扫描幸存者区间。任何从幸存者区间过来的引用都会被标记,基于这个原理,幸存者区间也被称为根区间。

根区间扫描阶段必须在下一个垃圾回收暂停之前完成,这是因为所有从幸存者区间来的引用需要在整个堆区间扫描之前完成标记工作。

3.并行标记阶段

首先可以明确的是,并行标记阶段是一个并行的且多线程的阶段,可以通过选项-XX:ConcGCThreads来设置并行线程的数量。默认情况下,G1 GC设置并行标记阶段线程数量为选项-XX:ParallelGCThreads(并行GC线程)的1/4。并行标记线程一次只扫描一个区间,扫描完毕后会通过标记位方式标记该区间已经扫描完毕为了满足SATB并行标记算法的要求,G1 GC采用一个写前barrier执行相应的动作。

4.重标记阶段

重标记阶段是整个标记阶段的最后一环。这个阶段是一个独占式阶段,在整个独占式过程中,G1 GC完全处理了遗留的SATB日志缓存、更新。这个阶段主要的目标是统计存活对象的数量,同时也对引用对象进行处理。

G1 GC采用多线程方式加快并行处理日志缓存文件,这样可以节省下来很多时间,通过选项-XX:ParallelGCThreads可以设置GC数量。

注意,如果你的应用程序使用了大量的引用对象,例如弱引用、软引用、虚引用、强引用,那么这个重标记阶段的耗时会有所增加。

5.清除阶段

前面各个阶段在做的主要事情就是为了标记对象,那么为什么需要针对每一个区间进行标记呢?这是因为如果我们知道了每个区间的存活对象数量,如果这个区间没有一个存活对象,那么就可以很快地清除RSet,并且立即放入空闲区间队列,而不是将这个区间放入排队序列,等待一个混合垃圾回收暂停阶段的回收。RSet也可以被用来帮助检测过期引用,例如,如果标记阶段发现所有在特定堆块上的对象都已经死亡,那么RSet可以快速清除这块堆块。

一句话总结,清除阶段会识别并清理完全空闲的区域。它是并发的清理,不会引起停顿。

评估失败和完全回收

如果在年轻代区间或者老年代区间执行拷贝存活对象操作的时候,找不到一个空闲的区间,那么这个时候就可以在GC日志里看到诸如“to-space exhausted”这样的错误日志打印。

发生这个错误的同时,G1 GC会尝试去扩展可用的Java堆内存大小。如果扩展失败,G1 GC会触发它的失败保护机制并且启动单线程的完全回收动作。

在这个完全回收阶段,单线程会针对整个堆内存里的所有区间进行标记、清除、压缩等动作。在完成回收后,堆内存就完全由存活对象填充,并且所有的年龄代对应的区间都已经完成了压缩任务。

也正是因为这个完全回收是单线程执行的,所以当堆内存很大时势必耗时很长,所以需要谨慎使用,最好不要让它经常发生,以避免不必要的长时间的应用程序暂停。

G1 GC使用场景

如果应用程序具有如下的一个或多个特征,那么将垃圾收集器从CMS或ParallelOldGC切换到G1将会大大提升性能:

  • Full GC次数太频繁或者消耗时间太长
  • 对象分配的频率或代数提升(promotion)显著变化。
  • 受够了太长的垃圾回收或内存整理时间(超过0.5~1s)

注意,如果正在使用CMS或ParallelOldGC,而应用程序的垃圾收集停顿时间并不长,那么继续使用现在的垃圾收集器是个好主意。

G1 GC性能优化方案

G1的年轻代回收

External Root Regions

外部根区间扫描指的是从根部开始扫描通过JNI中本地的类中调用Malloc函数分配出的内存。这个步骤是并行任务的第一个任务。这个阶段堆外(off-heap)根节点被开始扫描,这些扫描范围包括JVM系统字典、VM数据结构、JNI线程句柄、硬件注册器、全局变量,以及线程栈根部等,这个过程主要是为了找到并行暂停阶段是否存在指向当前收集集合(CSet)的指针。

这里还有一个情况需要引起大家的重视,就是查看工作线程是否在处理一个单一的根节点时耗时过长,导致感觉类似挂起的现象。这个现象可以通过查看工作线程对应的“termination”日志看出来。如果存在这个现象,你需要去查看是否存在比较大的系统字典(JVM System Dictionary),如果这个系统字典被当成了一个单一根节点进行处理,那么当存在大量的加载类时就会出现较长时间的耗时。

Rememebered Sets and Processed Buffers

Rset帮助维护和跟踪指向G1区间的引用,而这些区间本身拥有这些RSet。还记得我们在第4章介绍过的并行Refinement线程吗?这些线程的任务是扫描更新日志缓存,并且更新区间的RSet。为了更加有效地支援这些Refinement线程的工作,在并行回收阶段,所有未被处理的缓存(已经有日志写在里面了)都会被工作线程拿来处理,这些缓存也被称为日志里面的处理缓存。

为了限制花费在更新RSet上的时间,G1通过选项-XX:MaxGCPauseMills设置了目标暂停时间,采用相对于整个停顿目标时间百分比的方式,限制了更新RSet花费的总时长,让评估暂停阶段把最大量的时候花费在拷贝存活对象上。这个目标时间默认为整个停顿时间的10%,例如整个停顿时间是10s,那么花费在更新RSet上的时间最大为ls。G1 GC的设计目标是让更多的停顿时间花费在拷贝存活对象上面,因此暂停时间的10%被用于更新RSet也是比较合理的,百分比大了,花在干具体业务(各阶段拷贝存活对象)上的时间也就少了。

如果你发现这个值不太准确或者不符合你的实际需求,这里可以通过更新选项-XX:G1RSetUpdatingPauseTimePercent来改变这个更新RSet的目标时间值。切记,如果你改变了花费在更新RSet上的时间,那你必须有把握工作线程可以在回收暂停阶段完成它们的工作,如果不能,那这部分工作会被放到并行Refinement线程里面去执行,这会导致并行工作量增加、并行回收次数增多。最坏的情况是如果并行Refinement线程也不能完成任务,那么Java应用程序就会被暂停,原本负责执行Java应用程序的资源就会直接接手任务,这个画面“太美”不敢看!大家要尽量避免这种情况发生。

注意,-XX:G1ConcRefinementThreads选项的值默认和-XX:ParallelGCThreads的值一样,这意味着对于-XX:ParallelGCThreads选项的修改会同样改变-XX:G1ConcRefinementThreads选项的值。

在当前CSet里面回收之前,CSet内部的每个区间的Rset都需要被扫描,主要目的是找到CSet区间内部的引用关系。一个有较多存活对象的区间容易导致Rset的粒度变细,即每个区间对应的表格会从粗粒度变为细粒度,也可以理解为里面对象增多后扫描一个Rset需要更长的扫描时间,这样你就会看到更多的时间被花费在了扫描RSet上面。也可以理解为扫描时间取决于RSet数据结构的粗细粒度。

Summarizing Remembered Sets

XX:+G1SummarizeRSetStats选项用于统计RSet的密度数量(细粒度或者粗粒度),这个密度帮助决定是否并行Refinement线程有能力去应对更新缓存的工作,并且收集更多关于Nmethods的信息。这个选项每隔n次GC暂停收集一次RSet的统计信息,这个n次由选项-XX:G1SummarizeRSetStatsPeriod=n决定,也是需要通过选项进行设置的。

注意,-XX:+G1SummarizeRSetStats选项是一个诊断选项,因此必须启用-XX:+UnlockDiagnosticVMOptions选项才可以启用-XX:+G1SummarizeRSetStats选项。

PDF书籍下载地址:
https://github.com/jiankunking/books-recommendation/tree/master/Java

 

欢迎关注我的其它发布渠道

 
 
 
原文地址:https://www.cnblogs.com/cx2016/p/13226874.html