十四.吊打面试官系列-JVM优化-JVM垃圾回算法详解

前言

说到JVM不可避免的会聊到垃圾回收器,(Garbage Collection,简称GC)。它负责跟踪哪些对象仍然在使用,哪些对象已经不再被引用,并释放那些不再被引用的对象所占用的内存空间。这一过程涉及到对象的标记、清除、压缩等多个阶段,每个阶段都有其特定的算法和策略。

随着Java技术的不断发展,JVM的垃圾回收机制也在不断地优化和完善。从早期的串行回收器到并行回收器,再到现代的CMS(Concurrent Mark-Sweep)回收器和G1(Garbage-First)回收器,每一次的演进都带来了性能上的提升和特性上的丰富。

然而,尽管JVM的垃圾回收机制已经相当成熟和高效,但在某些特定的场景下,我们仍然需要对其进行调优和优化,以满足特定的性能需求。这要求我们深入理解JVM的垃圾回收机制,熟悉各种回收器的特点和使用场景,并能够根据实际情况选择合适的回收器和配置参数。

本文旨在为读者提供一个关于JVM垃圾回收机制的全面介绍。我们将从垃圾回收的基本概念入手,逐步深入到JVM中的垃圾回收算法、回收器以及调优策略等方面。希望通过本文的学习,能让你够对JVM的垃圾回收机制有一个清晰的认识,并能够在实际开发中灵活运用相关知识,提升程序的性能和稳定性

一.垃圾标记算法

在JVM中,如果堆中的对象不再被任何变量引用被视为垃圾,我们通常通过:User u = new User() 来创建对象,不考虑栈中分配对象(逃逸分析)的情况变量u在栈中,User实例在堆中分配内存,随着线程执行,方法结束,栈帧销毁u也会跟着释放,那么堆中的User对象就变成了无引用的对象,也就是垃圾对象,等待被垃圾回收器回收。
在这里插入图片描述
要回收垃圾就必须要标记哪些对象是垃圾对象 ,就有了垃圾标记算法,JVM的垃圾标记算法主要用于确定哪些对象在内存中不再被引用,从而可以被垃圾回收器回收。以下是JVM中两种主要的垃圾标记算法:

1.引用计数算法:

原理:为每个对象分配一个计数器,当对象被引用时,计数器加一;当引用被删除或超出作用域时,计数器减一。当计数器的值为零时,表示该对象不再被引用,可以被垃圾回收。

  • 优点:实现简单,效率高。
  • 缺点:无法解决循环引用的问题。例如,对象A引用对象B,对象B又引用对象A,即使这两个对象都不再被其他对象引用,但由于它们相互引用,导致计数器不会归零,从而无法被垃圾回收。

在这里插入图片描述

2.可达性分析算法:

原理:从一系列称为“根”(GC Roots)的对象开始,向下搜索这些对象引用的对象,搜索所经过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时(即从GC Roots到这个对象不可达),则证明此对象是不可用的,可以被垃圾回收器回收。
根对象:通常包括虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(即一般说的Native方法)引用的对象。

  • 优点:能够解决循环引用的问题,是JVM中主流的垃圾标记算法。
  • 缺点:需要遍历整个对象图,如果对象图很大,可能会带来较大的性能开销。但在实际应用中,可以通过增量标记、标记-清除、标记-整理等优化策略来减少性能开销。

在这里插入图片描述

finalize方法

当对象被标记为垃圾意味着被判了死刑,但是其实他们并不是必死无疑,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除)

如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做.

总结来说,JVM主要使用可达性分析算法来标记垃圾对象,而引用计数算法由于其无法解决循环引用的问题,在JVM中并不常用。在垃圾回收过程中,JVM会结合具体的垃圾回收算法(如标记-清除、标记-整理、复制等)来高效地回收不再被引用的对象所占用的内存空间。

二.垃圾回收算法

标记出了垃圾后就可以回收垃圾了,根据不同的应用场景和JVM演变,目前常见的JVM垃圾回收算法主要有以下几种:标记-清除算法、复制算法、标记-整理算法,分代回收算法。严格来说分代回收算法并不是一种回收算法,它只是建议在不同的区域使用不同的回收算法而已。

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

分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图

  • 标记阶段:遍历内存区域,对需要回收的对象打上标记。
  • 清除阶段:再次遍历内存,对已经标记过的内存进行回收。

在这里插入图片描述
标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。

  • 效率问题:遍历了两次内存空间(第一次标记,第二次清除)。
  • 空间问题:容易产生大量内存碎片(一个萝卜一个坑),当再需要一块比较大的内存时,可能找不到满足要求的连续内存块。

2.复制(Copying)算法

把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。
在这里插入图片描述
复制算法的优点是:解决了内存的碎片化问题 ,效率更高(清理内存时,记住首尾地址,一次性抹掉)。缺点也比较明显:内存利用率不高,每次只能使用一半内存。

新生代通常多用复制算法,新生代中的对象大都是“朝生夕死”的,因此新生代内存通常按照8:1:1的比例划分为Eden区、To Survivor区和From Survivor区。这种划分可以大大提高内存利用率。

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

标记整理可以看做是对“标记-清除”算法的一种优化。标记阶段与“标记-清除”算法相同。在清除阶段,会将存活的对象都向一端移动,然后直接清理掉边界以外的内存。
在这里插入图片描述
标记整理的优点是:解决了“标记-清除”算法中内存碎片过多的问题。同时也不存在复制算法中内存利用率不高的问题。

4.分代收集(Generational Collection)算法

把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此新生代采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收

  • Eden区:新创建的对象主要在这里分配。
  • Survivor区(S0和S1):用于存放从Eden区晋升的对象。
  • 老年代:存放长时间存活的对象

在这里插入图片描述
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或者“标记-整理”算法来进行回收。

三.垃圾回收流程

1.STW机制(Stop-The-World)

STW指的是在垃圾回收算法执行过程中,将JVM(Java虚拟机)内存冻结,使得应用程序进入停顿的一种状态。在这个状态下,除了垃圾回收线程外,所有的Java线程都会停止执行。这一过程是为了确保垃圾回收算法能够在一个一致性的快照中进行分析,防止在分析过程中对象引用关系发生变化,从而保证分析结果的准确性。

为什么要这样设计STW机制呢?

  1. 保证一致性:垃圾回收算法需要在整个分析期间确保系统的一致性,即系统看起来像是被冻结在某个时间点上。如果允许线程继续执行,对象引用关系可能会发生变化,导致分析结果不准确。
  2. 防止无止境的内存增长:通过暂停所有线程,垃圾回收算法可以确保在收集过程中不会有新的对象被创建,从而防止内存持续增长。

STW它保证了垃圾回收的正确性和效率。然而,频繁的STW会导致用户体验下降,因此现代垃圾回收器都致力于减少STW的时长和频率。例如,CMS(Concurrent Mark-Sweep)等响应优先的垃圾回收器就是为了减少STW的时长而设计的。它们通过并发执行和标记清除算法,将耗时长的阶段与用户线程并发执行,从而减少了STW的时间,提高了用户体验。因此,STW虽然不可避免,但我们可以通过优化垃圾回收算法来减少其影响。

2.MinorGC&FullGC

MinorGC(新生代垃圾回收)和FullGC(完全垃圾回收)是Java虚拟机(JVM)中垃圾回收(GC)的两种主要类型,MinorGC针对新生代进行回收,而FullGC则对整个堆内存进行回收。以下是关于它们的详细解释:

MinorGC(新生代垃圾回收)

MinorGC是针对JVM中新生代(Young Generation)的内存区域进行的垃圾回收。新生代通常包括Eden区和两个Survivor区(FromSpace和ToSpace)。

触发条件:当新生代中的Eden区空间不足时,会触发MinorGC。此时,JVM会暂停所有用户线程(即STW),然后清理新生代中不再被引用的对象,并将存活的对象复制到Survivor区。

MinorGC通常较快,因为它只涉及新生代中的内存区域。然而,如果新生代中的对象存活率较高,可能会导致较多的对象被复制到Survivor区,从而增加FullGC的风险

FullGC(老年代垃圾回收)

说是老年代的垃圾回收,FullGC其实是对整个堆内存(包括新生代和老年代)进行的垃圾回收。因为FullGC会伴随着一次MoinorGC,它通常比MinorGC更耗时,因为需要扫描和清理整个堆内存。

触发条件:FullGC的触发条件有多种,包括但不限于:

  • 老年代空间不足:当老年代(Old Generation)内存空间不足以容纳新对象或新生代中存活的对象时,会触发FullGC。
  • 元空间(Metaspace)不足:在Java 8及之后的版本中,永久代(PermGen)被元空间(Metaspace)所取代。如果元空间不足,也会触发FullGC。
  • 分配担保失败:在Minor GC后,如果survivor区无法容纳所有幸存对象,那么就要将部分幸存对象转移到老年代。如果老年代剩余空间不足以容纳这些对象,就需要进行Full GC

影响:FullGC会导致应用程序暂停较长时间,从而影响用户体验和应用程序的响应时间。因此,在设计和调优应用程序时,应尽量避免频繁触发FullGC

3.垃圾回收流程

JVM的堆内存被分为新生代(Young Generation)和老年代(Old Generation)。新生代进一步细分为Eden区、From Survivor区和To Survivor区,通常它们的比例是8:1:1,JVM的分代回收流程是基于Java对象生命周期的特点来设计的,主要目的是为了提高垃圾回收的效率和性能。以下是JVM分代回收流程

  1. 对象创建:新创建的对象首先被分配到新生代的Eden区。
  2. Minor GC:当Eden区满时,会触发Minor GC。Minor GC会清理不再被引用的对象,并将存活的对象复制到其中一个Survivor区(通常是To Survivor区),同时对象的年龄加1。然后交换From Survivor区和To Survivor区的角色,以备下一次Minor GC使用。
  3. 对象晋升:如果对象在Survivor区中“熬过”了一定次数的Minor GC(默认是15次,CMS收集器默认6次),则会被晋升到老年代。对象晋升到老年代
    的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置
  4. 大对象分配:某些大对象(大小超过 Eden 区或 Survivor 区的一半)可能会直接被分配到老年代,以避免频繁的 Minor GC
  5. 当老年代空间不足时,会触发Full GC。Full GC会清理整个堆内存,包括新生代和老年代
  6. 如果内存清理后,依然无法为新的对象开辟内存空间,会抛出内存溢出错误:内存溢出(OutOfMemory,简称OOM)

在这里插入图片描述

4.对象动态年龄判断

从上面我们可以看出,一个对象一直不被回收就会在两个幸存区中来回复制15次(复制算法)后进入老年代,其实对象在幸存区来回复制又回收不掉其实是一个很浪费性能的事情。为了能够让对象尽早的进入老年代,从而节约资源这里有两种做法:

  • 一是设置对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置,根据情况适当改小
  • 二是JVM自带的动态年龄判断机制:动态年龄判断的关键在于不严格遵循上述固定年龄阈值,而是根据实际情况动态调整。例如,如果年轻代空间紧张,即使某些对象没有达到预设的年龄阈值,为了给新对象腾出空间,这些相对年龄较大的对象也可能被提前晋升到老年代。

动态年龄判断 指的是:JVM还会根据其他条件来动态地调整对象的晋升策略,逻辑是从年龄最小的对象开始累加,如果累加的对象大小大于幸存区的一半50%(-XX:TargetSurvivorRatio可以指定),那么将当前的对象age作为新的阈值,年龄大于等于此阈值的对象将直接进入老年代,而无需等待达到-XX:MaxTenuringThreshold(15岁)中设置的年龄阈值。这种动态的判断机制使得JVM能够根据运行时的情况灵活地调整对象的晋升策略,从而优化垃圾收集的性能和内存使用效率。下面方便理解举例说明:
在这里插入图片描述
假设我们有一个Java应用程序,其内存配置如下:

  • 新生代(Young Generation)大小为10MB,其中Eden区8MB,每个Survivor区(S0和S1)各1MB。
  • -XX:MaxTenuringThreshold 设置为15,即对象默认需要达到15岁(经过15次Minor GC)才会晋升到老年代。
  • -XX:TargetSurvivorRatio 设置为50%,即当Survivor区中的对象总大小超过其容量的50%时,会触发动态年龄判断。

假设在多次Minor GC后,Survivor区(S0)中的对象分布如下:

  • 年龄1的对象占用0.1MB
  • 年龄2的对象占用0.2MB
  • 年龄10的对象占用0.3MB

JVM在MinorGC后发现:这些对象的总大小为0.6MB,已经超过了Survivor区(1MB)的50%。由于Survivor区(S0)中的对象总大小超过了其容量的50%,JVM会触发动态年龄判断。在这个例子中,年龄10的对象及其以上年龄的对象(因为它们占用了超过Survivor区50%的空间)会被直接晋升到老年代,而无需等待它们达到15岁的默认阈值。

JVM进行动态年龄判断的主要原因是优化内存使用和性能。通过提前晋升一些对象到老年代,JVM可以减少Survivor区的空间压力,从而避免频繁的Minor GC和可能的内存溢出。同时,这也有助于减少在Survivor区之间的对象复制操作,进一步提高性能

5.老年代空间分配担保机制

老年代空间分配担保机制是Java虚拟机(JVM)中的一种重要机制,当JVM进行Minor GC时,新生代中的存活对象可能会晋升到老年代。为了确保这些对象能够成功晋升,JVM采用了一种空间分配担保机制。该机制的核心是,在每次Minor GC之前,JVM会检查老年代是否有足够的空间来接收这些晋升的对象。判断流程如下:

  1. 在每次Minor GC之前,JVM会检查老年代的最大可用连续空间是否大于新生代所有对象的总空间。
  2. 如果老年代的可用空间足够大,那么Minor GC可以安全进行,因为即使新生代中的所有对象都存活,它们也能被老年代容纳。
  3. 如果老年代的可用空间不足以容纳新生代的所有对象,JVM会查看-XX:HandlePromotionFailure参数的设置。
  • 如果该参数设置为true,并且老年代的可用空间大于历次晋升到老年代对象的平均大小,JVM会尝试进行一次Minor GC,但这次GC是有风险的。
  • 如果老年代的可用空间小于历次晋升的平均大小,或者-XX:HandlePromotionFailure参数未设置或设置为false,JVM将进行Full GC以腾出更多空间
  • 如果回收完还是没有足够空间存放新的对象就会发生"OOM"

所以简单理解:老年代空间分配担保机制的主要目的是确保在Minor GC后,如果大量对象存活需要转移到老年代时,老年代有足够的空间来容纳这些对象,从而避免Full GC的频繁触发。

文章就写到这里把,太长了写着累,看着也累,如果文章对你有帮助请给个好评,白嫖可耻哦!!!

下一章:常用的垃圾回收器

  • 21
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

墨家巨子@俏如来

你的鼓励是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值