JVM学习笔记(十 - 垃圾回收器)

垃圾回收器

GC性能指标

目前还没有哪个垃圾回收器可以完美适配任何的场景,因此针对不同的场景条件使用适合的垃圾回收器是十分重要的。评判一个垃圾回收器的性能是否优良主要有以下几个指标:

  • 吞吐量

    用户线程执行的时间占总运行时间的比例,越大越好吞吐量 = 用户线程执行时间 / (用户线程执行时间 + 垃圾回收时间)

  • 垃圾回收开销

    吞吐量的补数,也就是垃圾回收时间占总运行时间的比例,越小越好

  • 暂停时间

    进行垃圾回收时,程序停止执行的时间,也就是STW时长,越少越好

  • 内存占用

    垃圾回收时要求的内存开销,也就是java堆区所占的内存大小,越小越好

  • 回收及时性

    是否能够及时回收相应的垃圾对象,也就是一个对象从诞生到回收所经历的时长,越及时越好

吞吐量暂停时间是两个最主要的指标

JVM学习笔记(十 - 垃圾回收器)_第1张图片

  • 高吞吐量就意味着充分利用CPU资源,尽可能快地完成任务,比较适用的场景是后台运算而且没有太多的交互,因此常用于服务器端。例如批量处理,订单处理,科学运算等等业务。

  • 低延迟优先就意味着STW时长较短,响应速度较快,尽可能保证用户的体验,比较适用于需要大量交互的场景,多用于客户端。

垃圾回收器的组合关系

JVM学习笔记(十 - 垃圾回收器)_第2张图片

Serial与Serial Old

Serial垃圾回收器是最基本,历史最悠久的,jdk1.3之前回收新生代的唯一选择。Serial是HotSpot的Client模式下新生代的默认回收器。Serial采用的算法是复制算法,而且是单线程串行回收,也就意味着STW。默认搭配老年代的Serial Old回收器,也是单线程串行回收,但采用的是标记-压缩算法,也就意味着更长时间的STW。Serial也可以搭配CMS,Serial Old则作为CMS的后备回收方案,不过到了JDK9就提示Deprecated过期,不建议使用。Serial Old也可以搭配新生代的Parallel Scavenge。

图示

JVM学习笔记(十 - 垃圾回收器)_第3张图片

优点

  • 简单而高效,单线程回收,因此省去了回收线程的切换开销,这个高效是与其他收集器的单线程做对比的,对于一些硬件较为劣势的客户端,比如只有单核CPU,是一个不错的选择。
  • 回收后的内存是规整的。

缺点

  • 由于是单线程串行回收,对比多线程回收来说是比较低效的,假如回收的区域较大,STW的时间就会比较长,可能会严重影响用户体验。
  • 可能会限制硬件的发挥,比如说多核多线程CPU的场景,采用单线程串行回收就显得浪费。

ParNew

ParNew收集器是针对新生代的收集器,可以说是Serial收集器的多线程版本,实际上两者底层也共享了不少的代码,Par是Parallel的简写,New则表明针对的是新生代。它是多线程并行回收的,同样会触发STW,采用的是复制算法。设置的命令是 -XX:+UseParNewGC,默认搭配老年代的CMS收集器,只要设置命令 -XX:+UseConcMarkSweepGC 开启CMS,就会自动搭配ParNew收集器。在JDK9以前,ParNew收集器也可以搭配Serial Old收集器,到了JDK9就被标为过期了。ParNew收集器还可以指定回收的线程数,默认与CPU所支持的保持一致,命令是 -XX:ParallelGCThreads,最好不要超过CPU所支持的线程数,否则需要切换线程,降低效率。

图示

JVM学习笔记(十 - 垃圾回收器)_第4张图片

优点

  • 在多核多线程CPU等硬件优势下,由于采用多线程,可以更加充分地利用CPU资源,加快回收的速度,相应的STW时长就会更短,一定程度上提高吞吐量。

缺点

  • 在单个CPU的环境下,回收的效率可能比不上Serial收集器,因为多了线程切换的开销。

Parallel Scavenge与Parallel Old

Parallel Scavenge也是针对新生代的收集器,同样是多线程并行回收,采用的也是复制算法,回收时也会触发STW。和ParNew收集器不同,Parallel Scavenge的目标是达到可控制的吞吐量,因此也被称为吞吐量优先的收集器。自适应调节策略也是与ParNew收集器的一个重要的区别,指的是可以根据JVM的运行情况动态地调整内存的分配,从而达到提高吞吐量的目的。在JDK6是推出了针对老年代的收集器Parallel Old,用于取代Serial Old,在Parallel Old推出前,Parallel Scavenge是与Serial Old搭配使用的。Parallel Old同样是多线程并行回收,采用的算法是标记-压缩算法,同样触发STW。Parallel Scavenge 与 Parallel Old是相互激活的,也是JDK8默认采用的组合。

CMS(Concurrent Mark Sweep)

CMS收集器是JDK1.5时推出的一款主打低延迟的老年代收集器,具有划时代意义,首次实现了回收线程与用户线程并发执行。它尽可能地缩短STW的时长,提高响应的速度,进而提高用户的体验。采用的算法是标记-清除算法。由于不兼容,无法与Parallel Scavenge搭配使用,只能选择Serial和ParNew。

图示

JVM学习笔记(十 - 垃圾回收器)_第5张图片

  • 初始标记

    这个阶段会暂停所有的用户线程,但仅仅是标记GC Roots的直接可达对象,因此标记的时间一般都比较快,用户线程很快就能恢复执行。

  • 并发标记

    根据初始标记的直接可达对象遍历标记所有的可达对象,一般耗时比较长,但是并不需要STW,而是与用户线程并发执行。

  • 重新标记

    由于并发标记时用户线程也同时执行,因此并发标记的结果不一定准确,因为随着用户线程的执行,很难避免引用关系发生变化。清除的是没有标记的对象,但由于并发执行,可能会出现漏标记,也就是本来没有被引用的对象随着执行可能被引用了,这些对象就不应该被当成垃圾回收,并发标记时尽可能地把怀疑的对象都标记出来,到了重新标记阶段会判断是否为垃圾,以免把错误回收可达对象。

  • 并发清理

    清除标记外的垃圾对象,采用的是标记-清除算法,不用移动存活对象的地址,因此允许与用户线程并发执行,不需要STW,这也是为什么不能采用标记-压缩算法的原因。

注意

  • CMS的垃圾回收是需要提前进行的,当堆空间的使用率达到某个阈值时就会触发执行,JDK1.5及以前是68%,JDK6及以后是92%,也就是不能等内存不足时才触发进行,因为CMS是并发回收的,回收的同时用户线程也在执行,也就意味着在回收的时候还可能会产生对象占用空间,如果产生的速度快于回收的速度,那么就有可能导致回收失败,因此CMS的回收要提前进行,尽可能地在空间耗尽前完成回收。假如CMS回收失败,那么就会使用Serial Old后备方案,暂停所有的用户线程,进行单线程串行的垃圾回收。

  • 在并发清除时,如果有对象晋升到老年代,按理说已经过了标记的阶段,这些对象会被清除,但实际上并不会,这些对象会根据空闲列表找到合适的位置存放,不会被回收。

  • 其实针对内存碎片的问题,CMS允许搭配两个命令参数,在合适的时候自动进行内存的压缩,可以在一定程度上解决内存碎片的问题,其中第一个命令是 -XX:+UseCMSCompactAtFullCollection, 开启在指定的Full GC完成后进行压缩操作,由于压缩整理意味着一些对象位置的移动,所以无法并发执行,因此停顿的时间将会更长。第二个命令是 -XX:CMSFullGCsBeforeCompaction,设置压缩前Full GC执行的次数,也就是执行多少次次Full GC后触发压缩整理操作。

  • 并发回收的线程数也是可以设置的,通过命令 -XX:ParallelCMSThreads 来设置,默认的线程数是**(ParallelGCThreads + 3)/4**,如果设置不当,将会使得CPU资源紧张,进而使得性能变得异常糟糕。

优点

  • 由于初始标记与重新标记阶段耗时较短,并发标记和并发清理又是与用户线程并发执行,所以总体STW的时长较短,实现低延迟。

缺点

  • 采用标记-清除算法,不可避免地会导致内存碎片的问题,需要额外维护空闲列表,而且往后容易触发Full GC,反而会提高延迟。
  • 可能会出现回收失败的情况,需要采用Serial Old后备方案单线程串行回收,也就意味着STW的时长会更长,延迟会更高。
  • 并发回收就意味着需要占用一部分CPU资源,那么就会降低吞吐量。
  • 无法清除浮动的垃圾,在并发清除阶段,用户线程可能会产生新的垃圾对象,也就是本来不是垃圾的对象随着程序执行变成了垃圾,当已经过了重新标记的阶段,只能等待下次回收,无法及时清除。

G1(Garbage First)

G1收集器是兼顾新生代和老年代的收集器,采用的回收思想是分区回收。G1收集器的目标是在延迟可控的情况下尽可能地提高吞吐量。它允许设置一个停顿的时间,然后每次都尽可能地在指定的时间内完成部分回收的任务,只能是大概率实现,不能百分百保证在指定的时间内完成。它把堆空间打散成一个个Region区域,每个Region只能充当一个角色。回收时会根据每个Region的回收价值来决定回收的顺序,所谓的垃圾回收价值就是值回收后能获得的空间和垃圾回收的时间开销之间的平衡,回收后获得的空间大而回收的时间又不算大的就称为回收价值大,G1会优先回收价值大的Region,因此命名为Garbage First的原因是垃圾优先。采用的算法是复制算法。JDK9的时候成为了默认的垃圾回收器。实际上Oracle官方透露,在G1的回收阶段是有考虑过与用户线程并发执行的,但是实现起来比较复杂,其次G1每次只回收一部分的Region,停顿的时间是用户可控的,所以没有必要急着去实现,而是把这个想法寄托到ZGC收集器当中。

注意

由于G1的回收存在并发的情况,因此也是需要提前触发执行的,可以通过命令 -XX:InitiatingHeapOccupancyPercent 来设置并发GC的周期的java堆占用率阈值,默认是45%。虽然G1允许设置一个最大的停顿时间,但并不是百分百能实现,JVM会尽可能地实现,也就是大概率实现,可以通过命令 -XX:MaxGCPauseMillis 来设置最大停顿时间,默认是200ms。Region的大小是可以设置的,但是设置的值比较有讲究,只能是2的幂,而且范围是1MB~32MB之间,也就是1,2,4,8,16,32。可以通过命令 -XX:G1HeapRegionSize 来设置,目标是根据最小的java堆大小划分出约2048个区域。默认的Region大小是堆内存的1/2000

优点

  • 并行与并发

    • G1在回收期间可以由多个GC线程并行执行,充分地利用CPU的资源,此时会触发STW。
    • G1在某些回收的阶段是与用户线程并发执行的,因此一般不会在整个回收阶段都停顿所有的用户线程。
  • 分代收集

    虽然G1收集器采用的是分区回收的做法,但是每个分区每次只能充当一个角色,可以是伊甸园区,可以是幸存者区,也可以是老年区,但不能既是伊甸园区又是幸存者区,因此一定程度上G1也是属于分代收集的垃圾收集器,进而兼顾新生代和老年代的回收。

  • 空间整合

    G1的回收以Region为单位,Region之间采用复制算法,整体上又可看作标记-压缩算法,因此很好的解决了内存碎片的问题,而且堆的空间越大,G1的优势更明显。

  • 可预测的停顿时间模型

    G1允许指定在一个长度为M毫秒的时间片段内,消耗在垃圾回收的时间不超过N毫秒,而且由于以Region为单位进行回收,只会选取部分区域进行回收,优先回收价值大的Region,在有限的时间内尽可能地提高了回收的效率,从而使得全局的停顿得到很好的控制。虽然与CMS对比,最好的情况下的延迟可能没有CMS的好,但最差的情况要比CMS好的多。

缺点

  • G1需要额外占用10%~20%的空间来维护一个记忆集,因此空间上的开销要大一点,在小内存的环境下难以发挥优势,因为每次都只回收部分的分区,如果内存小,那么可能会来不及回收。

  • G1在回收期间的写屏障会消耗相应的性能,会给程序的执行造成一定的负担。

Region

使用G1收集器时,它会把堆空间划分成约2048个大小相同的Region块,每个Region的大小由堆空间来决定,整体控制在1MB~32MB,而且必须是2的N次幂,也就是1MB,2MB,4MB,8MB,16MB,32MB,而且在JVM的生命周期内不会发生变化。每个Region一次只能充当一个角色,比如说不能一半是伊甸园区,一半是幸存者区,但是可以充当任意的角色,也就是允许被覆盖为别的角色。如下图,G1还额外增加了一种新的内存区域,叫做Humongous内存区,主要用于存放大对象,如果超过了1.5个Region区,那么就会存放到H区。考虑到大对象在默认情况下会被分配到老年代,而老年代的回收频率不高,假如一些生命周期较短的大对象也存放到老年代,那么对垃圾回收来说就相当的不理想,因此G1才增设了H区,用于存放这些对象。假如一个H区也存不下,那么就会寻找一片连续的H区去存放。为了解决线程同步的问题,Region内部同样有TLAB。

JVM学习笔记(十 - 垃圾回收器)_第6张图片

记忆集(Remember Set) 与 写屏障(Write Barrier)

由于G1是以Region为单位来进行回收的,Region之间免不了会存在一些引用关系,而这些Region可能不是同一个区域,那么在标记的时候就要考虑这些引用关系了,比如说要回收某个新生代的Region,而另外的老年代Region存在对该区的引用,常规做法是把老年代的也遍历标记了,但这样的效率是十分低下的,因此G1额外开销10%~20%的空间来创建各个Region的记忆集,就是用于记录这些外部Region的引用关系,那么就不需要额外遍历了。具体的实现是每次出现引用类型数据的写操作时,都会产生一个写屏障的暂时中断操作,它会先判断这个即将写入的引用是否来自不同类型的Region,如果不同那么就会用卡表CardTable的方式记录在记忆集中,在标记时就会把记忆集中的引用也加入到GC Roots中,这样就能更好地避免遗漏,而且省去额外的遍历开销。

回收过程

JVM学习笔记(十 - 垃圾回收器)_第7张图片

新生代GC

  • 第一阶段:扫描根

    扫描根节点,此时的记忆集也会加入充当根节点。

  • 第二阶段:更新记忆集

    处理脏卡队列dirty card queue中的card,更新记忆集,保证记忆集的准确性。当应用程序出现引用赋值语句如object.field = object时,JVM会在此操作的前后执行特殊的操作,它会在脏卡队列中保存一个该对象引用信息的card,当新生代回收的时候就会根据这个脏卡队列来更新记忆集。为什么不直接保存到记忆集呢?主要是考虑到记忆集的同步访问的问题,如果直接保存到记忆集,就会出现额外的线程同步开销,性能没那么理想。

  • 第三阶段:处理记忆集

    处理记忆集中的引用对象,标记对应的存活对象。

  • 第四阶段:复制对象

    把伊甸园区的存活对象复制到幸存者区,然后该晋升到老年代的也跟着晋升。

  • 第五阶段:处理引用

    处理软引用,弱引用,虚引用等引用,最终清空伊甸园区,然后相应的会有一个链表用于记录空闲的Region。

老年代并发标记

  • 初始标记阶段

    标记从根节点直接可达的对象,会触发STW,并且会触发新生代GC。

  • 根区域扫描

    G1扫描幸存者区直接可达的老年代区域对象,并做标记,而且这个过程要在YGC之前,因为YGC会操作幸存者区的对象。

  • 并发标记

    在整个堆中进行并发标记,这个过程可能会被YGC中断,在标记阶段如果发现一个Region全是垃圾,那么就会直接回收。标记的同时还会计算每个Region的对象活性,也就是存活对象所占比例,为后续计算回收价值作准备。

  • 再次标记

    并发标记的结果可能不够准确,需要修正。

  • 独占清理

    这个阶段并不会去进行实际的垃圾回收,而是计算各个区域的存活对象与GC回收的比例,也就是回收价值,并进行排序,为下个阶段作铺垫,整个过程是STW的。

  • 并发清理

    并发清理,不会STW。

混合回收

随着程序执行,越来越多的对象晋升到老年代,为了避免空间耗尽触发Full GC,G1会进行混合回收,在回收整个新生代的同时还会回收部分的老年代。

  • 并发标记结束后,百分百为垃圾的老年代Region已经被回收,而部分为垃圾的则默认会分8次回收。
  • 由于老年代中的内存分段默认分8次回收,因此会优先回收垃圾多的内存分段,而且并不是所有的内存分段都会被回收,存活对象占比大的内存分段是不会进行回收的,因为采用的是复制算法,存活对象多的话影响性能。这个回收条件由一个阈值来决定是否需要回收,命令是 -XX:G1MixedGCLiveThresholdPercent ,默认值是65%,也就是垃圾占比达到65%才会回收。
  • 混合回收并不一定会进行8次,存在一个阈值 -XX:G1HeapWastePercent ,默认是10%,意思是允许有10%的堆空间被浪费,也就是如果发现垃圾占堆空间的比例低于10%,就不再进行混合回收了,因为混合回收的时间相对较长,如果回收后得到的空闲空间不多,那么就不值得了。

你可能感兴趣的:(JVM学习笔记,java,jvm,其他)