跳转至

垃圾回收

引用

直接引用

直接引用:

image-20241226133102499

其中对象类型数据的指针存放在 对象的对象头中

引用强度分类

强 > 软 > 弱 > 虚

  • 强引用:当对象存在强引用时,一定不会被回收
  • 软引用:在垃圾回收后,如果内存仍然不足时,抛出异常之前会先回收只有软引用指向的对象
  • 弱引用:当一个对象只有一个弱引用时,只要发生 gc ,此对象就会被回收
  • 虚引用:配合引用队列使用,被引用对象回收时,将虚引用入队,由 Referene Handler 线程盗用虚引用相关方法释放直接内存

垃圾判断算法

分为

  1. 引用计数算法
  2. 可达性分析算法

引用计数算法

在对象头中分配一个空间来保存对象的被引次数

问题:无法解决循环依赖

public class ReferenceCountingGC {

    public Object instance;  

    public ReferenceCountingGC(String name) {}

    public static void testGC() {
        // 创建两个 ReferenceCountingGC 对象
        ReferenceCountingGC a = new ReferenceCountingGC("沉默王二");
        ReferenceCountingGC b = new ReferenceCountingGC("沉默王三");

        // 使 a 和 b 相互引用
        a.instance = b;
        b.instance = a;

        // 将 a 和 b 设置为 null
        // 这意味着不会再使用这两个对象了
        a = null;
        b = null;

        // 这个位置是垃圾回收的触发点
    }
}

全部置为 null 后无法再在后面的代码中找到这两个对象了,这两个对象相当于无法再被使用了

image-20241226141029856

可达性分析算法

GC Roots:一组必须活跃的引用,是一切引用链的源头:

  • 虚拟机栈中的引用(方法的参数 / 局部变量等)

    public class StackReference {
     public void greet() {
         Object localVar = new Object(); // 这里的 localVar 是一个局部变量,存在于虚拟机栈中
         System.out.println(localVar.toString());
     }
    
     public static void main(String[] args) {
         new StackReference().greet();
     }
    }
    

    虚拟机栈中的 localVar 是一个局部变量,可以被认为是 GC Roots

    • greet 方法执行期间,localVar 引用的对象不会被回收,因为其从 GC Roots 可达
    • greet 方法执行完后,localVar 作用域结束,Object 不再有一个可达的 GC Root
  • 运行时常量池中的常量(String / Class 类型)

    public class ConstantPoolReference {
     public static final String CONSTANT_STRING = "Hello, World"; // 常量,存在于运行时常量池中
     public static final Class<?> CONSTANT_CLASS = Object.class; // 类类型常量
    
     public static void main(String[] args) {
         System.out.println(CONSTANT_STRING);
         System.out.println(CONSTANT_CLASS.getName());
     }
    }
    

    CONSTANT_STRING 和 CONSTANT_CLASS 作为常量存储在运行时常量池。它们可以用来作为 GC Roots。

  • 类静态变量

    public class StaticFieldReference {
     private static Object staticVar = new Object(); // 类静态变量
    
     public static void main(String[] args) {
         System.out.println(staticVar.toString());
     }
    }
    

    这里的 staticVar 存在元空间,可以被视为 GC Root,如果StaticFieldReference 不被卸载(这通常发生在其类加载器被垃圾回收时),被其引用的对象也会有资格被回收

  • 本地方法栈中 JNI 的引用

    在本地方法中可能存在一个对于 Java 堆中的对象的引用

    当调用 Java 方法时,虚拟机会创建一个栈帧并压入虚拟机栈,而当它调用本地方法时,虚拟机会通过动态链接直接调用指定的本地方法。

    image-20241226142949220

Stop The World

垃圾收集的过程中,JVM 会暂停所有用户线程,这种暂停称为 Stop The World ,防止在垃圾收集的过程中用户线程修改了堆中的对象,导致垃圾收集器无法准确收集垃圾。

这个事件会对 Java 应用的性能产生影响。

垃圾收集算法

标记清除算法

  • 首先利用可达性分析方法把可回收的对象标记出来
  • 然后把标记的垃圾清理掉

image-20241226160652779

这样的优点是操作简单,缺点是存在内存碎片,导致需要分配较大的对象时,因无法找到足够的连续内存而不得不提前触发新一轮的垃圾收集。

复制算法

由标记清除算法演化而来,用于解决内存碎片问题

  • 将可用内存分为两块,每次只使用其中的一块
  • 一块用完后,将存活的对象复制到另一块

image-20241226161206844

代价是消耗内存,只有一半内存可用

标记整理算法

  • 标记过程不变
  • 然后直接将存活对象移动到一块连续的区域(垃圾所在区域视为空闲)

image-20241226161435437

缺点是内存变动比复制算法更频繁,需要整理所有存活对象的引用地址,效率上比复制算法更差

分代收集算法

根据对象存活周期的不同会将内存划分为几块,一般是把 Java 分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

image-20241226161544705

新生代和老年代

image-20241226161544705

回收使用的算法

  • 新生代的回收:新生代通常采用复制算法,因为每次只有少量对象存活,这种算法适合新生代对象频繁创建和回收的特点

    每次垃圾回收时,会将 Eden 区和 Survivor 区中的存活对象复制到另一个 Survivor 区中,可以避免内存碎片

  • 老年代的回收:使用标记整理或者标记清除算法,因为存活时间长、回收频率低

Eden 区

有将近 98% 的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,JVM 会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。

通过 Minor GC 之后,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区,如果 From 区不够,则直接进入 To 区。

Survivor 区

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲

1、为啥需要 Survivor 区?

不就是新生代到老年代吗,直接 Eden 到 Old 不好了吗,为啥要这么复杂。

如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历 16 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。

2、Survivor 区为啥划分为两块?

设置两个 Survivor 区最大的好处就是解决内存碎片化,我们先假设一下,Survivor 只有一个区域会怎样。

Minor GC 执行后,Eden 区被清空,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。那么问题来了,这时候我们怎么清除它们?

在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。

但因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,From 与 To 职责兑换,这时候会将 Eden 区和 To 区中的存活对象再复制到 From 区域,以此反复。

这种机制最大的好处就是,整个过程中,永远有一个 Survivor space 是空的,另一个非空的 Survivor space 是无碎片的。

那么,Survivor 为什么不分更多块呢?比方说分成三个、四个、五个?

显然,如果 Survivor 区再细分下去,每一块的空间就会比较小,容易导致 Survivor 区满,两块 Survivor 区可能是经过权衡之后的最佳方案。

Old 区

老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发“Stop-The-World”。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。

以下几种情况会进入老年代:

  • 大对象:大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。
  • 长期存活对象:虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。

    这里的 15,JVM 也支持进行特殊设置 -XX:MaxTenuringThreshold=10

  • 动态对象年龄:JVM 并不强制要求对象年龄必须到 15 岁才会放入老年区,如果 Survivor 空间中某个年龄段的对象总大小超过了 Survivor 空间的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代

垃圾收集器

  • 串行垃圾收集器
  • 并行垃圾收集器
  • CMS 垃圾收集器
  • G1 垃圾收集器

面对不同的业务场景,往往需要不同的垃圾收集器才能保证 GC 性能。

主要分为:

  • 分代收集器:代表为 CMS
  • 分区收集器:代表为 G1

分代收集器 - CMS

Concurrent Mark Sweep

针对老年代的垃圾回收算法,目标是获得最短回收停顿时间,采用 标记清除 算法

三色标记算法

先标一点 -> 再全标 -> 再校验 -> 再清理

CMS 垃圾收集器通过三色标记算法,实现了垃圾回收线程与用户线程的并发执行,其运行分为 4 个步骤:

  1. 初始标记:寻找所有被 GCRoots 直接关联到的对象,需要 Stop The World。由于不需要骚婊整个引用链,因此速度很快

  2. 并发标记: 对于初始标记阶段标记的对象进行整个引用链的扫描,不需要 STW。对整个引用链做扫描需要花费大量时间,但是此过程允许垃圾回收线程和用户线程并发执行

  3. 重新标记: 对于并发标记阶段的错误进行矫正(由于上一阶段和用户线程并发,可能出现错标或者漏标),则其需要在这个阶段做一些校验,需要 STW
  4. 并发清除: 将标记为垃圾的对象清除,不需要STW

image-20241226185811245

缺点:

  • 对 CPU 核数敏感,当 CPU 数量很小时,垃圾回收的线程占总线程数会很高,降低了系统的吞吐量
  • 使用标记清除,内存碎片多,当大对象无法找到连续内存空间时会触发 Full GC,导致长停顿
  • 无法处理浮动垃圾(CMS 进行回收时,应用程序线程还在跑,还在产生垃圾,这些垃圾只能在下一次 GC 中清理)

分区收集器 - G1

Garbage-First

  • 主要目标:将 STW 停顿的时间和分布,变成可预期且可配置的

    在任意 xx 毫秒时间范围内,STW 停顿不得超过 yy 毫秒。举例说明:任意 1 秒内暂停时间不超过 5 毫秒。G1 GC 会尽力达成这个目标(有很大概率会满足,但并不完全确定)。

image-20250220105336564

将堆内存划分为了多个等大区域,每个区域都可以充当 eden, survivor, old, humongous(大对象专用)

  1. 新生代的回收

Eden 区占比只有 5%~6% 左右,也就是有大小限制。当 Eden 区创建的对象达到限制时,使用可达性分析标记的存活对象,将 存活的对象复制到另一个

标记和复制的过程都需要 STW,但是因为需要回收的内存较小,速度较快

  1. 并发标记

当老年代占用内存达到阈值(45%)之后,会触发不会暂停用户线程的并发标记。随后也需要进行重新标记,STW 进行检查。

  1. 混合收集

由于设置了暂停时间目标,此时不会对所有老年代区域进行回收,而是优先回收存活对象少的区域(Gabage First)

在此期间也会伴随着对于年轻代的垃圾回收。

特别地,如果出现并发失败(清理速度赶不上创建新对象速度),会触发 full GC

GC 分类

  1. Young GC (Minor GC)

    仅针对 Eden 和 Survivor 的 GC,当 Eden 被填满时(无法为新对象分配空间),或 Eden 和 Survivor 都不足,或 某些 Full GC 之前、

  2. Old GC (Major GC)

    仅回收老年代,当老年代空间不足时触发

  3. Full GC

    对新老都触发,可能还有元空间,会使整个 JVM 停顿,System.gc()

    在老年代空间不足且无法通过 YGC 释放足够空间时

  4. Mixed GC

    同时回收新生代和部分老年代,仅在 G1 发现老年代垃圾过多时触发