JVM (三)- CMS 和 G1

简单版

在 Java 中,CMS(Concurrent Mark-Sweep)和 G1(Garbage-First)都是垃圾收集器(Garbage Collectors),它们在垃圾回收的方式和目标上有明显的区别。

CMS(Concurrent Mark-Sweep)

  • 类型: CMS 是一种低延迟的垃圾收集器,主要用于减少应用程序暂停时间(即 "停顿时间")。
  • 工作原理:
    1. 初始标记(Initial Mark): 标记根对象,通常会导致短暂停顿。
    2. 并发标记(Concurrent Mark): 在不暂停应用程序的情况下,遍历对象图并标记所有可达的对象。
    3. 重新标记(Remark): 再次暂停应用程序,标记在并发标记阶段发生变化的对象,这阶段的暂停时间相对较短。
    4. 并发清理(Concurrent Sweep): 在不暂停应用程序的情况下,清除不可达的对象。
  • 优点:
    • 适用于低延迟的应用,因为它减少了长时间的全停顿。
  • 缺点:
    • 对硬件资源的要求较高,特别是对多核 CPU 的要求。
    • 会产生“浮动垃圾”(Floating Garbage),即在并发清理阶段创建的新垃圾无法立即被清理。
    • 碎片化问题:由于 CMS 不会整理内存,可能导致内存碎片。

G1(Garbage-First)

  • 类型: G1 是一种面向服务器应用程序的垃圾收集器,设计目的是在提供低延迟的同时,提供可预测的停顿时间。
  • 工作原理:
    1. 区域划分(Region-based): G1 将堆内存划分为多个相同大小的区域(Regions),每个区域可以容纳年轻代或老年代的对象。
    2. 垃圾优先(Garbage-First): G1 优先清理那些包含最多垃圾的区域,因此得名“垃圾优先”。
    3. 混合回收(Mixed GC): G1 会在收集年轻代的同时,选择性地收集部分老年代的区域。
    4. 整理内存(Compaction): G1 在清理垃圾时会进行内存整理,减少碎片化问题。
  • 优点:
    • 可预测的停顿时间: G1 提供了停顿时间的预测,可以在一定范围内控制应用的最大停顿时间。
    • 减少碎片化: 通过对内存的整理,G1 可以减少内存碎片问题。
  • 缺点:
    • 在某些场景下,G1 的停顿时间可能仍然不可忽略,尤其是在极高的堆内存使用情况下。

总结

  • CMS 更适合低延迟、对暂停时间敏感的应用程序,但可能面临内存碎片化和较高的硬件需求。
  • G1 是一种通用的垃圾收集器,适合在需要平衡低停顿和吞吐量的应用程序中使用,且在内存管理上比 CMS 更加高效。

    1、算法实现  CMS 基于标记清除算法,G1 基于标记整理算法实现;

   2、停顿时间: CMS 目标是获取最短的停顿时间,但是并发标记阶段占用CPU资源,使得程序变慢

                           G1 可以利用多核多CUPU环境实现可预测停顿

   3、内存碎片:CMS 标记清除算法产生大量的空间碎片,导致老年还有足够空间时,无法找到足够大的连续空间分配对象,从而触发Full GC ;  G1 通过通过独立区域避免了传统标记清除算法的碎片问题

    4、内存上、CMS 将堆分为连续的新生代和连续的老年代,G1 分为2048个区域;

  5、cms 大对象分配到老年代,young gc 无法回收,等到cms gc 才能回收

     G1 大对象直接分配到 Humongous 大对象区域,在 mixed GC 可以回收没有引用的Humongous对象

  6、G1 会比 CMS 使用更多的内存和CPU负载,G1适合大堆的应用;

    

 

 

 

详细版 

一、垃圾回收内存区域

  已知 堆内存 是JVM 管理内存最大的区域,如下图。注,方法区,也需要回收对象。

  

 

  堆内存 分为 新生代(Minor GC), 老年代(Major GC)和 永生代

    新生代(Minor GC)分为 年轻代(Young GEN)

      年轻代(Young GEN)分为, Eden, S0(SurvivorFrom区), S1(SurvivorTo区)

 

二、定义jvm中的内存垃圾

  堆 和 方法区 都需要回收内存垃圾。  

2.1、堆 --- 回收对象的定义方式

  堆有两种方式定义 JVM 中的内存垃圾, 引用计数法 和 可达性分析算法

  引用计数法:就是记录对象是否被其他对象引用,当对象没有被其他对象引用就说明这个对象已经可以作为垃圾进行回收了。

      方式:通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数就减一,当该对象的引用计数为0时,那么该对象就会被回收。   

      缺点:无法解决循环引用(在此想到另外一个问题,spring如何初始化循环依赖的对象的

   可达性分析算法:通过一系列“GC Roots”对象作为起点进行搜索,如果在“GC Roots”和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象不一定会成为可回收对象。进入DEAD状态的线程还可以恢复,GC不会回收它的内存。

      方式:阶段一:第一个阶段是可达性分析,分析该对象是否可达

         阶段二:当对象没有重写finalize()方法或者finalize()方法已经被调用过,虚拟机认为该对象不可以被救活,因此回收该对象。

      GC ROOT对象:(1) 虚拟机栈(栈帧中本地变量表)中引用的对象(2) 方法区中静态属性引用的对象(3) 方法区中常量引用的对象(4) 本地方法栈中Native方法引用的对象。  

2.2、方法区 --- 回收对象的定义方式

  方法区中内存的回收主要是 废弃常量 和 无用的类

  回收废弃常量与回收Java堆中的对象非常相似。以常量池中字面量的回收为例,若字符串“abc”已经进入常量池中,但当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用该字面量,若发生内存回收,且必要的话,该“abc”就会

被系统清理出常量池。常量池中其他的类(接口)、方法、字段的符号引用与此类似。

  判定回收条件

    1、该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;

    2、加载该类的ClassLoader已经被回收;

    3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

 

三、常见的垃圾回收算法

  有三种:Mark-Sweep(标记-清除算法),Copying(复制清除算法)和 Mark-Compact(标记-整理算法)

 

3.1、Mark-Sweep(标记-清除算法)

  实现:标记清除算法分为两个阶段,标记阶段和清除阶段。标记阶段任务是标记出所有需要回收的对象,清除阶段就是清除被标记对象的空间。

  优缺点:实现简单,容易产生内存碎片

3.2、Copying(复制清除算法)

  实现:将可用内存划分为大小相等的两块,每次只使用其中的一块。当进行垃圾回收的时候了,把其中存活对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。

  优缺点:不容易产生内存碎片;可用内存空间少;存活对象多的话,效率低下。

3.3、Mark-Compact(标记-整理算法)

  实现:先标记存活对象,然后把存活对象向一边移动,然后清理掉端边界以外的内存。

  优缺点:不容易产生内存碎片;内存利用率高;存活对象多并且分散的时候,移动次数多,效率低下

3.4、分代收集算法:

  分代收集算法是目前大部分JVM的垃圾收集器所采用的算法)

  分代算法对不同代采用的算法不同。

3.4.1 年轻代(Young Generation)的回收算法 (回收主要以Copying为主)

  1、所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

  2、新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(S0 和 S1)。大部分对象在Eden区中生成。

   当创建对象,向Eden区申请内存时,如果Eden区满了,就进行minor GC。回收时先将eden区存活对象复制到一个survivor0区(SurvivorFrom区),然后清空eden区。

   当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时 survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

  3、当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。

    若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。(Ful GC 触发条件)

  4、新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

3.4.2 年老代(Old Generation)的回收算法(回收主要以Mark-Compact为主)

  1、在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

  2、内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高  

3.4.3 Full GC触发条件:

  1、调用System.gc时,系统建议执行Full GC,但是不必然执行

  2、老年代空间不足

  3、方法区(1.8之后改为元空间)空间不足

  4、创建大对象,比如数组,通过Minor GC后,进入老年代的平均大小大于老年代的可用内存由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

 

四、CMS 和 G1 

  CMS与G1都是并发回收,多线程分阶段回收,只有某阶段会 stw(Stop the World);

 

4.1、CMS垃圾回收器

  CMS只会回收老年代和永久代(1.8开始为元数据区,需要设置CMSClassUnloadingEnabled),不会收集年轻代;年轻带只能配合Parallel New或Serial回收器; CMS是一种预处理垃圾回收器,它不能等到old内存用尽时回收,需要在内存用尽前,完

成回收操作,否则会导致并发回收败;所以CMS垃圾回收器开始执行回收操作,有一个触发阈值,默认是老年代或永久带达到92%;

4.1.1、CMS 处理过程有七个步骤(这也是面试常问的问题,包括哪俩个阶段会STW): 

  1. 初始标记(CMS-initial-mark) ,会导致STW; 初始标记阶段就是标记老年代中的GC ROOT对象和与GC ROOT对象关联的对象给标记出来。

  (Java中Stop-The-World机制简称STW,是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起(除了垃圾收集帮助器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止,native代码可以执行,但不能与JVM交互;

  这些现象多半是由于gc引起)

  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;因为是并发运行的,在运行期间会发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则

些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代; 并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理;

  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;   

  4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;

  5. 重新标记(CMS-remark) ,会导致STW; 这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。 

这个阶段,重新标记的内存范围是整个堆,包含_young_gen和_old_gen。为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做cms

的“gc root”,来扫描老年代;

    因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次ygc,回收掉年轻带的对象无用的对象,并

将对象放入幸存带或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存带非常小,这大大减少了扫描时间 由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,

这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled。来提高这个阶段的效率。

  6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行;通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。 

   这个阶段主要是清除那些没有标记的对象并且回收空间;由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时

再清理掉。这一部分垃圾就称为“浮动垃圾”。

   7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行; 

 

 

4.1.2、CMS垃圾回收器的优化:

 

  1.减少remark阶段停顿

    一般CMS的GC耗时 80%都在remark阶段,如果发现remark阶段停顿时间很长,可以尝试添加该参数:-XX:+CMSScavengeBeforeRemark

    在执行remark操作之前先做一次ygc,目的在于减少ygen对oldgen的无效引用,降低remark时的开销。 

  2.内存碎片 

    CMS是基于标记-清除算法的,只会将标记为为存活的对象删除,并不会移动对象整理内存空间,会造成内存碎片,这时候我们需要用到这个参数;-XX:CMSFullGCsBeforeCompaction=n

  3.promotion failed与解决方法

    过早提升与提升失败 

    在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年          代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步, 如果老年代满了,

    Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。 

    早提升的原因 

    1. Survivor空间太小,容纳不下全部的运行时短生命周期的对象,如果是这个原因,可以尝试将Survivor调大,否则端生命周        期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc; 

    2. 对象太大,Survivor和Eden没有足够大的空间来存放这些大象; 

      提升失败原因 

    当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。 

    为什么是没有足够的连续空间而不是空闲空间呢? 

    老年代容纳不下提升的对象有两种情况: 

    1. 老年代空闲空间不够用了; 

    2. 老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象; 

    解决方法 

    1. 如果是因为内存碎片导致的大对象提升失败,cms需要进行空间整理压缩; 

    2. 如果是因为提升过快导致的,说明Survivor 空闲空间不足,那么可以尝试调大 Survivor; 

    3. 如果是因为老年代空间不够导致的,尝试将CMS触发的阈值调低;

    4.增加线程数

    CMS默认启动的回收线程数目是 (ParallelGCThreads + 3)/4) ,这里的ParallelGCThreads是年轻代的并行收集线程数,感觉        有 点怪怪的; 

    年轻代的并行收集线程数默认是(ncpus <= 8) ? ncpus : 3 + ((ncpus * 5) / 8),可以通过-XX:ParallelGCThreads= N 来调整; 

    如果要直接设定CMS回收线程数,可以通过-XX:ParallelCMSThreads=n,注意这个n不能超过cpu线程数,需要注意的是增加      gc线程数,就会和应用争抢资源;

 

4.2 G1垃圾回收器:

4.2.1 G1垃圾回收器相关数据结构

  1.在HotSpot的实现中,整个堆被划分成2048左右个Region。每个Region的大小在1-32MB之间,具体多大取决于堆的大小。

  2.对于Region来说,它会有一个分代的类型,并且是唯一一个。即,每一个Region,它要么是young的,要么是old的。还有一类十分特殊的Humongous。所谓的Humongous,就是一个对象的大小超过了某一个阈值——HotSpot中是Region的1/2,那

么它会被标记为Humongous。

 

       

 

  每一个分配的Region,都可以分成两个部分,已分配的和未被分配的。它们之间的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。这个做法实际上就是bump-the-pointer。 

        

 

 

  即每一次回收都是回收N个Region。这个N是多少,主要受到G1回收的效率和用户设置的软实时目标有关。每一次的回收,G1会选择可能回收最多垃圾的Region进行回收。与此同时,G1回收器会维护一个空间Region的链表。每次回收之后的Region

都会被加入到这个链表中。每一次都只有一个Region处于被分配的状态中,被称为current region。在多线程的情况下,这会带来并发的问题。G1回收器采用和CMS一样的TLABs的手段。即为每一个线程分配一个Buffer,线程分配内存就在这个Buffer内分

配。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。

3.卡片 Card

  在每个分区内部又被分成了若干个大小为512 Byte卡片(Card),标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查

找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

4.RS(Remember Set)

  RS(Remember Set)是一种抽象概念,用于记录从非收集部分指向收集部分的指针的集合。

在传统的分代垃圾回收算法里面,RS(Remember Set)被用来记录分代之间的指针。在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。这种记录可以带来一个极大的好处:在回收一个Region的

时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。那么,如果一个线程修改了Region内部的引用,就必须要去通知RS,更改其中的记录。为了达到这种目的,G1回收器引入了一种新的结构,

CT(Card Table)——卡表。每一个Region,又被分成了固定大小的若干张卡(Card)。每一张卡,都用一个Byte来记录是否修改过。卡表即这些byte的集合。实际上,如果把RS理解成一个概念模型,那么CT就可以说是RS的一种实现方式。

  在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,

RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。

    

 

 

 

4.2.2 G1垃圾回收器执行步骤:

1、初始标记;

  初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS的值,让下一个阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这一阶段需要停顿线程,但是耗时很短,

2、并发标记;

  并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段时耗时较长,但可与用户程序并发执行。

3、最终标记;

  最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remenbered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set Logs里面,

  最终标记阶段需要把 Remembered Set Logs的数据合并到Remembered Set中,这一阶段需要停顿线程,但是可并行执行。

4、筛选回收

  最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。
 

4.3 CMS和G1的区别

  面试如果把上述的都讲清楚。面试官估计也差不多能换个话题了。如果要继续深入问的话请参考CMS垃圾回收器详解

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 
posted @ 2020-10-30 09:39  抽象Java  阅读(900)  评论(0编辑  收藏  举报