JVM垃圾回收总结

前言

这一篇是对JVM垃圾收集器相关细节知识点的总结,能给调优带来思路,也可以应付JVM垃圾回收相关的面试。前面都是八股文,最后一段围绕PS+PO、CMS、G1对比,列出了比较重要的调优参数和参考,会更具有实践性一些。内容全部来自《深入理解Java虚拟机(第3版)周志明著》。

垃圾收集算法

  • • 标记-复制(Mark Copying)将内存分为相等大小两块,每次在其中一块分配对象,标记后将所有存活对象复制到另一块上面,然后把使用过的空间一次清理掉。优点是没有内存碎片,缺点是浪费一半空间,复制内存开销大,不适用大空间。

  • • 标记-清除(Mark Sweep)标记出垃圾对象,回收其空间。优点是高效,但是会产生内存碎片,可能导致无法分配大对象。

  • • 标记-整理(Mark Compact)标记出垃圾对象,将存活对象往前面空余的空间移动,最后清理边界外的空间。优点是没有内存碎片,连续空间方便分配。缺点是移动对象开销大,回收是变得复杂,停顿时间长。

HotSpot垃圾收集细节

可达性分析算法

即GC(Garbage Collection)回收前查找垃圾对象的方式,从根节点/对象出发遍历其引用的对象,再像遍历树一样逐层扩展搜索所有引用对象标记为存活。其他不可达或者未直接或间接被根对象引用的对象则为垃圾。

根节点(GC Roots)枚举

固定可作为根节点主要是全局引用(如:常量或类静态属性)与执行上下文(如:栈帧中的本地变量表)。但是Java应用过于庞大查找根节点困难,所以HotSpot使用了称为OopMap的数据结构来记录根对象引用。当类加载完成的时候, HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。

安全点(Safe Point)

有了OopMap可以快速的枚举根节点,但是如果引用关系频繁变化不断的修改OopMap信息成本太高,所以只在安全点(Safe point)记录这些信息。当要执行GC的时候所有线程要尽快在安全点停下来,它们设置在方法调用、循环跳转、异常跳转等地方。要GC时设置一个标志位,线程执行到安全点判断这个标志位为真就会挂起。

安全区域(Safe Region)

Sleep或Blocked状态的线程不在运行状态无法执行标志位的判断,但它们会将自己标记处于安全区域,GC时识别到这个标记可以不用额外处理,安全的对它们进行回收。安全区域类似安全点,区域内引用关系不会变化,回收垃圾是安全的。当离开安全区域的时候会判断是否完成了根节点枚举,完成了线程继续往下走,否则必须一直等待。

记忆集(Remembered Set)与卡表(Card Table)

进行YGC(Young GC)时如果老年代有对象引用了新生代对象,那么这些老年代也要加入可达性分析中。但是老年代空间太大,查找这些对象开销大,年轻代空间较小,GC频率较高,如果每次YGC都去找查找这些老年代对象那代价太大了。

记忆集是一种用于记录从非收集区(如上文老年代)域指向收集区域(如上文年轻代)的指针集合的抽象数据结构。记录精度有字长、对象、卡精度,精度越小越准确但是维护成本高。

记忆集像是一种设计思路,而卡表是一种具体实现。卡表使用记录卡精度,代表的是非收集区2的N次幂字节数大小区域(HotSpot中是2的9次幂=512字节)。记录的方式是一个数组,地址位运算右移9位(相当于用地址除以512)对应数组的0、1、2位置。区域有引用指向收集区对象,对应位置设置为1,否则为0。为1的卡表称其变脏(Dirty)。

G1中记录Region之间引用也用到了这种方式。

CARD_TABLE [this address >> 9] = 0

写屏障

写屏障和volatile关键字中的内存屏障不是一个东西。写屏障是用来维护卡表的,就是在引用类型字段赋值前后加上前屏障(Pre-Write Barrier)和后屏障(Post-Write Barrier),类似Spring的AOP。

并发的可达性分析

从上文可以知道从根节点出发可以找出所有存活对象,但是需要线程进入安全点或者安全区域,停顿等待可达性分析完成。但是如果整个堆对象都用这种方式,那虚拟机停顿(STW )时间就会很长,服务在这个时间内是无法提供服务的。

为了避免长时间停顿,引出了并发可达性分析。也就是将可达性分析分为了两阶段初始标记和并发标记。这种方式在CMS和G1收集器中使用。

初始标记从根节点出发找到所有被引用对象,这个阶段是STW(Stop the world);然后开始并发标记阶段,GC线程从这些被引用的对象出发找出后续直接、间接引用对象,这个阶段不需要STW,因为业务线程也在并发执行;最后还有需要STW的重新标记阶段,处理并发标记阶段引用变动问题。

使用三色标记(Tri-color Marking),将对象分为白色(未被引用的垃圾对象)、黑色(已扫描过的存活对象)、灰色三种(本身是存活,但是至少有一个未扫描的引用)。

刚开始所有根节点是黑色、其他对象都是白色,整个扫描完后如果还是白色,那对象则为要被回收的垃圾。

由于是标记过程中业务线程还在运行,那么就会导致标记过的对象引用产生变动导致问题。主要两种情况:①标记为存活的对象(黑色)所有引用断开成为了垃圾对象,这种情况可以忽略,只是逃过了这次收集,等下次回收清理掉就好了。②将成为垃圾的对象(白色)被重新引用了,相当于不是垃圾对象会被回收,这样程序肯定会出问题,暂且称之为“对象消失”。

经过证实,下面两种情况同时出现会导致“对象消失”:插入了一条/多条黑色对象到白色对象的引用,同时删除了全部灰色对象到白色对象的引用。

解决办法有两种:增量更新(Incremental Update)和原始快照(SATB / Snapshot At The Beginning)。

增量更新破坏的是第一个条件,通过写屏障在引用变化的时候将相关黑色对象标记为灰色对象,这样在重新标记阶段会将这个灰色对象重新扫描一次,那引用的白色对象也会被发现,从而存活。CMS使用的是这种。

原始快照破坏的是第二个条件,在开始扫描那一刻生成对象图快照,要删除灰色对象对白色对象引用时,将删除的引用记录下来。并发扫描结束后将这些变动的引用中灰色对象作为根,按快照图重新扫描一次,由于快照图中保存了删除前的引用关系,白色对象会被扫描到并标记为存活。G1使用的是这种。

垃圾收集器

年轻代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial Old、Parallel Old、CMS

G1(Garbage First)、Shenandoah、ZGC(Z Garbage Collector)三种收集器包含两个分代或者没有分代概念。

Serial和Serial Old是单线程,效率低,少用。其他的基本都是多线程。

下图表示各种收集器搭配关系:

JVM垃圾回收总结

PS(Parallel Scavenge)+PO(Parallel Old)

PS的目标是尽可能提高JVM的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),适用于计算密集型不需要太多交互的程序,被称作“吞吐量优先收集器”。整个GC过程都需要STW(Stop The World)。

JVM垃圾回收总结
image-20211108172941928

-XX:MaxGCPauseMillis(最大停顿时间)参数设置一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过这个值。但是设置得越小,系统会把新生代调得小,每次回收空间小了,就不需要太长的时间,但是随着内存使用上涨会导致回收频率变高,最终吞吐量变低。

-XX:GCTimeRatio(垃圾收集时间比率)参数设置是0~100的整数,将垃圾收集时间占比视为1,这个值则代表运行用户代码时间占比。如设置为19,代表垃圾收集时间:代码运行时间=1:19=垃圾收集最大占5%的总时间;默认值为99,代表1:99=垃圾收集最大占1%的总时间。

-XX:+UseAdaptiveSizePolicy(开启自适应调节策略)只需要设置好基本参数(如-Xmx设置最大堆),然后指定最大停顿时间(-XX:MaxGCPauseMillis)或者垃圾收集时间比率(-XX:GCTimeRatio),JVM会动态调整新生代大小(-Xmn)、Eden与Survivor区比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数来实现目标,而不用去指定它们。

PO是PS的老年代版本,一般两者配合使用,是JDK8默认的垃圾收集器。

CMS(Concurrent Mark Sweep)

CMS目标是尽可能缩短GC导致的停顿时间,适合需要快速响应的带来良好交互体验的服务,比如B/S系统,被称作“并发低停顿收集器”,是并发收集器探索里程碑。

CMS是老年代收集器,它在因内存不足收集失败时会用低效的单线程收集器(Serial Old)并在STW状态下执行,能与CMS搭配的年轻代收集器为Serial和ParNew。

收集分为下面几个阶段:

  • • 初始标记(CMS initial mark)从根出发,标记根对象直接引用的对象,单线程执行,需要STW,时间较短

  • • 并发标记(CMS concurrent mark)从根引用的对象出发,标记后续直接、间接引用对象,与用户线程并发执行,时间较长

  • • 重新标记(CMS remark)重新扫描并发标记期间业务线程修改的引用,需要STW,时间较短

  • • 并发清除(CMS concurrent sweep),与业务线程并发执行回收垃圾对象占用的空间

JVM垃圾回收总结
image-20211108173013975

CMS有如下一些问题:

  • • CPU核心数少时,垃圾回收对用户程序影响大。CMS默认启动的回收线程数是(处理器核心数量 +3)/4,核心数小于4时, CMS对用户程序的影响就可能变得很大,比如2核心,那回收线程数为(2+3)/4=1,意味着占用了50%的CPU资源。大于等于4核心只会占用25%或者更小。

  • • 浮动垃圾要到下次GC才能回收。因为并发标记期间用户线程并发执行,如果标记之后产生了新的垃圾对象,CMS无法在当次收集中处理掉,只好留待下一次垃圾收集时再清理。不过只是晚点回收,不会影响服务正确执行。

  • • 并发收集失败(Concurrent Mode Failure)被迫启用低效的单线程回收。因为回收过程基本是与用户线程并发执行,在回收过程中如果预留内存不足以分配新对象,就会出现并发收集失败,被迫启动后备预案,停止用户线程,临时启用Serial Old来进行行老年代回收 (Full GC),效率低导致长时间STW。CMS不像PS-PO或者单线程收集器(Serial Old)等到老年代几乎填满再GC,而是当老年代使用率达到CMS启动占用率(-XX:CMSInitiatingOccupancyFraction)设定的值触发CMS,选项设置1~100。这个值越小,预留空间越大,并发收集失败可能性越小,但是触发CMS会越频繁,值越大刚好相反。

  • • 使用“标记-清除”算法产生内存碎片。产生内存碎片会导致大对象分配不到连续空间触发低效的单线程老年代回收,解决办法是开启内存整理(-XX:+UseCMS-CompactAtFullCollection),让CMS整理内存,将存活对象往前移动,使后面空间连续。但是这个过程无法与用户线程并发执行,导致停顿时间长,所以提供选项-XX:CMSFullGCsBefore-Compaction指定执行过若干次不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次都进行碎片整理)。但这两参数从JDK 9开始废弃。

G1(Garbage First)

JDK 9默认收集器为G1,如果指定使用CMS(-XX:+UseConcMarkSweepGC)会打印未来将被废弃的警告,和CMS一样,低停顿是主要优势之一。

G1收集器物理上不分代,逻辑上分代。将堆内存划分成N个相等大小的Region,按需动态的指定Region属于Eden、Survivor或者老年代中的一种。通过选项-XX:G1HeapRegionSize设定其大小,取值范围为1~32MB,2的N次幂。Region还有一类特殊的Humongous区域,将超过一个Region大小的对象放在连续的N个Humongous Region中。

G1会尽可能的满足指定的最大收集停顿时间(-XX:MaxGCPauseMillis,单位毫秒,默认200),将Region作为回收最小单元,并维护可回收的Region集合(Collection Set,简称CSet),这样就可以有计划地避免每次回收整个堆内存。G1会计算回收能获得的空间大小、所需时间以及过往回收经验值,不再衡量属于哪个分代,而是按用户指定的停顿时间,有计划的回收高优先级、收益大的Region,这也体现了G1具有年轻代和老年代混合回收(Mixed GC)的特点。

最大收集停顿时间一般200毫秒左右比较正常,如果设置得太短(如20毫秒),每次收集时间短,收集的内存小,收集速度赶不上分配速度,最终可能导致被迫执行单线程的Full GC,造成长时间停顿。

G1中每个Region会单独维护哈希表结构的记忆集记录跨Region引用,Key是别的Region的起始地址,Value是一个存储卡表的索引号的集合。这种双指向结构(卡表记录“我指向谁”,这种结构还记录了“谁指向我”,但是我没明白怎么记录指向我的?)实现起来更复杂,同时由于Region数量多,记忆集要占用更多(根据经验判断为10~20%)的内存。

G1也有并发标记过程,并且与用户线程并发执行,这期间:通过SATB来解决引用变动导致的“对象消失”问题;G1为每一个Region设计两个名为TAMS(Top At Mark Start)的指针,划分一部分Region空间来分配新对象,新对象被隐式标记默认存活不纳入回收范围;如果内存回收速度赶不上分配速度,也会像CMS一样被迫使用低效的单线程收集器导致长时间停顿。

G1收集过程大致分为4个步骤:

  • • 初始标记(Initial Marking)标记根直达对象,修改TAMS指针值,让下阶段用户线程并发执行分配新对象,耗时较短,需要STW

  • • 并发标记(Concurrent Marking)从跟直达对象开始递归扫引用对象,与用户线程并发执行,还需要通过STAB记录对象图和有变动的引用,耗时较长

  • • 最终标记(Final Marking)处理STAB记录的引用变动的对象,需要STW,耗时较短

  • • 筛选回收(Live Data Counting and Evacuation)更新Region统计数据,根据回收价值和成本进行排序,根据设置的期望停顿时间来制定回收计划,确定回收的Region集合,将存活对象复制到空的Region中,因为需要移动存活对象,所以必须暂停用户线程(STW),多条收集线程并行完成。

除了并发标记,其他步骤都需要STW。

JVM垃圾回收总结
image-20211110101623534

CMS与G1对比

  • • CMS默认使用的是标记清除算法(可以通过选项指定使用整理算法),而G1使用的是复制清除算法(存活对象复制到空的Region)。

  • • G1更耗内存:G1将内存划分成了N个Region,每个Region都需要维护记忆集合,需要消耗20%甚至更多内存。CMS只需要记录年轻代和老年代之间的跨代引用,就相当简单。

  • • G1执行负载更高:G1和CMS都要通过写后屏障来维护记忆集合记录变动的引用信息,G1记忆集复杂,维护成本更高,CMS使用简单的卡表记录跨代引用维护成本较低。

  • • 在解决并发标记出现的“对象消失”问题上,CMS使用增量更新解决,重新从变灰色的对象出发递归搜索,需要更长的停顿时间;G1使用SATB解决,原始快照减少并发标记和重新标记阶段的消耗,停顿时间更短。

  • • 并发标记期间,CMS和G1都会用写后屏障(引用变量赋值前后)来维护记忆集,为了实现SATB,G1还需要利用写前屏障跟踪并发时引用变化情况。相比起来G1在这一点上需要消耗更多资源,所以将写前后屏障做的事情放到队列,再异步处理;而CMS的写屏障是同步执行。

  • • 从整体看,小内存应用CMS优势更明显,大内存应用G1优势更大,平衡点在6~8GB内存之间。随着JDK 9默认G1建议抛弃CMS,和后续持续对G1的优化,未来还是G1更具优势。


原文始发于微信公众号(我有八千部下):JVM垃圾回收总结

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/102416.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!