垃圾回收算法-通用的分代垃圾回收机制

垃圾回收算法-通用的分代垃圾回收机制

    概要

    分代垃圾回收机制,是基于这样一个事实:不同对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。

    一、判断对象是否可回收

    首先思考一个问题,内存堆中那么多对象,回收器要回收哪些对象?怎么判断出这些要回收的对象呢?因此对于垃圾回收,判断并标识对象是否可回收是第一步。从理论层面来说,判断对象是否可回收一般两种方法。

    1. 引用计数器算法

    每当对象被引用一次计数器加 1,对象失去引用计数器减 1,计数器为 0 是就可以判断对象死亡了。这种算法简单高效,但是对于循环引用或其他复杂情况,需要更多额外的开销,因此 Java 几乎不使用该算法。

    2. 根搜索算法-可达性分析算法

    所谓可达性分析是指,顺着 GCRoots 根一直向下搜索,整个搜索的过程就构成了一条“引用链”,只要在引用链上的对象叫做可达,在引用链之外的(说明跟 GCRoots 没有任何关系)叫不可达,不可达的对象就可以判断为可回收的对象。 哪些对象可作为 GCRoots 对象呢? 包括如下:

   1)虚拟机栈帧上本地变量表中的引用对象(方法参数、局部变量、临时变量)

   2)方法区中的静态属性引用类型对象、常量引用对象

   3)本地方法栈中的引用对象(Native 方法的引用对象) 

   4)Java 虚拟机内部的引用对象,如异常对象、系统类加载器等

   5)所有被同步锁(synchronize)持有的对象

   6)Java 虚拟机内部情况的注册回调、本地缓存等

   GCRoots可达性分析,如下图:

   二、堆空间的基本结构

   Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是堆内存中对象的分配与回收。

   Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。

  从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。

    在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

    1) 新生代(Young Generation)

    2)老年代(Old Generation)

    3)永久代(Permanent Generation)

    下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

    

    说明:JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存。

    三、分代垃圾回收

    垃圾收集器将堆做了进一步划分,但具体的划分方式取决于所使用的垃圾收集器。大多数垃圾收集器(如 Serial GC、Parallel GC、CMS)会将堆内存划分为不同的代,主要是新生代(Young Generation)和老年代(Old Generation)。

    堆空间结构如下图: 

 

     1. 新生代

     所有新生成的对象首先都是放在Eden区。新生代的目标就是尽可能快速地收集到那些生命周期短的对象,对应的是Minor GC,每次Minor GC会清理新生代的内存。算法采用效率较高的复制算法,频发的操作,但是会浪费内存空间,当“新生代”区域存放满对象后,就将对象存放到老年代区域。

     1) 空间比例

    新生代使用的垃圾回收算法是复制算法,所以新生代又被分为了 Eden 和Survivor;空间大小比例默认为8:2

    Survivor又被分为了S0、S1,这两个的空间大小比例为1:1

     2) 为什么 Survivor 分区是 2 个

    如果 Survivor 分区有 2 个分区,我们就可以把 Eden、From Survivor、To Survivor 分区内存比例设置为 8:1:1 ,那么任何时候新生代内存的利用率都为90% ,这样空间利用率基本是符合预期的。再者就是虚拟机的大部分对象都符合“朝生夕死”的特性,所以每次新对象的产生都在空间占比比较大的 Eden 区,垃圾回收之后再把存活的对象方法存入 Survivor 区,如果是 Survivor 区存活的对象,那么“年龄”就 +1 ,当年龄增长到 15 (可通过 -XX:+MaxTenuringThreshold 设定)对象就升级到老生代。

   2.  老年代

   在新生代中经历了N(默认值为15)次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。老年代对象越来越多,我们就需要启动Major GC和Full GC(全量回收),来一次大扫除,全面清理新生代区域和老年代区域。

   3. 永久代

   用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响。JDK7以前就是“方法区”的一种实现。

  JDK8 以后已经没有"永久代”了,使用metaspace元数据空间和堆替代。

   四、垃圾收集算法

   不同的垃圾回收算法都有各自的优缺点,适应于不同的垃圾回收场景

   1.  标记-清除算法(Mark-Sweep)

   根据名称就可以理解改算法分为两个阶段:首先标记出所有需要被回收的对象,然后对标记的对象进行统一清除,清空对象所占用的内存区域,下图展示了回收前与回收后内存区域的对比,红色的表示可回收对象,橙色表示不可回收对象,白色表示内存空白区域。

   它是最基础的收集算法,后续的算法都是对其不足进行改进得到。

   这种垃圾收集算法会带来两个明显的问题:

   1) 效率问题:标记和清除两个过程效率都不高。试想一下如果堆中大部分的对象都可回收的,收集器要执行大量的标记、收集操作。

   2)空间问题:标记清除后会产生大量不连续的内存碎片。当有大对象要分配而找不到满足大小的空间时,要触发下一次垃圾收集。

   如下图: 

      

   2. 标记-复制算法(Mark-Copying)

   针对标记-清除算法执行效率与内存碎片的缺点,计算机科学家又提出了一种“半复制区域”的算法。

   标记-复制算法将内存分为大小相同的两个区域:运行区域和预留区域。所有创建的新对象都分配到运行区域,当运行区域内存不够时,将运作区域中存活对象全部复制到预留区域,然后再清空整个运行区域内存,这时两块区域的角色也发生了变化,每次存活的对象就像皮球一下在运行区域与预留区域踢来踢出,而垃圾对象会随着整个区域内存的清空而释放掉。

   如下图:

 

 

     标记-复制算法改进了标记-清除算法,在大量垃圾对象的情况下,只需复制少量的存活对象,并且不会产生内存碎片问题,新内存的分配只需要移动堆顶指针顺序分配即可,很好的兼顾了效率与内存碎片的问题。但依然存在下面这些问题:

    1)  可用内存变小:可用内存缩小为原来的一半,空间浪费有点多。

    2) 不适合老年代:如果存活对象数量比较大,复制性能会变得很差:比如内存中大量对象是存活状态,只有少量的垃圾对象,收集器要执行更多次的复制操作才能释放少量的内存空间,得不偿失。

    3. 标记-整理算法(Mark-Compact)

    标记-复制算法要浪费一半内存空间,且在大多数状态为存活状态时使用效率会很低,针对这一情况计算机科学家又提出了一种新的算法“标记-整理算法”。标记整理算法的标记阶段与其他算法一样,但是在整理阶段,算法将存活的对象向内存空间的一端移动,然后将存活对象边界以外的空间全部清空,如下图所示:

   标记整理算法解决了内存碎片问题,也不存在空间的浪费问题。但是,当内存中存活对象多,并且都是一些微小对象,而垃圾对象少时,要移动大量的存活对象才能换取少量的内存空间。   

   缺点:由于多了整理这一步,因此效率也不高,适合老年这种垃圾回收频率不是很高的场景。

   四、针对不同分代的垃圾收集

   1.  Minor GC(也称Young GC)

   用于清理新生代区域。

   1) 对象优先在Eden区进行分配,如果Eden区满了之后会触发一次Minor GC

   2)Minor GC之后从Eden存活下来的对象将会被移动到S0区域,当S0内存满了之后又会被触发一次Minor GC,S0区存活下来的对象会被移动到S1区,S0区空闲;S1满了之后在Minor GC,存活下来的再次移动到S0区,S1区空闲,这样反反复复GC,每GC一次,对象的年龄就涨一岁,默认达到15岁之后就会进入老年代。

   对于晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 设置

   3)在Minor GC之后需要发送晋升到老年代的对象没有空间安置,那么就会触发Full GC (这步非绝对,视垃圾回收器决定)

   4)采用垃圾收集算法:标记-复制算法

   5)在JVM的垃圾收集机制中,为什么对象从新生代晋升到老年代的年龄上限通常是 15

   - 标记位限制:

    HotSpot JVM使用一个对象头中的一个4位的字段(age)来存储对象的年龄。

    4位可以表示的最大值是15(即2^4-1 )。

   - 性能权衡:

   新生代的垃圾收集(Minor GC)通常采用的是复制算法(Copying),它的速度很快。

   大多数对象在新生代中很快就会被回收,少部分长寿命对象才会晋升到老年代。

   将年龄上限设定为15,可以在新生代中进行多次Minor GC,从而在对象晋升到老年代之前,有更多的机会被回收。

   如果年龄上限设置得过高,会导致更多长寿命对象停留在新生代,增加新生代的垃圾收集开销;而如果设置得过低,可能会导致老年代过早充满,增加Full GC的频率。

   - 经验与优化

   经过多年的实践和调优,15 这个值被认为在大多数应用场景下是合理和有效的。

   它在保证新生代垃圾收集效率的同时,也能够有效地将长寿命对象晋升到老年代,从而平衡了不同代之间的垃圾收集压力

   2. Major GC

   用于清理老年代区域。

   触发条件:Major GC的触发条件通常是老年代的内存空间不足,需要进行扩容,或者是根据一定的策略和阈值来触发。

   采用垃圾收集算法:标记-清除或标记-整理算法

   3. Full GC

   用于清理新生代、老年代区域。Full GC 通常会导致应用的暂停时间更长,成本较高,会对系统性能产生影响。

   触发条件:Full GC的触发条件通常是整个堆内存空间不足,或者是由于某些特定的垃圾收集器算法需要执行Full GC操作。但是,在某些情况下,例如老年代内存不足,也会触发Full GC。

   4. 总结

   Eden区:存储了从未通过垃圾回收的新对象

   Survivor区:存放垃圾回收后,仍然有用的对象。循环存放,小于15次垃圾回收次数。

   Tenured区:老年代区域中存放在新生代区域中经历了15次垃圾回收后仍存活的对象。

   五、什么是分配担保机制?

   分配担保(Allocation Guarantee)是指在进行Minor GC之前,JVM会检查老年代是否有足够的空间来存放Survivor区和Eden区中所有存活的对象。

   这个机制确保即使在最坏的情况下,即Survivor区和Eden区中的所有对象都需要移动到老年代,老年代也有足够的空间来容纳这些对象

   为什么说通过分配担保机制直接进入老年代是安全的?

   1.  防止空间不足导致程序崩溃

   在进行Minor GC时,如果Survivor区没有足够的空间来存放Eden区和另一个Survivor区中的所有存活对象,这些对象将被移动到老年代。如果没有分配担保机制,老年代没有足够的空间容纳这些对象时,程序可能会崩溃或者抛出OutOfMemoryError异常。

   2.  保证GC的成功

   分配担保机制确保在进行Minor GC时,不会因为内存不足而导致GC失败。JVM在进行Minor GC之前,会先检查老年代的空间,如果发现老年代空间不足,它会跳过Minor GC,直接触发Full GC(Full Garbage Collection)。Full GC会进行更彻底的垃圾回收,包括对老年代的回收,从而释放更多的空间。

   3.  提升性能和稳定性

   通过分配担保机制,JVM能够在Minor GC时更高效地处理内存分配和回收,避免频繁的Full GC,从而提升应用程序的性能和稳定性。

   六、JVM调优和Full GC

   在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:

   1.   年老代(Tenured)被写满

   2.  永久代(Perm)被写满

   3.  System.gc()被显式调用(通知虚拟机调用Full GC,相当于建议,能不能被调用由系统决定)

   4. 上一次GC之后Heap的各域分配策略动态变化

   七、垃圾收集器

   垃圾收集算法就像是 Java 中的接口一样,而垃圾收集器是接口的具体实现。所以,不同的厂商,不同版本的虚拟机实现的方式都有所不同。甚至是很大的差别。

   下图是常见的 HotSpot 虚拟机中的垃圾收集器:

 

   其中,新生代有 Serial、ParNew、Parallel Scavenge,老年代包括 CMS、MSC、Parallel old,收集器之间的连线说明两者可以搭配使用。

   垃圾收集器的选择和工作方式通常依赖于堆的分代模型,以下是常见的垃圾收集器在新生代和老年代的分工情况:

   1. Serial 垃圾收集器

   Serial 是最基本,历史最悠久,也是最简单的一个收集器。它是一个单线程的收集器。

   新生代:使用 Serial GC,单线程执行,回收新生代中的垃圾。

   老年代:使用 Serial Old GC,Serial Old 收集器是 Serial 收集器的老年代版本,同样是单线程的,使用的是”标记-整理算法“。

   适用场景:适用于单处理器的环境,或者内存较小、应用简单的场景。

   配置:-XX:+UseSerialGC

   2. Parallel Scavenge 垃圾收集器

   新生代:使用 Parallel GC,多线程回收,称为 Parallel Scavenge 或 Minor GC,目标是通过多线程提高回收效率。

   老年代:使用 Parallel Old GC,多线程回收老年代的对象。同样,Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法”。需要注意的是,如果老年代使用 Parallel Old 那么新生代就只能使用 Parallel Scavenge 与之配合。

   适用场景:追求高吞吐量的应用,如批处理和后台任务。

   配置:-XX:+UseParallelGC(开启年轻代并行回收),-XX:+UseParallelOldGC(开启老年代并行回收)

    3. CMS(Concurrent Mark-Sweep)垃圾收集器

   新生代:使用 Parallel Scavenge,多线程并行回收新生代中的对象。

   老年代:使用 CMS 垃圾收集器,主要通过并发标记和清理来回收老年代对象,减少长时间的暂停。

   适用场景:对延迟敏感的场景,适合响应时间优先的应用(如 Web 应用服务器)。

   配置:-XX:+UseConcMarkSweepGC

   4. G1(Garbage First)垃圾收集器

   新生代和老年代:G1 GC 打破了传统的分代垃圾收集模式,将堆内存划分为多个Region,这些 Region 可以动态分配给新生代和老年代。G1 通过并发标记和回收来优先处理垃圾最多的区域,并能够同时处理新生代和老年代中的对象。G1 会在执行 Young GC 时专门处理新生代区域,在Mixed GC时同时回收老年代和新生代区域。

   适用场景:适合大内存、低延迟的应用场景,尤其是服务器端应用

   配置:-XX:+UseG1GC

   5. ZGC(Z Garbage Collector)

   新生代和老年代:ZGC 和 G1 类似,打破了传统的分代结构,基于 Region 管理整个堆内存,采用并发标记和压缩技术,实现低延迟(目标是将停顿时间控制在 10ms 内)。

   适用场景:非常适合超大内存和对延迟极其敏感的场景

   配置:-XX:+UseZGC

   6. Shenandoah 垃圾收集器

   新生代和老年代:Shenandoah 同样不使用传统的分代模式,采用 Region 划分堆内存,针对整个堆内存进行并发标记、回收,旨在实现极低的停顿时间。
   适用场景:与 ZGC 类似,适合对低延迟有极高要求的场景。

   配置:-XX:+UseShenandoahGC

   7. Epsilon 垃圾收集器

   新生代和老年代:Epsilon 是一个“无操作”的垃圾收集器,不进行垃圾回收,因此不会区分新生代和老年代。这主要用于性能基准测试

   适用场景:测试或调试,不适用于生产环境。

   配置:-XX:+UseEpsilonGC

   总结:

   Serial GC(新生代/老年代)

  •    新生代:Serial GC(单线程)
  •    老年代:Serial Old GC(单线程)

   Parallel GC(新生代/老年代)

  •    新生代:Parallel Scavenge(多线程)
  •    老年代:Parallel Old GC(多线程)

   CMS GC(新生代/老年代):

  •    新生代:Parallel Scavenge(多线程)
  •    老年代:CMS(并发标记清理)

   G1 GC:管理新生代和老年代,通过区域划分实现并发回收,适合大内存、低延迟的应用场景,尤其是服务器端应用。

   ZGC 和 Shenandoah:打破分代模式,通过区域划分并发回收,适合超大内存和低延迟场景。

  选择合适的垃圾收集器,取决于应用的特性,如响应时间、吞吐量和可用内存大小。

 

   参考链接:

   https://javaguide.cn/java/jvm/jvm-garbage-collection.html

   https://xie.infoq.cn/article/9d4830f6c0c1e2df0753f9858

   https://juejin.cn/post/7034487823386279966

posted @ 2023-12-30 17:12  欢乐豆123  阅读(58)  评论(0编辑  收藏  举报