垃圾回收机制

垃圾回收的概念


  • 垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存爆掉。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

垃圾判断算法


引用计数算法

  • 引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数RC(Reference Count)
  • 如果该对象被其它对象引用,则它的引用计数加 1,如果删除对该对象的引用,那么它的引用计数就减 1,当该对象的引用计数为 0 时,那么该对象就会被回收。
  • 引用计数算法将垃圾回收分摊到整个应用程序的运行当中,而不是集中在垃圾收集时。因此,采用引用计数的垃圾收集不属于严格意义上的Stop-The-World的垃圾收集机制。
  • 无法解决循环依赖的问题
public class Test {
    public Object instance;

    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        // 相互引用
        a.instance = b;
        b.instance = a;
        // 两个对象的instance仍然互相引用
        a = null;
        b = null;
    }
}

可达性分析算法

  • 可达性分析算法(Reachability Analysis)的基本思路是,通过 GC Roots 作为起点,然后向下搜索,搜索走过的路径被称为 引用链(Reference Chain),当一个对象到 GC Roots 之间没有任何引用相连时,即从 GC Roots 到该对象节点不可达,则证明该对象是需要垃圾收集的。

  • 通过可达性算法,解决了引用计数无法解决的问题-“循环依赖”,只要无法与 GC Root 建立直接或间接的连接,系统就会判定为可回收对象

  • 判定为可回收对象后不一定就会被回收:对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法。

    • 如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收
    • finalize 方法只会被执行一次,如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收
  • GC Roots 是一组必须活跃的引用,不是对象,它们是程序运行时的起点,是一切引用链的源头:

      1. 虚拟机栈中的引用(方法的参数、局部变量等)
    public class Test {
        // 在 hello 方法执行期间,o 引用的对象是活跃的,因为它是从 GC Roots 可达的
        public void hello() {
            // o 是一个局部变量,存在于虚拟机栈中,可以被认为是 GC Roots
            Object o = new Object();
            System.out.println(o);
        }
    
        public static void main(String[] args) {
            // 当 hello 方法执行完毕后,o 的作用域结束,
            // o 引用的 Object 对象不再由任何 GC Roots 引用(假设没有其他引用指向这个对象),o 会被判定为可回收对象
            new Test().hello();
        }
    
    }
    
      1. 本地方法栈中 JNI 的引用

// 假设的JNI方法
public native void nativeMethod();

// 假设在C/C++中实现的本地方法
/*
 * Class:     NativeExample
 * Method:    nativeMethod
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject thisObj) {
    // 在本地方法栈中创建JNI引用
    jobject localRef = (*env)->NewObject(env, ...); 
    // localRef 引用的Java对象在本地方法执行期间是活跃的
}
    1. 类静态变量
public class StaticFieldReference {
    // 类静态变量,这个引用存储在元空间,可以被认为是 GC Roots
    // 只要 StaticFieldReference 类未被卸载,staticVar 引用的对象都不会被垃圾回收
    private static Object staticVar = new Object(); 

    public static void main(String[] args) {
        System.out.println(staticVar.toString());
    }
}
// 如果 StaticFieldReference 类被卸载(这通常发生在其类加载器被垃圾回收时),那么 staticVar 引用的对象也将有资格被垃圾回收(如果没有其他引用指向这个对象)
  • 4。 运行时常量池中的常量(String 或 Class 类型)
public class ConstantPoolReference {
    // 可以用来作为 GC Roots
    // 常量,存在于运行时常量池中
    public static final String CONSTANT_STRING = "Hello, World"; 
    // Class类型常量
    public static final Class<?> CONSTANT_CLASS = Object.class;
    // 只要包含这些常量的 ConstantPoolReference 类未被卸载,这些对象就不会被垃圾回收

    public static void main(String[] args) {
        System.out.println(CONSTANT_STRING);
        System.out.println(CONSTANT_CLASS.getName());
    }
}

Stop The World


  • "Stop The World"是 Java 垃圾收集中的一个重要概念。在垃圾收集过程中,JVM 会暂停所有的用户线程,这种暂停被称为"Stop The World"事件。

  • 是为了防止在垃圾收集过程中,用户线程修改了堆中的对象,导致垃圾收集器无法准确地收集垃圾。

垃圾收集算法


标记清除算法

  • 标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为 2 部分,先把内存区域中的这些对象进行标记,哪些属于可回收的标记出来(用前面提到的可达性分析法),然后把这些垃圾拎出来清理掉。

  • 它存在一个很大的问题,那就是内存碎片。碎片太多可能会导致当程序运行过程中需要分配较大对象时,因无法找到足够的连续内存而不得不提前触发新一轮的垃圾收集

复制算法

  • 复制算法(Copying)是在标记清除算法上演化而来的,用于解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

  • 当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样就保证了内存的连续性,逻辑清晰,运行高效。

标记整理算法

  • 标记整理算法(Mark-Compact),标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

  • 标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法差很多。

分代收集算法

  • 分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

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

  • 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理算法来进行回收。

新生代和老年代


  • 堆(Heap)是 JVM 中最大的一块内存区域,也是垃圾收集器管理的主要区域。

  • 堆主要分为 2 个区域,年轻代老年代,其中年轻代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区

Eden区

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

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

Survivor区

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

  • 如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。

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

  • 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 是无碎片的。

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。可通过 java -XX:+PrintFlagsFinal -version | grep MaxTenuringThreshold 查看默认的阈值。
    • 动态对象年龄:JVM 并不强制要求对象年龄必须到 15 岁才会放入老年区,如果 Survivor 空间中某个年龄段的对象总大小超过了 Survivor 空间的一半,那么该年龄段及以上年龄段的所有对象都会在下一次垃圾回收时被晋升到老年代。
posted @ 2024-07-17 13:38  n1ce2cv  阅读(5)  评论(0编辑  收藏  举报