垃圾收集器与内存分配策略

垃圾收集器与内存分配策略

概述

我们先提出三个问题:

哪部分内存需要回收?

什么时候进行回收?

如何进行回收?

生存还是死亡?

where

​ 我们都知道在Java中,栈,本地方法区,程序计数器都是线程私有的,随着线程的创建和结束,内存也会自动的分配和销毁,执行的方法也随着栈帧的插入和弹出而创建和销毁。所以这部分区域我们不必担心。

​ 而方法区(元空间)和堆空间,是线程共享的,这部分区域的内存就是我们需要去进行垃圾回收的区域。

when

​ 什么时候进行垃圾回收,就需要我们判断这个对象是否仍被引用,如果没有一个指针指向它,那我们就可以放心的进行垃圾回收,如果仍被引用,那就不行(你杀根本干嘛,这人我正用着呢),关于对象引用判断分为两种:

引用计数法

​ 就是说一个人引用,就对一个引用计数器加一,如果这个人不引用了,我们就减一,当引用计数器等于0的时候,我们就可以判定,可以被垃圾清理了。

该方法易理解,且实现简单。但一个缺点就是遇到循环依赖,不好处理。在主流的java虚拟机中都没有使用该方法进行判断。下面用代码进行说明。

class referenceCountingGC {
  Object instance = null;
  /**
   * 这里创建一个字节数组只是为了,占一些内存,方便后面看GC情况。
   */
  static final int _1mb = 1024*1024;
  private byte[] bigSize = new byte[2*_1mb];

  /**
   * -XX:+PrintGCDetails 
   * 输出GC详情
   * @param args
   */
  public static void main(String[] args) {
    referenceCountingGC A = new referenceCountingGC();
    referenceCountingGC B = new referenceCountingGC();
    A.instance = B;
    B.instance = A;

    A=null;
    B=null;

    /**
     * [Full GC (System.gc()) 
     * [PSYoungGen: 496K->0K(38400K)]
     * [ParOldGen: 8K->424K(87552K)]
     * 504K->424K(125952K
     * ), [Metaspace: 3208K->3208K(1056768K)],
     * 0.0135739 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
     * 
     * 我们看到即使两个对象相互引用,但JVM还是进行回收了,侧面反映出JVM没有使用
     * 引用技术法来判断对象是否存活
     */
    System.gc();
  }
}
可达性分析法

​ 简单来说我们将对象之间相互的引用想象成一个树结构,其中根对象为GC roots(有一系列的根对象),树结构中节点与节点之间形成链,当我们寻找一个对象时,从GC roots往下走,该路径为引用链,如果一个对象无法在引用链中找到,就是不可达的,我们就对可以进行垃圾回收。

请添加图片描述

​ 这里我们可以看到虽然object5,object6,object7之间相互引用,但是在引用链外,所以也是要判定为需要垃圾回收的。

有哪些可以对象可作为GC roots呢?

  1. 在虚拟机栈(局部变量表)中引用的对象,也就是当前方法正在使用的参数,局部变量,临时变量。
  2. 方法区中静态属性引用对象。(类的引用型静态变量)
  3. 方法区中常量引用对象。譬如字符串常量池中引用的对象。
  4. 本地方法栈中JNI(Native方法)引用对象
  5. Java虚拟机内部引用对象,如基本数据类型对应的Class对象,一些异常对象,类加载器对象。
  6. 被同步锁持有的对象
  7. 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存。

除了上述固定的GC Roots集合外,根据用户所选的垃圾回收器以及当前回收的内容区域不同,还有其他对象临时加入,共同构建GC Roots集合。比如:分代收集,局部回收。

再谈引用

​ 最初在JKD1.2版本之前,引用的概念就是引用与非引用,但现在我们希望能够再细分一些,比如一些对象我们希望在内存足够时,你就继续待着,如果内存不够了,那我们就清理掉(这么看与裁员是一样的)。很多系统的缓存功能都符合这样的应用场景。

​ 在JDK1.2后,Java对引用的概念进行扩充,分为强引用,软引用,弱引用,虚引用

  • 强引用就是最原始的概念Object o1 = new Object(),只要强引用关系还在,就不会清除
  • 软引用是指一些有用,但非必要的对象,在系统放生内存溢出前,将这些对象放入回收范围之中,进行第二次的回收,如果这次回收没有足够的内存,就会抛出内存溢出异常,java中使用SoftReference类实现软引用。
  • 弱引用指分必要对象,只会撑到下次垃圾回收之前,无论内存够不够,都会清理,java中使用WeekReference类实现
  • 虚拟用(幽灵引)一个对象有没有虚引用都不影响是否被清理,它的作用是指当清理时,系统收到一个通知。java中使用PhantomReference类实现

how

​ 当进行可达性分析后,一个对象被判定为不可达,也不是非死不可,第一次判断完后,该对象被一次标记,现在可以算的上缓期,随后再进行一次筛选,判断此对象是否有必要执行finalize()方法?

如果对象没有覆盖finalize()方法,或者finalize()方法已经被执行了,就判定为没必要执行

​ 如果该对象被判定为有必要执行执行finalize()方法,对象会被放到F-Queue的队列中,随后一条由虚拟机创建的,低调低优先级的Finalizer线程会去执行他们的finalize()方法,这里的执行时指启动finalize()方法,并不一定等待到运行结束,因为如果finalize()执行缓慢,甚至发生死循环,则其他对象无法被删除,可能导致内存回收子系统的崩溃,finalize()是最后一次救自己的机会,随后还会有第二次标记,只要在这之前,将自己与引用链上重新关联,那再第二次标记时就会踢出即将回收的队列,如果这个时候还没逃走,那就得被清理了。下面用代码演示:

public class FinalizeEscapeGC {
    private static FinalizeEscapeGC SAVE_HOOK=null;

    public void  isAlive() {
        System.out.println("yes i am still alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed !" );
        //进行自救
        FinalizeEscapeGC.SAVE_HOOK = this;
        System.out.println("进行自救:"+FinalizeEscapeGC.SAVE_HOOK.hashCode());
    }
    
    public static void main(String[] args) throws InterruptedException {
        FinalizeEscapeGC escapeGC = new FinalizeEscapeGC();
        System.out.println("创建的对象:"+escapeGC.hashCode());
        escapeGC = null;
        //第一次成功救出自己
        System.gc();
        //因为  Finalizer方法优先级低,我们等一下
        Thread.sleep(500);
        if (FinalizeEscapeGC.SAVE_HOOK != null) {
            FinalizeEscapeGC.SAVE_HOOK.isAlive();
        } else {
            System.out.println("i am dead");
        }

        //下面代码和上面一样
        FinalizeEscapeGC.SAVE_HOOK = null;
        System.gc();
        //因为  Finalizer方法优先级低,我们等一下
        Thread.sleep(500);
        if (FinalizeEscapeGC.SAVE_HOOK != null) {
            FinalizeEscapeGC.SAVE_HOOK.isAlive();
        } else {
            System.out.println("i am dead");
        }
    }
}

​ 运行后我们发现第一次gc后,该对象还存活,因为第一次执行了finalize()方法时,我们将对象本身赋值给了类变量,但是当第二次gc时,对象就彻底清除了,因为finalize()只执行一次。

最后声明一点,不要使用finalize()来拯救对象,官网也不推荐,只是在这里让大家知道它是干嘛的就行了。

回收方法区

​ 在《Java虚拟机规范》中并未要求堆方法区实现垃圾回收,所以有的虚拟机版本确实没有回收方法区,而且方法区回收也是比较苛刻的。

​ 方法区垃圾回收主要回收两类:废弃的常量,类型信息。

常量的垃圾回收和上面的类似,主要判断一个类型是否不再被使用比较苛刻,需要满足下面三点:

  1. 该类所有实例,包括派生子类的实例都已经被回收。
  2. 加载该类的类加载被回收了。
  3. 该类对应的java.lang.Class对象没有被任何引用。

满组上面三点,也不一定被回收,下面介绍一些相关虚拟机参数:

  1. -Xnoclassgc 控制是否对类型回收。
  2. -verbose:class -XX:+TraceClassLoading查看类加载和卸载信息(需要Product版虚拟机)
  3. -XX:+TraceClassUnLoading(需要FastDebug版虚拟机)

什么时候需要回收方法区?

​ 在大量使用反射,动态代理,CGLib等字节码框架,会动态创建很多类,通常都需要Java虚拟机具有类型卸载的能力。保证不会堆方法区有太大的压力

垃圾收集算法

​ 在上面我们已经知道判定一个对象是否会被回收可通过引用计数式追踪式(可达性分析)两种算法,而引用计数式在主流的垃圾回收策略中都没使用,所以我们下面讨论的还是追踪式算法。

第一个问题:我们每次都是直接对整个堆进行可达性分析和垃圾回收吗?

​ 不可行,这会导致消耗大量的时间。大多数的垃圾回收都遵从“分代收集”的理论进行设计,而它是建立在两个分代假说上的。

​ > 若分代假说:绝大多数对象都是朝生夕死的

​ > 强分代假说:熬过越多次垃圾回收的对象,越难以消亡

​ 这两个分代假说共同奠基了一个设计原则:收集器应该将Java堆划分出不同的区域。通俗的讲我们把新生对象放在一起,把老不死的(戏称)放在一起,通过回收对象的年龄(熬过的回收次数)放在不同的区域,每次只针对新生代进行垃圾回收,老年代偶尔进行一次回收。针对不同的区域收集,也就有了Minor GC ,Major GC ,Full GC回收类型的划分。

​ 在划分区域后,根据不同区域以及区域内元素的特点,我们使用不同的垃圾收集算法,且他们都是基于分代收集理论。后面我们会详细说明。

​ > 标记-清除算法

​ > 标记-复制算法

​ > 标记-整理算法

第二个问题:对象不是孤立存在的,如果对象之间存在跨代引用怎么办?

​ 当我们针对新生代垃圾回收时,因为存在跨代引用,我们还得遍历老年代保证可达性分析结果的正确。这肯定也会对内存回收带来负荷,为了解决这个问题,我们对分代收集理论添加第三条经验法则:

​ > 跨代引用假说:跨代引用相比同代引用要少的多。

​ 其实根据前面法则,我们也能知道如果针对存在跨代引用,那么新生代的对象在经历过多轮垃圾回收后,也会被放到老年代的,这样问题就解决了,但在之此前我们怎么减少对老年代分析的性能消耗?,我们可以依据跨代引用假说,我们就可以不用对整个老年代进行扫描,只需要在新生代上建立一个全局的数据结构(记忆集,Remembered Set),这个结构把老年代划分成若干个小块儿,标识出老年代的哪一块儿内存存在跨代引用,这样每次对新生代垃圾回收时,只用把老年代这片内存的对象加入到GC Roots即可。虽然在对象改变关系时维护数据的准确性,但比起扫描整个老年区还是划算的。

这里我们需要确定一些回收类型的概念

部分收集(Partial GC):目标不是完整的收集整个Java堆,其中又细分为:

新生代收集(Minor GC):只收集新生代

老年代收集(Major GC):只收集老年代,只有CMS收集器有单独收集老年代的行为(这里有一些概念上的混淆,有人会把Major GC 和 Full GC混淆,需要我们自己判断说的到底是哪个)

混合收集(Mixed GC):收集新生代和部分老年代,目前只有G1收集器有

整堆收集(Full GC):收集整个堆区域和方法区

标记-清除算法

​ 这是一个最早期最基础的算法,也就是我们先标记哪些对象需要清除,然后再统一进行清理,当然我们也可以只标记哪些需要保留,将没有标记的进行统一的清理,但是这个算法存在两个问题:

  1. 执行效率不稳定:如果有大量对象需要清理或者大量对象需要留下,那我们在标记和清除上花的时间就越多。
  2. 内存空间碎片化问题,也就是说在我们标记,清除后会产生大量不连续的内存碎片,当后面再分配大对象时,无法分配到空间,而再进行一次GC。

请添加图片描述

标记-复制算法

​ 也被称为复制算法,最初理论为内存按照容量分成1:1两块,每次只使用一块儿,当一块儿不够用时,在分析后,将保留的对象标记并复制到另一半区域,移动堆顶指针,按照顺存放,然后直接清除之前的区域。这样就不用担心空间零散的碎片。不过缺点也很明显,你直接分一半,也太浪费空间了,导致频繁的标记复制。请添加图片描述

​ 后面又进行了一次优化:Appel式回收,新生代分为一个较大的Eden区域,和两个较小的Survivor区,默认比例为8:1:1,也就是每次我们只使用Eden区和一个Survivor区,当垃圾回收时,将保留的对象都放在另一个Survivor区域,然后直接堆之前的区域清理。

第三个问题:那假如这次清理完,发现有大量的对象需要保存,但是一个Survivor区不够,怎么办?

​ 这里就设计了一个安全设计,当一个Survivor区不够,就需要使用老年代来做分配担保,将这些对象(超出的部分)直接交给老年代。不过我们最好还是用代码测试一下:

public class demo {
        private static byte[] a1;
        private static byte[] a2;
        private static byte[] a3;
    /**
     * -XX:NewSize=10485760—新生代大小为10m
     * -XX:MaxNewSize=10485760—新生代最大大小为10m
     * -Xms20M—初始堆大小为20m
     * -Xmx20M—最大堆大小为20m
     * -XX:+UseParNewGC:新生代使用ParNewGC垃圾回收器
     * -XX:+UseConcMarkSweepGC---老年代使用CMS
     * -XX:+PrintGCDetails---打印GC详细日志
     * -XX:+PrintGCTimeStamps—打印GC时间
     * -XX:SurvivorRatio=8—设置eden区和survivor区的比例为8:1:1
     * -XX:PretenureSizeThreshold=10485760—设置最大对象的阈值为10m
     *
     * @param args
     */
    public static void main(String[] args) {
        /**
         * 当前为6m时,可以将对象全部放在eden区
         * par new generation   total 9216K, used 8000K
         * eden space 8192K,  97% used
         * from space 1024K,   0% used
         * concurrent mark-sweep generation total 10240K, used 0K
         */
        a1 = new byte[1024 * 1024];//1m

        /**
         * 可以发现当再添加189k时,发生了第一次gc,
         * 如果Survivor区放不下存活对象,存活对象并不是全都进入老年代,而是部分对象进入老年代,部分对象继续被分配到Survivor区
         * par new generation   total 9216K, used 720K
         * eden space 8192K,   1% used
         * from space 1024K,  62% used
         * concurrent mark-sweep generation total 10240K, used 6146K
         */
        a2 = new byte[189* 1024];
        //直接分配一个5m的对象,由于eden区只有8m,之前已经分配了的再加上一些未知对象也会占据一定的内存空间,此时必然会引起新生代gc
        a3 = new byte[5 * 1024 * 1024];//5m
    }
}

​ 可以发现在上面的实验情况与我们想象的并不一样,结论就是:如果Survivor区放不下存活对象,存活对象并不是全都进入老年代,而是部分对象进入老年代,部分对象继续被分配到Survivor区。下面还有一种情况我们用代测试:

class demo2 {
    /**
     * 此时从红框中的信息可以清晰的发现,from区被占用率为0%,
     * 而老年代空间则被使用了26m左右,存活对象还是25m,逻辑没变,
     * 那么这情况就可以表明25m对象在新生代gc后都进入了老年代。
     * 
     * 
     * 结论:新生代gc后,如果触发了老年代gc,即使survivor区放的下部分存活对象,对象也会全部进入老年代。
     * @throws InterruptedException
     */
    public static void loadData() throws InterruptedException {
        //一开始我们就分配了80m,相当于一个eden区
        byte[] data0 = new byte[80 * 1024 * 1024];
        data0 = null;
        byte[] data = null;
        //请求40m
        for (int i = 0; i < 4; i++) {
            //每个请求10m
            data = new byte[10 * 1024 * 1024];
        }
        data = null;
        byte[] data1 = new byte[5 * 1024 * 1024];
        byte[] data2 = new byte[10 * 1024 * 1024];
        byte[] data3 = new byte[10 * 1024 * 1024];
        data3 = new byte[10 * 1024 * 1024];
        data3 = new byte[10 * 1024 * 1024];
    }

    /**
     * -XX:NewSize=104857600—新生代大小为100m
     * -XX:MaxNewSize=104857600—新生代最大大小为100m
     * -Xms200M—初始堆大小为200m
     * -Xmx200M—最大堆大小为200m
     * -XX:+UseParNewGC:新生代使用ParNewGC垃圾回收器
     * -XX:+UseConcMarkSweepGC---老年代使用CMS
     * -XX:+PrintGCDetails---打印GC详细日志
     * -XX:+PrintGCTimeStamps—打印GC时间
     * -XX:SurvivorRatio=8—设置eden区和survivor区的比例为8:1:1
     * -XX:PretenureSizeThreshold=104857600—设置最大对象的阈值为100m
     * @param args
     * @throws InterruptedException
     */
    public static void main(String[] args) throws InterruptedException {
        /**
         * 上述代码一开始就被分配了80m的大对象,所以这个对象会直接进入老年代占据80m空间,
         * 那老年代就只剩20m空间了。肯定不够分配新生代GC后的存活下来的25m对象,就会触发full gc。
         */
        loadData();
    }
}

​ 上面测试的结论是:新生代gc后,如果触发了老年代gc,即使survivor区放的下部分存活对象,对象也会全部进入老年代。

标记-整理算法

​ 现在我们是针对的老年代对象的存亡特征,其中标记的过程仍然是"标记-清除",但后续不是直接清除可回收对象,而是让所有存活对象都向内存空间一端移动,然后直接清理掉边界值以外的内存,这样也不用担心空间碎片的问题了。但是缺点也很明显,老年代中大部分都向都是存活的,如果移动并更新引用它们的地方,也是非常负重的操作。而且必须暂停所有的用户应用程序才能进行。

​ 所以如果是使用标记-清除算法,内存分配会很复杂,使用标记-整理算法,内存回收时很复杂。Hotspot虚拟机里面关注吞吐量的Parallel Old收集器基于标记-整理算法的,关注延迟的CMS收集器是基于标记-清除的。具体详细内容可以去深入了解。

​ 还有一种和稀泥的方法,前面一直使用标记-清理算法,暂时容忍空间碎片,当影响到对象分配时,再进行标记-整理算法。前面提到的CMS收集器遇到碎片过多时,采用的就是这个方式。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值