Skip to content

垃圾回收算法

垃圾回收

如何判断对象可以回收

引用计数法

为对象添加引用计数器,如果对象被其它对象引用一次,计数器 +1;对应引用释放,则计数器 -1;只有当计数器为 0 时该对象才会被垃圾回收。

  • 引用计数法造成的内存泄漏

    像下面这种即使对象不被其它对象引用,这两个对象也一直不会被回收,因为对象A和B之间存在引用关系,引用计数器一直为 1,这样就导致了内存泄露。

    虚拟机一般不采用该方法判断对象是否可以回收,因为解决不了循环依赖的问题。

可达性分析算法

如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

什么是GC Roots对象?

能被当成 GC Roots 对象的有以下几种:

  1. 虚拟机栈中的引用对象(方法中的引用对象,对应栈帧中的局部变量表),比如方法中的参数、局部变量、临时变量。

    栈帧中的局部变量表里的引用变量,真实对象存在于堆内存中,作为引用对象保证了堆内存中的对象不会被垃圾回收,因为它有被引用。

  2. 方法区中类的静态属性引用的对象,比如类中的引用类型的静态变量。

    方法区中的静态属性是引用变量时,引用的也是堆内存中的对象,能保证引用的堆内存中的对象不会被垃圾回收。

  3. 方法区中常量引用的对象,比如字符串中常量池(StringTable)的引用。

    StringTable 持有对堆内存中字符串对象的引用,能保证字符串对象不会被垃圾回收。

  4. 本地方法栈中 JNI (Native方法)引用的对象。

    本地方法栈中若持有了堆内存对象的引用,也可以保证对内存对象不被回收。

  5. 所有被同步锁(synchronized关键字)持有的对象。

  6. Java虚拟机内部的引用。

    如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

引用的四种类型

  1. 强引用

    强应用就是引用赋值,只要引用关系还在,垃圾回收就不将对象回收。

    永远不会回收

  2. 软引用

    软引用关联的对象,会在系统发生内存溢出前,强制进行回收。若回收完软引用关联的对象,内存依然不足,才会报出内存溢出。

    通过 SoftReference 实现软引用。

    发生内存溢出前会进行回收

  3. 弱引用

    弱引用关联的对象,只能生存到下一次垃圾回收发生为止。当垃圾回收的时候,无论内存是否足够,弱引用关联的对象都会被回收。

    通过 WeakReference 实现弱引用( ThreadLoadl 防止内存溢出就使用了弱引用)。

    垃圾回收时会进行回收

  4. 虚引用

    虚引用是最弱的一种引用关系,一个对象是否存在虚引用,跟对象的生存时间没关系,也无法通过虚引用获取对象实例。

    为对象设置虚引用的唯一目的只是为了该对象在被垃圾回收的时候得到系统的通知。

    通过 PhantomReference 实现虚引用。

    跟垃圾回收时机无关,只是为了垃圾回收时得到系统通知。

终结器引用

终结器引用用以对象实现的 finalize() 方法为主,因为该方法能够实现对象的自我拯救,比如在该方法里面将自己赋值给某个 GC Roots 对象得引用等。

在可达性分析算法分析后判断不可达的对象,并不会直接判定对象可回收。而是判断对象是否需要执行 finalize() 方法,如果执行了该方法之后仍然没有引用,才会真的被回收掉。

当对象没有重写 finalize()方法和 finalize()方法已经被调用过一次时,是不会执行的。

java
public class FinalizeGc {

    private static FinalizeGc save = null;

    public void isAlive(){
        System.out.println("yes");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        //自我拯救
        FinalizeGc.save=this;
    }

    @SneakyThrows
    public static void main(String[] args) {
        save = new FinalizeGc();
        save = null;
        System.gc();
        Thread.sleep(500);
        if(save!=null){
            save.isAlive();
        }else{
            System.out.println("no");
        }

        //只能执行一次,第二次无效
        save = null;
        System.gc();
        Thread.sleep(500);
        if(save!=null){
            save.isAlive();
        }else{
            System.out.println("no");
        }
    }

}

//finalize method executed!
//yes
//no

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

  1. 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾回收算法

三种回收算法

  1. 标记清除

    • 速度快
    • 容易造成内存碎片

  2. 标记整理

  3. 标记-复制

分代回收算法

JVM 采用的是分代回收算法,即不同的分区使用的回收算法不一样。

  1. 年轻代

年轻代采用的是标记-复制算法

  1. 老年代

    Old区采用标记-整理算法。

    在老年代中存储的类生命周期都较长或者为大对象,不适合用复制算法。

垃圾回收流程

1. 新对象生成

新创建的对象默认是会分配到年轻代的Eden区,若是大对象则是直接分配到老年代。

java
#限制大对象的大小
-XX:PretenureSizeThreshold

2. 年轻代垃圾回收

  • 新生成的对象放到 Eden 区。

  • Eden 区满了之后触发 MinorGC,对 Eden 进行复制回收。

    • 不存活的对象被垃圾回收。
    • Eden区和form区存活的对象复制到to区,对象年龄加 1。并且交换from区和to区。
    • MinorGC 会暂停其它用户线程,等垃圾回收结束,才会恢复用户线程。
  • 当对象年龄到达一个阈值。对象会被放到老年代中(并不是必须到达阈值才会)。

    新生代年龄阈值默认是15(4bit,最大表示为 1111 )。

    • XX:MaxTenuringThreshold
  • 当 from区中相同年龄对象占用空间占用from区的一半时,所有大于该年龄的对象会晋升到老年代。

3. 老年代垃圾回收-fullGC

老年代对象有以下几种情况:

  • 大对象直接放入老年代。
  • 年轻代垃圾回收年龄到达限制,晋升到老年代。
  • 发生 MinorGC时候,to 区放不下复制的对象,就会把对象放到老年代中。
  • 动态对象年龄判定

在From空间中,相同年龄所有对象的大小总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象就会被移动到老年代,而不用等到15岁(默认):

老年代的垃圾回收-FullGC

老年代的对象存储的比年轻代多,而且存在较多大对象。对老年代进行垃圾回收时,不适合复制算法。适合的是标记整理算法。标记出存活对象,将所有存活对象向一端移动,来保证内存的连续性。

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

在发生MinorGC 时,虚拟机会检查每次晋升进入老年代的大小是否大于老年代的剩余空间大小,如果大于,则直接触发一次Full GC。

否则,就查看是否设置了-XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行MinorGC,此时可以容忍内存分配失败;如果不允许,则仍然进行Full GC(这代表着如果设置-XX:+Handle PromotionFailure,则触发MinorGC就会同时触发Full GC,哪怕老年代还有很多内存,所以,最好不要这样做)。

FullGC触发条件

  • 老年代空间不足
  • 方法区空间不足
  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

JVM: GC过程总结(minor GC 和 Full GC)-CSDN博客

GC参数

  • XX:+UserSerialGC

    使用串行回收器进行回收,这个参数会使新生代和老年代都采用串行回收器。新生代使用复制算法,老年代使用标记-整理算法。Serial收集器是最基本、历史最悠久的收集器。它是一个单线程收集器,一旦收集器工作,系统会停止。

GC演示

GC配置

java
//新生代10M,eden区8M,from和to区1M。
//老年代10M
-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC +XX:+PrintGCDetails -verbose:gc

演示类

java
public class GcParam {

    private static final int _512KB = 512 * 1024;
    private static final int _1MB = 1024 * 1024;
    private static final int _6MB = 6 * 1024 * 1024;
    private static final int _7MB = 7 * 1024 * 1024;
    private static final int _8MB = 8 * 1024 * 1024;

    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC +XX:+PrintGCDetails -verbose:gc
    public static void main(String[] args) {
        //新生代10M,eden区8M,from和to区1M。
        //老年代10M
        List<byte[]> list = new ArrayList<>();
        //新生代10M,eden区8M。放下这个对象后,再放入其他对象时候会触发一次MinorGC
        //eden区本身有7M,主线程对象放入后,eden区空间不足,触发一次MinorGC,将主线程对象放入from区。
        //此时eden区7M,from区1M。
        list.add(new byte[_7MB]);
        //再放入1M对象,发现eden区放不下,会触发MinorGC。试图将1M对象放入from区,但是from区已经满了,则直接晋升到老年代
        //在触发MinorGC时,eden区的8M的对象由于from区放不下,会直接晋升到老年代。
        list.add(new byte[_1MB]);

        //eden区放不下这个大对象,会直接放到老年代。不会触发GC
        list.add(new byte[_8MB]);
        
        //当添加2个8M的对象时,老年代大小只有10M,放不下会触发Full GC,在Full GC之前会触发一次MinorGC。
        //当Full GC结束后,发现老年代仍然放不下对象,会抛出OOM异常。
        list.add(new byte[_8MB]);

    }

}
  • 连续放入两个 8M 对象的日志。

    8M的大对象 eden 区放不下,因为 eden 区总共就 8M,主线程对象会首先占用一部分 eden区。所有 8M 大对象会直接放入老年代。

    但是老年代总共 10M,在放入第2个8M对象的时候会触发老年代的垃圾回收。

    1. 在触发FullGC之前会触发一次MinorGC,对新生代进行回收,并判断回收后对象能否存入新生代。若还是不行再进行老年代的回收。
    2. 进行FullGC,对老年代进行回收。
    3. FullGC之后,老年代依然无法放入第2个8M大对象,此时才抛出OOM异常。
    java
        public static void main(String[] args) {
            List<byte[]> list = new ArrayList<>();
            //eden区放不下这个大对象,会直接放到老年代。不会触发GC
            list.add(new byte[_8MB]);
            //当添加2个8M的对象时,老年代大小只有10M,放不下会触发Full GC,在Full GC之前会触发一次MinorGC。
            //当Full GC结束后,发现老年代仍然放不下对象,会抛出OOM异常。
            list.add(new byte[_8MB]);
        }
    
    [GC (Allocation Failure) [DefNew: 3712K->1023K(9216K), 0.0026860 secs][Tenured: 8373K->9396K(10240K), 0.0034997 secs] 11904K->9396K(19456K), [Metaspace: 3558K->3558K(1056768K)], 0.0062630 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
    [Full GC (Allocation Failure) [Tenured: 9396K->9378K(10240K), 0.0022996 secs] 9396K->9378K(19456K), [Metaspace: 3558K->3558K(1056768K)], 0.0023284 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    Heap
     def new generation   total 9216K, used 246K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
      eden space 8192K,   3% used [0x00000000fec00000, 0x00000000fec3d890, 0x00000000ff400000)
      from space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
      to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
     tenured generation   total 10240K, used 9378K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
       the space 10240K,  91% used [0x00000000ff600000, 0x00000000fff288e8, 0x00000000fff28a00, 0x0000000100000000)
     Metaspace       used 3592K, capacity 4536K, committed 4864K, reserved 1056768K
      class space    used 398K, capacity 428K, committed 512K, reserved 1048576K
    Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    	at com.albert.javase.jvm.gc.GcParam.main(GcParam.java:36)

    老年代放不下对象时,会进行两次自我拯救。第一次是回收新生代,第二次是回收老年代。若两次回收后依然放不下对象,则抛出OOM异常。

  • 单独线程 OOM

    错误结论:

    堆内存本身是共享的,如果工作线程将堆内存占用满了,那么其他线程就不能工作了。

    如果其它线程不能工作,那么在工作线程抛出OOM之后,主线程继续添加8M对象时,会再次抛出OOM。

    java
        public static void main(String[] args) {
            new Thread(() -> {
                //工作线程内存溢出并不会影响到主线程
                List<byte[]> list = new ArrayList<>();
                list.add(new byte[_8MB]);
                list.add(new byte[_8MB]);
            }).start();
            Thread.sleep(5000);
            List<byte[]> list = new ArrayList<>();
            list.add(new byte[_8MB]);
            System.out.println("主线程正常工作");
        }
    
    [GC (Allocation Failure) [DefNew: 5806K->1024K(9216K), 0.0024938 secs][Tenured: 8576K->9598K(10240K), 0.0027493 secs] 13998K->9598K(19456K), [Metaspace: 4442K->4442K(1056768K)], 0.0052991 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
    [Full GC (Allocation Failure) [Tenured: 9598K->9542K(10240K), 0.0022562 secs] 9598K->9542K(19456K), [Metaspace: 4442K->4442K(1056768K)], 0.0022871 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space
    	at com.albert.javase.jvm.gc.GcParam.lambda$main$0(GcParam.java:51)
    	at com.albert.javase.jvm.gc.GcParam$$Lambda$1/1834188994.run(Unknown Source)
    	at java.lang.Thread.run(Thread.java:748)
    [GC (Allocation Failure) [DefNew: 1348K->333K(9216K), 0.0028144 secs][Tenured: 9542K->1382K(10240K), 0.0095047 secs] 10890K->1382K(19456K), [Metaspace: 4975K->4975K(1056768K)], 0.0124296 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
    主线程
    Heap
     def new generation   total 9216K, used 398K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
      eden space 8192K,   4% used [0x00000000fec00000, 0x00000000fec638e8, 0x00000000ff400000)
      from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
      to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
     tenured generation   total 10240K, used 9574K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
       the space 10240K,  93% used [0x00000000ff600000, 0x00000000fff599e0, 0x00000000fff59a00, 0x0000000100000000)
     Metaspace       used 4982K, capacity 5042K, committed 5248K, reserved 1056768K
      class space    used 557K, capacity 591K, committed 640K, reserved 1048576K

    正确结论:

    当工作线程抛出OOM后,它所占据的内存资源会被释放,从而不会影响到其它线程。

但是测试结果发现,主线程在工作线程OOM之后,可以正常添加 8M对象。