【Java学习笔记】垃圾收集器和内存分配策略

596 阅读10分钟

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

如何判断对象的"死活"

  1. 引用计数算法

    • 在对象中添加一个引用计数器,每有一个地方引用它时,计数器+1;引用失效的时候,计数器值-1;任何时刻计数器为0的对象就是不可能再被使用的
    • 存在问题需要大量额外处理,比如循环引用的问题
    • 以下代码执行完后从GC日志中可以看到相关objA和objB被回收,说明Java使用的不是引用计数算法
    public class ReferenceCountingGC {
        public Object instance = null;
        
        private static final int _1MB = 1024 * 1024;
        
        /**
         *  这个成员属性的意义是占点内存,以便在GC日志中看清楚是否有回收过
         */ 
        private byte[] bigSize =new byte[2 * _1MB];
        
        public static void testGC() {
            ReferenceCountingGC objA = new ReferenceCountingGC();
            ReferenceCountingGC objB = new ReferenceCountingGC();
            objA.instance = objB;
            objB.instance = objA;
            
            objA = null;
            objB = null;
            
            System.gc();
        }
    }
    
  2. 可达性分析算法

    可达性算法分析

    • "GC Roots"根对象作为起始节点集,根据引用关系向下搜索,搜索过程走过的路径为"引用链"(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连(GC Roots到对象不可达),此对象不可能再被使用
    • 可以被使用为GC Roots的对象
      • 在虚拟机栈中引用的对象(如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量)(虚拟机栈中的栈帧都是还没有执行完的方法形成的栈帧,这些方法中的局部变量、参数变量、临时变量肯定不能GC掉)
      • 在方法区中类静态属性引用的对象(如Java类中的引用类型静态变量)
      • 在方法区中常量引用的对象(如字符串常量池里的引用)
      • 在本地方法栈中JNI(Native方法)引用的对象
      • Java虚拟机内部的引用(基本数据类型对应的Class对象)
      • 所有被同步锁(synchronized关键字)持有的对象
      • 反映Java虚拟机内部情况的JMXBean、JVMTI中的注册的回调、本地代码缓存等
    • 内存区域是虚拟机自己的实现细节,不是独立封闭的,进行可达性分析时候需要将关联区域的对象加入GC Roots集合中去

引用

  • 分类:强引用、软引用、弱引用、虚引用
    • 强引用:reference类型的数据中储存的数值代表的是另外一块内存的起始地址(Object obj = new Object(),obj就是强引用,存放在虚拟机栈中,无论任何情况下,只要强引用关系还存在,垃圾收集器就不会回收掉被引用的对象)
    • 软引用:还有用,但非必须的对象。在OOM发生之前,会将这些对象放进回收范围进行二次回收(SoftReference实现软引用),如果此次回收还没有足够内存才会抛出OOM
    • 弱引用:非必须对象,强度比软引用更弱,当垃圾收集器开始工作时候,无论当前内存足够,都会回收
    • 虚引用最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对生存时间构成影响,主要作用是为了垃圾收集器回收时收到一个系统通知(PhantomRefernece类实现虚引用)

生存还是死亡判断?

  • 两次标记法

    • 当对象被判定为不可达时,并非一定要被清理,要进行两次标记之后才能宣告一个对象真正“死亡”

    • 当第一次可达性分析后没有与GC Roots相连接的引用链,会被第一次标记,随后再进行一次筛选

      • 没必要执行finalize()方法

        • 再次筛选的时候是否有必要执行finalize()方法,假如没有finalize()或已经被虚拟机调用过,则“没有必要执行”
      • 有必要执行finalize()方法

        • 放置在F-Quene队列中,稍后由虚拟机自动建立的、低优先级的Finalizer线程去执行队列中finalize()方法.执行不代表会等待finalize方法执行完成,防止出现finalize方法执行缓慢或者死循环导致整个F-Queue队列只其他对象处于等待或者整个内存回收子系统崩溃
        • finalize方法是对象最后一次逃脱的机会,对象可以在finalize()里面成功拯救自己如下雨引用链上任何一个对象建立关联,稍后收集器会对F-Queue队列中的对象进行第二次小规模的标记,如果这时候还没有逃脱,基本上就是要面临被回收
        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            FinalizeEscapeGC.SAVE_HOOK = this;
        }
        

        • 任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,则finalize()方法不会被再次执行
        • 运行代价大,不确定性高,无法确保各个对象的调用顺序,不推荐使用

回收方法区

  • 回收内容:废弃的常量和不再使用的类型
  • 判断常量是否废弃:已经没有任何对象引用常量池中的对象,虚拟机中也没有其他地方引用这个字面量
  • 判断一个类型是否属于不在使用三个条件
    • 该类所有的实例已经被回收(Java堆中不存在该类及其任何派生子类实例)
    • 加载该类的类加载器已经被回收
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

辣鸡收集算法

分代收集理论

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:熬过越多次辣鸡手机过程的对象就难以消亡
  • 跨代引用假说:跨代引用相对于同代来说仅占极少数(存在引用关系的对象应该倾向于同时生存或者同时消亡的,例如某个新生代被老年代所引用,该引用会使新生代对象在收集时同样存活,进而进入老年代)
    • 在新生代上建立一个全局的数据结构(记忆集),将老年代划分成若干小块,标识出老年代哪一块内存存在跨代引用,Minor GC时,在跨代引用的内存里的对象才会加入到GC Roots进行扫描
  • 辣鸡收集器一致的设计原则:
    • 收集器应将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄是对象熬过辣鸡手机过程的次数)分配到不同的区域之中储存
    • 如果一个区域中大多数对象都是朝生夕灭,将他们集中到一起,每次回收时只关注少量存货,能以较低代价回收到大量的空间
    • 如果否是难以消亡的对象,把他们集中放在一起,虚拟机用较低评率来回收这个区域,同时兼顾辣鸡收集的时间开销和内存的空间

标记-清除算法

首先标记处所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象或者反过来标记所有存活的相对性,统计回收被标记的对象

缺点:

  • 执行效率不稳定:如果堆中有大量的对象,大部分需要回收,会进行大量标记和清除,效率随对象数量的增长而降低
  • 内存空间的碎片化问题:清楚后产生大量不连续的内存碎片,空间碎片太多导致后期需要分配较大对象时无法找到足够的连续内存导致再次触发另一次辣鸡收集

标记-复制算法

将可用内存容量划分为大小相当的两块,每次使用其中的一块,当这一块内存使用完后,将存货的对象复制到另一块上,再把已使用过的内存空间一次全部清理

缺点:

  • 对于多数对象都是存活的,这种算法会产生大量的内存间复制开销,但对于多数对象都是可回收的,这种算法可以有效解决标记-清除算法带来的内存空间碎片问题
  • 可用内存缩小为了原来的一半,空间浪费太大

多数商用Java虚拟机使用这种收集算法去回收新生代

Appel式回收(建立在新生代有百分之98熬不过第一轮收集假设基础上)

  • 把新生代分为一块较大的Eden空间和两块较小的Survivor空间(8:1)
  • 每次分配内存只使用Eden和其中一块Survivor空间上,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间
  • 特殊情况:不能保证每次只有不多于百分之10的对象存活,使用逃生门设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象,使用其他内存区域进行分配担保(如果另外一块Survivor空间没有足够空间存放上一次新生代手记下来的存活对象,便通过分配担保机制直接进入老年代)

标记-整理算法

标记过程和“标记-清除”算法一样,但后续非将所有可回收对象进行清理,而是让所有存活对象向内存空间一端移动,然后直接清理边界以外的内存

移动和不移动的弊端

  • 移动在老年代每次回收都存在大量对象存活区域,必须暂停用户应用程序才能进行
  • 不移动就会产生“标记-清除”算法大量的碎片化空间,需要使用复杂的内存分配器和内存访问器(如“分区空闲分配链表”)
  • 吞吐量:赋值器(“用户程序”或“用户线程”)和收集器的效率总和
  • 关注吞吐量使用标记-整理算法,关注延时使用标记-清理算法:因为内存分配会比垃圾收集频率更高,总吞吐量会下降,如果进行移动会造成应用程序的停顿,造成延时增长

和稀泥式解决方法:大部分时间使用标记-清楚算法,当内存空间的碎片程度影响到内存分配,再使用标记-整理算法进行收集

内存分配与回收策略

  • 对象优先在Eden分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代(虚拟机会设置一个年龄计数器,储存在对象头中,设置一个年龄上限,在survivor区待到了这个年龄上限就会从survivor区进入老年代)
  • 动态对象年龄判定(如果Survivor空间中相同年龄所有对象大小综合大于Survivor空间的一般,年龄大于或等于该年龄的对象就可以直接进入老年代)
  • 空间分配担保
    • 首先检查老年代最大可用的连续空间是否大于新生代所有空间,条件成立贼此次Minor GC可以保证是安全的
    • 不成立检查-XX:HandlePromotionFailuer是否允许担保失败,允许则继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,大于则会尝试进行一次Minor GC,但存在风险
    • 小于或者不允许冒险,则进行Full GC
    • 冒险:新生代复制收集过后出现大量Minor GC之后仍然存活的情况,需要老年代进行分配担保吧把Survivor无法容纳的对象直接送入老年代