标记阶段

对垃圾进行GC回收之前,首先需要区分内存中那些是存活的对象,那些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会执行垃圾回收,释放掉所占用的空间,这个阶段过程,我们称之为垃圾标记阶段

判断一个对象是否存活主要有两种方式:引用计数法和可达性分析算法。

引用计数法

  • • 对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况。
  • • 对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失败的时候,引用计数器就减1,只要A的引用计数器的值为0,则表明A不可再被使用,可进行回收。

优点

  • • 实现简单,垃圾对象便于识别
  • • 判定效率高,回收没有拖延性

缺点

  • • 需要单独字段存储计数器,增加了内存的开销
  • • 每次赋值需要更新计数器,伴随加法减法操作,增加了时间开销
  • • 无法处理循环引用的问题,是一条致命缺陷,导致了Java中没有使用该类算法

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_老年代


代码示例:

public class RefCountGC {
    //这个成员属性唯一的作用就是占用一点内存
    private byte[] bigSize = new byte[5 * 1024 * 1024];//5MB
    Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();
        obj1.reference = obj2;
        obj2.reference = obj1;
        obj1 = null;
        obj2 = null;
        //显式的执行垃圾回收行为
        //这里发生GC,obj1和bj2能否被回收?
        System.gc();
    }
}

JVM垃圾收集器(二) ------ 垃圾收集相关算法_引用计数_02


如果不下小心直接把obj1-reference和obj2-reference置null。则在Java堆当中的两块内存依然保持着互相引用,无法回收。

小结

  • • Java没有使用引用计数法,因为他存在着循环引用问题,很难处理
  • • Python使用了引用计数法
  • • Python手动的解决了循环引用的问题。就是在合适的时机,解除引用关系
  • • 使用弱引用weakref。是Python的标准库,旨在解决循环引用

可达性分析算法

又称之为:根搜索法,追踪性垃圾收集

目的:解决了引用计数法中循环引用的问题,防止了内存泄露的发生。

基本思路

  • • 以跟对象集合(GC Root)为起始点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达;
  • • 使用可达性分析算法之后,内存中存活的对象都会被根对象集合直接或者间接的连接着,搜索所走过的路径称之为引用链;
  • • 如果目标没有任何应用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象;
  • • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活的。

图解:


GC Root

GC Root包含的元素:

  • • 虚拟机栈中的引用对象 ------ 比如各个线程被调用的方法中使用到的参数、局部変量(局部变量表中的内容),临时变量
  • • 本地方法栈内JNI (通常说的本地方法)引用的对象
  • • 方法中类静态属性引用的对象 ------ 比如: java类引用类型静恋変量
  • • 方法区中的常量引用的对象 ------ 比如:宇符串常量池中(String Table)里面的引用
  • • 方法区中的静态属性引用的对象 ------ Java类的引用类型静态变量
  • • 所有被同同步synchronize持有的对象
  • • Java虚拟机的内部引用 ------ 基本数据类型对应的Cass对象,一些常驻的异常对象(如NullPointerException,OutOfMemoryrror) ,系统类加载器
  • • 反虚Java虚拟机内部情况的JXMBeanJVMTI中注册的回调,本地代码缓存等。

小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那就是一个Root(也就是说存在于堆中,但是存在外部引用)。

注意:

  • • 如果要使用可达性分析算法来判断内存是否可以被回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不能满足的话分析结果准确性就无法得到保障;
  • • 这点就是导致GC进行时必须"Stop The World"的一个重要原因 ------- 即使号称不会发生停顿的CMS收集器,枚举根节点时候也必须要停顿。

清除阶段

当成功区分出内存中存活对象和死亡对象之后,GC接下来的任务就是对执行垃圾进行回收,释放掉所占用的内存空间。

清除对象的几种方式

标记-清除算法(Mark-Sweep

执行过程:当堆的有效内存空间被耗尽的时候,就会停止整个程序(SWT),然后进行两项工作(标记,清除)

  • • 标记 ------ Collector从引用根节点开始遍历,标记所有被引用的对象,一般是在对象头中标记为是可达对象
  • • 清除 ------ Collector从堆内存中从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为是可达对象,则将其回收

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_老年代_03


缺点:

  • • 效率不算高(如果包含大量的对象,且大部分是需要被回收的,那么就需要进行大量标记和清除的动作,导致执行效率随对象增长而降低)
  • • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • • 清理出来的空间不是连续的,会产生内存碎片,需要维修一个空闲列表

比较适合比如堆空间对象数量少并且存活时间长的回收

注意:这里指的置空并不是真正的置空,而是把需要清除的对象保存在空闲列表里面,下次加载,先判断该内存地址是否足够,如果够,就存放。

标记-复制算法(Copying)

主要用于解决标记-清除算法面对大量可回收对象时执行效率低的问题。

核心:它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。在垃圾回收的时候将内存中存活的对象复制到另一块未被使用的内存中,之后清除刚刚使用的内存块的所有对象,交换内存角色,完成回收。

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_老年代_04


优点:

  • • 没有标记清除过程,实现简单,运行高效
  • • 复制过去保证空间的连续性,不会产生内存碎片

缺点:

  • • 需要两倍的内存空间
  • • 当对象复制,导致地址改变,需要维护对象的引用关系。比如局部变量表中的对象引用需要从原来的改为复制过来的地址
  • • 如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销

特别的:如果系统中,垃圾对象很多,复制算法需要复制的存活的对象并不是很大,比如年轻代。应用场景:新生代中,对常规的垃圾进行回收(幸存者区)

在新生代中很好的解决了空间浪费这个问题:新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将EdenSurvivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认EdenSurvivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被“浪费”的。当然,98%的对象可被回收仅仅是“普通场景”下测得的数据,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此回收还有一个充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_垃圾收集_05


标记-压缩算法(Mark-Compact

为了解决标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低问题。

执行过程:

  • • 第一阶段和标记清除算法一样,从根节点开始标记所有被引用的对象
  • • 第二阶段讲所有的存活的对象压缩到内存的一端,按照顺序排放
  • • 之后清理所有的内存空间
  • • 故也可以叫:标记-清除-压缩算法

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_垃圾收集_06


优点:

  • • 消除了标记清除算法中,内存区域分散的缺点,我们需要给新的对象分配内存时,JVM只需要持有一个内存的起始地址即可
  • • 消除复制算法中,内存减半的高频代价

缺点:

  • • 效率低于复制算法
  • • 移动对象的同时,如果被其他对象引用,则需要调整引用的地址
  • • 移动的过程中,需要全程暂停用户行为,STW

算法比较


Mark-Sweep

Mark-Compact

Copying

速度

中等

最慢

最快

空间开销

少(但是会堆积碎片)

最少(但是不会堆积碎片)

需要两倍空间(不会堆积碎片)

移动对象




分代收集算法

分代收集算法建立起来的原因:

  • • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的;
  • • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
  • • 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。(该条主要是因为比如在新生代内进行垃圾收集(Minor GC),但是新生代中的对象完全有可能在老年代中被使用,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样)

采用分代的话我们可以分情况分区域来考虑垃圾收集效率的问题:如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那 么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有 效利用。

  • • 年轻代

特点:区域比老年代小,对象生命周期短,存活效率低,回收频繁

使用算法:复制算法 ------ 速度最快(对于空间问题的话使用survivor的设计得以解决)

垃圾收集器:Minor GC/Young GC

  • • 老年代

特点:区域较大,对象生命周期长,存活率高,回收没有年轻代频繁

使用算法:标记清除或者标记压缩混合使用

垃圾收集器:Major GC/Old GCFull GC

解决假说第三点的跨代引用问题:

  • • 问题出现:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以
    消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。但是,如果我们为了这老年代的少量的跨代引用就去扫描整个老年代,就太过于浪费空间与时间了。
  • • 解决:在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

增强收集算法

  • • 出现原因:为了尽量减少stw出现的时长
  • • 基本思想
  • • 如果一次性将所有垃圾进行处理,需要造成系统长时间停顿,难么,我们就让用户线程和垃圾收集线程交替执行
  • • 每次垃圾收集只是收集一小块的区域的内存空间,接着切换到应用程序的线程。依次反复,直到垃圾收集完成。
  • • 总的来说,增量收集算法的基础任然是传统的标记清除和复制算法,增量收集算法基础仍然是标记清除和复制算法,增量收集算法通过对线程冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记,清理或者复制工作。
  • • 缺点:使用该方式,由于垃圾回收过程中,间断性的还执行了引用程序代码,所有能够减少系统停顿的时间。但是,因为线程切换和上下文转化的消耗,会使得垃圾回收的总成本上升,造成系统吞吐量下降。

分区算法

  • • 般来说堆空间越大,一次性GC的时间就会越长,有关GC产生的停顿时间就越长,为了更好的控制GC产生的停顿时间,将一个内存很大的区域分割成多个小块,根据目标的停顿时间,每每次合理的回收若干个小区域,而不是整堆回收,从而减少一次GC所产生的停顿
  • • 分代算法将按照对象的生命周期长短划分为两个部分,分区算法将整个堆空间划分为连续的不同小区间
  • • 每一个小区间都独立使用,独立回收,这个算法的好处就是可以控制一次回收多少个小区间

图示:

JVM垃圾收集器(二) ------ 垃圾收集相关算法_引用计数_07