# 前言

因为我装双系统误删了我 windows 的引导分区,导致我电脑没法启动(惨惨),因为第一次遇见,浪费了不少时间处理了,好在幸运的是引导修复成功了,也幸好修修好了,不然备份重装可要遭老罪咯。碎碎念就到这里咯,前面将内存管理的部分简单的记录了一下,本篇用以记录 GC 机制的学习,Java 很重要的一块内容,当一个对象需要被释放的时候如何判断一个对象需要被回收?什么时候处理的?JVM 是怎么处理的?

注:本篇中所有代码都在 Clown_java/src/main/java/JavaJVM at master・clown-q/Clown_java (github.com)

# 垃圾回收机制的作用

在内存管理的学习过程中可以知道,Java 不需要像 C 和 C++ 那样手动的管理内存,JVM 会自行处理,当然也就不需要我们手动的去释放资源,所以也就有垃圾回收机制的用武之地。

# 对象存活判定算法

这部分就是为了回答第一个问题,JVM 如何判断一个对象需要被回收,可以回收。其实主要就是下面这几种对象存活判定算法

# 引用计数法

这是最简单的一种算法,在 Java 中我们要操作一个对象肯定会给其一个引用变量,我们通过引用变量来进行操作

image-20230909115136494

像这里就会有一个 str2 变量,下面也是通过这个引用变量来操作对象,而这个算法的思想就是每个对象都给一个引用计数器,用来存放引用计数,当有一个地方引用的时候引用计数加一,引用失效的时候引用计数减一,当引用计数为 0 的时候就认为这个对象不在被使用。这里可以看一下下面这个例子

public class ReferenceCount {
    public static void main(String[] args) {
        String str = new String("aaa");// 引用计数器加一,当然为 1
        str=null;// 引用计数器减一,当前为 0// 认为这个对象不会被再次使用
    }
}

当 str 被赋值为空后好像确实无法在获取到这个对象,当然这个对象也就会被丢弃,似乎这很合理,当然并不完美,如果两个对象相互的引用就会出现问题,比如下面这个例子

public class ReferenceCount {
    public static void main(String[] args) {
//        String str = new String ("aaa");// 引用计数器加一,当然为 1
//        str=null;// 引用计数器减一,当前为 0// 认为这个对象不会被再次使用
        Test test1 = new Test(); // 对象一的计数器值加一,当前值为 1
        Test test2 = new Test(); // 对象二的计数器值加一,当前值为 1
        test1.test = test2; // 对象一的成员属性引用对象二,计数器值加一,当前值为 2
        test2.test = test1; // 对象二的成员属性引用对象一,计数器值加一,当前值为 2
        test1 = test2 = null; // 两个对象的原始引用都置空,计数器都减一,当前的值都为 1
    }
    public static class Test{
        Test test;
    }
}

这里就会形成一个交叉引用,但是因为 test1test2 两个变量都被置空,所以我们无法获得原本 new 出来的两个对象,但是他们的计数器值都为 1,根据引用计数法来看这两个对象都不会被 GC 回收,但是这两个对象实际上已经没有任何作用。所以说引用计数法不是最好的解决方案

# 可达性分析算法

采用了一种类似树结构的搜索机制,树搜索就绕不开根节点,在可达性分析算法中所有的对象都可被选为根节点,先来看下面这个例子

public class AccessibilityAnalysis {
    public static void main(String[] args) {
        A root = new A();
        root.B = new B();
        root.C = new C();
        root=null;
    }
    public static class A{
        public B B;
        public C C;
    }
    public static class B{}
    public static class C{}
}

这里根节点是 root 这个引用变量并分别为 BC 类的对象分配了内存,并将它们分别赋值给 root.Broot.C ,然后我将 root 置空 root 不再引用 ABC 对象,这些对象变为不可达,也就是说没有任何引用指向它们。在 Java 的垃圾回收机制运行时,它会检测到这些不可达对象并将它们标记为可以回收。在 root 置空之前的示意图

image-20230909135638184

置空后

image-20230909135948467

下面的对象结点已经不可达了,虽然还有引用但是在可达性分析算法中已经可以被回收了,就很好的解决了上面交叉引用不会被回收的情况,还是下面这段代码

public class ReferenceCount {
    public static void main(String[] args) {
//        String str = new String ("aaa");// 引用计数器加一,当然为 1
//        str=null;// 引用计数器减一,当前为 0// 认为这个对象不会被再次使用
        Test test1 = new Test(); // 对象一的计数器值加一,当前值为 1
        Test test2 = new Test(); // 对象二的计数器值加一,当前值为 1
        test1.test = test2; // 对象一的成员属性引用对象二,计数器值加一,当前值为 2
        test2.test = test1; // 对象二的成员属性引用对象一,计数器值加一,当前值为 2
        test1 = test2 = null; // 两个对象的原始引用都置空,计数器都减一,当前的值都为 1
    }
    public static class Test{
        Test test;
    }
}

置空前

image-20230909145114778

置空后

image-20230909145132184

这里两个对象就不可达了,满足了可达性分析算法回收的条件,就会被回收了

# 回光返照

通过上面两个算法 JVM 可以判断哪些对象是可以被回收的,为什么叫回光返照呢,这是因为实际上即使一个对象被判断会被回收,依然可以抢救一下其被 “火化” 的命运。

有这样一个方法 finalizefinalize 是一个在 Java 中用于垃圾回收的方法。它是 java.lang.Object 类中的一个方法,因此所有的 Java 类都默认继承了这个方法。 finalize 方法的作用是允许对象在被垃圾回收之前执行一些清理和资源释放的操作。

image-20230909151433276

如果子类重写这个方法,再回收的时候就会执行这个方法,而在这个方法中可以 “抢救” 一下要被回收的对象

public class LastRadianceOfTheSettingSun {
    public static A root;
    public static void main(String[] args) throws InterruptedException {
        root = new A();
        root = null;
        System.gc();  // 申请 GC
        Thread.sleep(1000); // 等待 GC 执行完成
        System.out.println(root.str);
    }
    public static class A{
        String str = "test";
    }
}

这段代码是没有重写 finaliuze 方法的

image-20230909152046476

这里抛出了一个空引用异常,再看下面这段

public class LastRadianceOfTheSettingSun {
    public static A root;
    public static void main(String[] args) throws InterruptedException {
        root = new A();
        root = null;
        System.gc();  // 申请 GC
        Thread.sleep(1000); // 等待 GC 执行完成
        System.out.println(root.str);
    }
    public static class A{
        String str = "回光返照";
        @Override
        protected void finalize() throws Throwable{
            System.out.println("GC前的垂死挣扎");
            root = this;
        }
    }
}

image-20230909152443743

可以看到,这里就续命成功,值得注意的是 finalize 这个方法并不是在主线程调用的,而是在一个更低的优先级,这里将上面代码中的等待注释

image-20230909153503965

再执行就会抛出异常

image-20230909153518886

这里可以看一下他的线程名

image-20230909153749929

image-20230909153736353

这里可以看到,是一个 Finalizer 线程,这个线程是 JVM 自行创建的

finalize 方法只能被调用一次

# 垃圾回收算法

在一个较大的项目中可能会有很多需要被回收的对象,产生的时间也并不是固定的,垃圾搜集器会不定期的检查堆中的对象是否满足回收的条件,这里就回答了开篇中提出的问题,什么时候处理的?JVM 是怎么处理的?

# 分代管理

如果每次检查的时候都检查所有的对象,效率毫无疑问是非常慢的,越是庞大的项目中越是如此,所以这里可以使用到分代管理,简单来说就是给不同的类型的对象分级,经常使用到的对象减少去检查的次数来提高检查的效率。你们具体是怎么实现的呢?

JVM 将堆内存再次细分,划分为新生代,老年代和永久代(其中永久代实际上是 HostSpot 特有的概念,JDK8 之前方法区是使用永久代实现,JDK8 之后方法区采用元空间实现,并且使用的是本地内存)划分看下图

image-20230909175314736

所有刚创建的对象都会放入到新生代的 Eden 区,在进行新生代区域 GC 时,会先对所有的新生代区域进行扫描,并回收不在使用的对象

垃圾收集对应不同分区也是有着等级之分的

  • Minor GC 主要对新生代进行扫描收集
    • 触发的条件是 Eden 分区容量不足
  • Major GC 主要对老年代进行垃圾收集
  • Full GC 对所有的对象进行扫描收集

如果是一个大对象会被直接放入到老年代中,因为 Survivor 区域比较小不一定放得下

下面简单画一下流程

image-20230909180639831

新对象进入到 Eden,在一次 GC 后,不在使用的被回收,还在使用的进入到 From,然后 From 和 To 发送一次互换

image-20230909180851333

再一次 GC 发生的时候,所有在 To 区域的对象会进行 GC 年龄判断,且加一,当对象的年龄超过默认值 15 后会进入老年代

image-20230909181330218

可以看到上图中,一系列的 GC 后,B 被回收,A 的年龄大于 15 进入到老年代,新的对象 D 进入 from

public class LastRadianceOfTheSettingSun {
    public static A root;
    public static void main(String[] args) throws InterruptedException {
        root = new A();
        root = null;
        System.gc();  // 申请 GC
        Thread.sleep(1000); // 等待 GC 执行完成
        System.out.println(root.str);
    }
    public static class A{
        String str = "回光返照";
        @Override
        protected void finalize() throws Throwable{
            System.out.println("GC前的垂死挣扎");
            root = this;
        }
    }
}

还是这段代码,添加启动参数来查看 GC 日志

image-20230909182253206

这里直接运行

image-20230909182318765

这里手动调用的 System.gc (),所以这里会将所区的对象都扫描到