大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 036 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的主要目标是已经有一定 Java 开发经验,并希望进一步完善自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和完善的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。当然,我也会在必要的时候进行相关技术深度的技术解读,相信即使是拥有多年 Java 开发经验的从业者和大佬们也会有所收获并找到乐趣。
–
垃圾回收是 JVM 中最复杂也是最重要的机制之一。它自动管理内存,帮助开发者避免内存泄漏和其他内存管理问题。然而,垃圾回收的具体实现和优化策略并不简单。在本篇文章中,我们将探讨 JVM 的垃圾回收机制,包括垃圾回收的基本原理、算法及其优缺点,帮助你更好地理解 JVM 如何高效管理内存资源。
文章目录
1、Java垃圾回收机制2、哪些内存需要回收?2.1、引用计数法2.2、可达性分析2.3、GC Roots2.4、二次标记 3、什么时候回收?4、如何回收?4.1、标记-清除算法4.2、复制算法4.3、标记-整理算法4.4、分代收集算法
1、Java垃圾回收机制
Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来
说起垃圾收集(Garbage Collection,GC),大部分人都把这项技术当做 Java 语言的伴生产物。事实上,GC 的历史比 Java 久远,1960 年诞生于 MIT 的 Lisp 是第一门真正使用内存动态分配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,人们就在思考 GC 需要完成的 3 件事情
哪些内存需要回收?什么时候回收?如何回收?接下来我们逐个看这些问题
2、哪些内存需要回收?
我们知道,Java 堆中存放着几乎所有的对象实例,所以垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还"存活"着,哪些已经"死去"(即不可能再被任何途径使用的对象)。
2.1、引用计数法
对哪些内存需要回收的一个很简单的实现是引用计数法
引用计数法作为一种内存管理技术,用于自动跟踪和管理对象的引用数量。它的基本原理是为每个对象维护一个引用计数器,记录当前有多少个指针指向该对象。当一个指针引用该对象时,引用计数器加 1;当一个指针不再引用该对象时,引用计数器减1。当引用计数器的值为0时,表示该对象没有被任何指针引用,可以被释放。
引用计数法的优点是实时性高,当没有引用指向一个对象时,可以立即释放该对象。
然而,引用计数法也存在一些缺点。首先,引用计数法需要维护每个对象的引用计数器,增加了额外的开销。其次,,引用计数法无法处理循环引用的情况。当两个对象互相引用时,它们的引用计数器都不为 0,导致无法被释放,从而造成内存泄漏。
总的来说,引用计数法是一种简单而高效的内存管理技术,适用于处理非循环引用的情况。但是在处理循环引用和大量对象的情况下,可能会导致内存泄漏和性能问题。因此,在实际开发中,需要综合考虑使用引用计数法和其他内存管理技术来进行内存管理。
2.2、可达性分析
可达性分析算法是另外一种垃圾回收算法,用于确定哪些对象是可达的(被引用的),哪些对象是不可达的(没有被引用的),从而进行垃圾对象的回收。
可达性分析算法的基本思想是从一组根对象开始,通过遍历对象之间的引用关系,标记出所有可达的对象。具体步骤如下:
根对象的集合可以是全局变量、活动线程的栈帧、静态变量等。这些根对象是程序中始终可访问的对象;
从根对象开始,通过对象之间的引用关系,遍历所有可达的对象。一般使用深度优先搜索(DFS)或广度优先搜索(BFS)算法进行遍历;
遍历过程中,对已经访问过的对象进行标记,表示这些对象是可达的;
遍历完成后,所有未被标记的对象即为不可达的对象,即垃圾对象;
对于不可达的对象,可以进行相应的回收操作,释放它们所占用的内存空间。
可达性分析算法的优点是能够准确地找到不可达的对象,避免了内存泄漏。它不仅可以处理循环引用的情况,还可以处理复杂的对象引用关系。同时,可达性分析算法相对简单,实现起来较为容易。
然而,可达性分析算法也存在一些缺点。首先,它需要遍历整个对象图,对大型对象图或者对象图变化频繁的情况下,可能会导致较大的性能开销。其次,可达性分析算法需要停止程序的执行,进行垃圾回收操作,可能会引起一定的停顿时间。
总的来说,可达性分析算法是一种常用的垃圾回收算法,通过标记可达对象和回收不可达对象,实现自动内存管理。它是现代编程语言中常用的垃圾回收算法之一。
2.3、GC Roots
在 Java 中,垃圾收集(GC)根(GC Roots)是垃圾收集器进行可达性分析时的起点集合。GC Roots 包括一系列对象引用,通过这些引用可以访问到活跃的(即还在使用中的)对象。以下是可作为 GC Roots 的主要类型的对象:
虚拟机栈中的局部变量表:每个线程的虚拟机栈中的局部变量表中的引用都作为 GC Roots。这包括了 Java 方法的局部变量、方法参数等。方法区中的类静态属性引用:存储在方法区的类静态属性引用的对象也作为 GC Roots。这些是通过static
关键字定义在类中的变量。方法区中的常量引用:方法区中的常量也可以作为 GC Roots,比如字符串常量池中的引用。本地方法栈中 JNI(即通常所说的 Native 方法)的引用:本地方法栈中的 JNI 引用也作为 GC Roots。这些是 Java 应用通过 JNI 调用的本地代码(如 C 或 C++ 代码)中的对象引用。活跃的 Java 线程:每个活跃的 Java 线程本身也被视为一个 GC Root。同步锁(synchronized 语句)持有的对象:作为同步锁持有的对象也被视为 GC Roots。反射和代理对象:通过反射或动态代理创建的对象,这些对象在使用时也被视为 GC Roots。Java 虚拟机内部的引用:比如基础类加载器等,JVM 内部的一些结构也会持有对对象的引用。 这些 GC Roots 是垃圾收集器进行可达性分析的出发点。在垃圾收集过程中,垃圾收集器会从这些根节点开始遍历,如果一个对象到所有的 GC Roots 都没有任何引用链相连(即从 GC Roots 出发无法到达该对象),那么这个对象就被认为是不可达的,因此可能会被垃圾收集器回收。
2.4、二次标记
Java 虚拟机(JVM)在进行垃圾回收时,对于那些在可达性分析中被判定为不可达的对象,并不会立即进行回收。这些对象会经历至少两次标记过程,这个过程涉及到了"终结器(Finalizer)" 机制和可达性分析的再次应用。以下是这个过程的详细说明:
第一次标记
不可达对象的初步判定:在进行垃圾回收时,JVM 首先进行可达性分析,以确定哪些对象是可达的,哪些不是。不可达对象在这个阶段被初步判定为可回收。检查是否有必要执行finalize
方法: 对于那些不可达的对象,JVM 会检查它们是否覆盖了 finalize
方法,以及 finalize
方法是否已经被调用过。如果一个对象没有覆盖 finalize
方法,或者 finalize
方法已被调用过,那么 JVM 将这个对象标记为可回收。 Ps:finalize
方法在 Java 中是一个由 java.lang.Object
类定义的方法,它被设计用来在对象被垃圾回收器回收之前给予一个最后的机会来执行资源释放等清理操作。然而,由于其不确定性和潜在的性能问题,finalize
方法的使用通常是不推荐的。
第二次标记
F-Queue
队列:如果对象覆盖了 finalize
方法且未被调用过,这个对象会被放入一个称为 F-Queue
的队列中,等待被终结器线程执行其 finalize
方法。执行 finalize
方法:终结器线程会在将来某个时刻执行该队列中对象的 finalize
方法。这为对象提供了最后的机会去复活自己,比如对象在 finalize
方法中被其他某个存活对象所引用。再次标记:在 finalize
方法执行后,JVM 会再次对 F-Queue
中的对象进行一次小规模的标记清除,以决定这些对象是否真的无法被任何路径所引用。 最终的死亡判定
如果在执行finalize
方法后,对象仍然无法通过任何引用路径被访问到,那么 JVM 将最终判定这个对象为"死亡",并在下一次垃圾回收时清理掉这个对象占用的内存。 3、什么时候回收?
Java 虚拟机(JVM)进行垃圾回收(GC)的具体时机取决于多种因素,包括垃圾回收算法、堆内存使用情况、JVM 参数设置等。通常,JVM 不会在固定的时间点执行垃圾回收,而是根据特定的条件触发。以下是几种常见的触发时机:
堆内存不足时: 年轻代满了(Minor GC):当年轻代(Young Generation)的 Eden 区或 Survivor 区满了时,JVM 会触发 Minor GC 来清理年轻代的无用对象。老年代满了(Major GC/Full GC):当老年代(Old Generation)接近满时,JVM 会触发 Major GC 或 Full GC。这种类型的垃圾回收通常比 Minor GC 更耗时,因为它涉及整个堆。 调用 System.gc():显式触发,调用System.gc()
会建议 JVM 执行垃圾回收,但 JVM 可以忽略这个建议。通常不建议在生产代码中使用这个方法,因为它可能导致不可预测的 GC 行为和性能问题。元空间不足时:元空间满了(Metaspace GC),如果元空间(Metaspace,取代了永久代)接近其最大限制,JVM 也会尝试进行垃圾回收。JVM 即将退出:程序终止时:在 JVM 正常终止执行时(比如所有非守护线程都已完成),可能会进行垃圾回收,但这并不是必须的,因为操作系统会回收所有分配给 JVM 的资源。大对象直接进入老年代:大对象处理,如果一个对象非常大,以至于在年轻代中无法容纳,它可能会直接被分配到老年代。在这种情况下,如果老年代空间不足以容纳这个大对象,也可能触发垃圾回收。 此外,某些 JVM 参数设置(如堆大小 -Xmx
、 -Xms
、回收阈值 -XX:MaxHeapFreeRatio
等)和 Jvm 的自适应调整(Ergonomics)也会影响 GC 的触发。
注意事项:
GC 的确切行为取决于所使用的 JVM 版本和配置,以及具体选择的垃圾回收器。强制垃圾回收通常是不可预测的,并且不建议在生产环境中使用。垃圾回收最好由 JVM 自己管理。4、如何回收?
垃圾回收涉及到大量的程序细节,而且各个平台的虚拟机操作内存的方式也不一样,但是他们进行垃圾回收的算法是通用的,所以这里我们也只介绍几种通用算法。
在 JVM 中主要的垃圾收集算法有:标记-清除、标记-整理(标记-清除-压缩)、复制(标记-复制-清除)、分代收集等算法。这几种收集算法互相配合,针对不同的内存区域采取对应的收集算法实现(这里具体是由相应的垃圾收集器实现)。
4.1、标记-清除算法
标记-清除(Mark-Sweep)算法是垃圾回收(GC)领域中的一种基本算法。它分为两个主要阶段:标记(Mark)和清除(Sweep)。
下面是对这种算法的详细介绍:
标记阶段(Mark):在标记阶段,算法遍历所有的可达对象。然后,算法遍历这些对象的引用,递归地标记所有从这些根可达的对象。被标记的对象被视为活动的,即它们在应用程序中仍在使用中,不应该被回收。清除阶段(Sweep):在清除阶段,算法扫描堆内存,查找那些在标记阶段没有被标记的对象。所有未被标记的对象都被视为垃圾,因此它们占用的内存将被回收。清除过程会释放这些对象占用的内存空间,使其可用于未来的对象分配。优点:①、算法相对简单直观;②、可以处理循环引用的情况。
缺点:①、垃圾回收过程中,标记和清除这两个过程的效率都不高,并且需要暂停整个应用(Stop-the-World),对应用性能有影响;②、清除后会导致内存碎片化,因为内存的申请通常不是连续的,那么清除一些对象后,就会产生大量不连续的内存碎片,而碎片太多时,当有个大对象需要分配内存时,便会造成没有足够的连续内存分配而提前触发垃圾回收,甚至直接抛出 OutOfMemoryExecption。
4.2、复制算法
为了解决标记-清除算法的两个缺点,复制算法诞生了。
复制算法(Copying Algorithm)同样是垃圾回收(GC)领域中的一种基本算法。这种算法通过将内存分为两个部分(通常是等大小的两块区域)来简化内存管理和提高垃圾回收的效率。
以下是复制算法的工作:
内存划分:在复制算法中,堆内存被划分为两个相等的部分,通常称为 “From” 区域和 “To” 区域;对象分配:初始时,对象都被分配在 “From” 区域。随着应用程序的运行,对象逐渐在 “From” 区域中累积。垃圾回收过程:当 “From” 区域填满时,垃圾回收被触发。回收过程中,算法会检查 “From” 区域中的对象,并将活动的对象复制到 “To” 区域。未被复制的对象(即不可达的对象)被视为垃圾,随着 “From” 区域的清空被回收。区域交换:一旦所有存活的对象都被复制到 “To” 区域,原来的 “From” 区域就变成了空的。接下来,“From” 区域和 “To” 区域的角色互换:原来的 “To” 区域变成新的 “From” 区域,原来的 “From” 区域变成新的 “To” 区域。优点:①、由于活动对象被紧密地复制到另一块区域,内存碎片化得到了显著减少;②、实现简单,回收过程高效。
缺点:①、由于堆空间被划分为两部分,但在任何时刻只有一半被用于对象分配,导致有效内存减半,内存利用率降低;②、对存活对象的复制操作可能会增加额外的开销,尤其是当存活对象数量较多时。
4.3、标记-整理算法
上面我们说过复制算法会浪费一半的内存,并且对象存活率较高时,会有过多的复制操作,效率低下。如果对象存活率很高,基本上不会进行垃圾回收时,标记-整理算法诞生了。
标记-整理(Mark-Compact)了标记-清除算法带来的内存碎片化问题。
以下是标记-整理算法的三个阶段 :
标记阶段:与标记-清除算法相同,首先从一组根对象(GC Roots)开始,遍历所有可达的对象,并对这些对象进行标记。整理阶段:在所有存活的对象都被标记之后,标记-整理算法不是简单地清除未标记的对象,而是将所有存活的对象向一端移动。通过移动存活对象,算法压缩空间,消除了内存碎片。清理阶段:移动对象之后,清理掉边界以外的内存空间优点:通过整理存活对象,确保了连续的空闲内存,有利于大对象的分配,解决了内存碎片化问题。
缺点:①、效率相对较低,在整理阶段需要移动对象,并更新所有指向这些对象的引用,这增加了额外的开销;②、停顿时间可能较长,整个标记和整理过程需要暂停应用程序(Stop-the-World),在大型堆中可能导致较长的停顿。
标记-整理算法在垃圾回收的效率和内存的有效利用之间取得了较好的平衡。它通过移动存活对象并清理未使用的内存,减少了内存碎片,使得内存利用更加高效。尽管它可能引起较长的停顿时间,但在处理大量长生命周期对象的场景中,它提供了一种有效的垃圾回收解决方案。
4.4、分代收集算法
分代收集算法(Generational Collection Algorithm)是一种在现代垃圾回收(GC)中广泛使用的方法。当前商业虚拟机都是采用此算法,但是其实这不是什么新的算法,而是上面几种算法的合集。这种算法基于一个观察:不同对象的生命周期各不相同。一些对象很快就不再使用(短生命周期),而其他对象可能会持续存在整个应用程序的生命周期(长生命周期)。基于这个原理,分代收集算法将堆内存分为几个不同的区域(代),每个区域使用不同的垃圾回收策略。
通常情况下,Java 堆被分为三个主要部分:
新生代(Young Generation):存放新创建的对象。由于许多对象生命周期短暂,年轻代经常进行垃圾回收,这种回收被称为 Minor GC。年轻代通常使用复制算法,因为需要频繁回收,而且每次只有少量对象存活。
新生代通常被划分为三个部分:
Eden 区:新创建的对象首先被分配到 Eden 区。这个区域相对较大,通常占新生代的大部分空间(如您所述,比例通常是 8:1:1,其中 8 代表 Eden 区)。两个 Survivor 区(From Survivor 和 To Survivor):Survivor 区用于存放从 Eden 区幸存下来的对象。在任何时候,其中一个 Survivor 区是空的。比如,两个 Survivor 区分别称为 S0 和 S1,那么在一次垃圾回收过程中,对象可能从 Eden 和 S0 复制到 S1,然后 Eden 和 S0 被清空。老年代(Old Generation):存放生命周期长的对象。这里的对象通常是从年轻代中经过多次 GC 仍然存活下来的。老年代的垃圾回收频率较低,但每次回收的耗时更长,称为 Major GC 或 Full GC。通常采用标记-清除或标记-整理算法。
永久代/元空间(PermGen/Metaspace,取决于 JVM 版本):存放类的元数据、常量池等。这部分区域的回收频率最低。
分代收集的优点
提高回收效率:由于大多数新创建的对象很快变得不可达,年轻代的回收是非常高效的。分代算法减少了查看整个堆来查找垃圾对象的需要。减少内存碎片化:通过在不同代上采用不同的算法,分代收集可以有效减少内存碎片化。优化回收策略:可以针对不同代的特性采取最适合的垃圾回收策略。分代收集算法是现代垃圾回收中一个核心概念,它通过将对象根据生命周期不同分布在不同的内存区域,以此来优化垃圾回收过程,减少回收带来的性能开销。这种方法在多数 Java 应用中被证明是非常有效的。