JVM——垃圾回收算法

概述

垃圾回收主要集中于堆与方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。

对象存活判断

要对对象进行垃圾回收,首先要判断对象是否存活,一般有两种方式:

  1. 引用计数法

    每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。

    此方法简单,确无法解决对象相互循环引用的问题。

  2. 可达性分析

    从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。

    在Java语言中,GC Roots包括:

    • 虚拟机栈中引用的对象。
    • 方法区中 类静态属性实体 引用的对象。
    • 方法区中 常量 引用的对象。
    • 本地方法栈中JNI引用的对象。

finalize()二次标记

一个对象是否应该在垃圾回收器在GC时回收,至少要经历两次标记过程。

  1. 通过可达性分析算法分析对象是否与GC Roots可达。经过第一次标记,并且被筛选为不可达的对象会进行第二次标记。

  2. 判断不可达对象是否有必要执行finalize方法。执行条件是当前对象的finalize方法被重写,并且还未被系统调用过。如果允许执行那么这个对象将会被放到一个叫F-Query的队列中,等待被执行。

注:由于finalize由一个优先级比较低的Finalizer线程运行,所以该对象的的finalize方法不一定被执行,即使被执行了,也不保证finalize方法一定会执行完。

对象引用分类

一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。

Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用4种,这4种引用强度依次逐渐减弱。

  1. 强引用

    在代码中普遍存在的,类似Object obj = new Object()这类引用;

    只要强引用还在,垃圾收集器永远不会回收掉被引用的对象。

  2. 软引用

    有用但并非必需的对象,可用 SoftReference 类来实现软引用。

    在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。

  3. 弱引用

    非必需的对象,但它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,JDK提供了 WeakReference 类来实现弱引用。

    无论当前内存是否足够,用软引用相关联的对象都会被回收掉。

  4. 虚引用

    虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,JDK提供了 PhantomReference 类来实现虚引用。

    为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收时收到一个系统通知。

方法区的回收

方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量与符号引用和不再使用的类型。

如果已经没有任何字符串对象引用常量池中的常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

而要判定一个类型是否属于“不再被使用的类型”的条件就比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如
    OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方
    法。

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

分代收集理论

分代假说

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消
    亡。

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分
出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区
域之中存储。

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

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域
——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安
排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算
法”“标记-整理算法”等针对性的垃圾收集算法。

跨代引用假说

分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不
是孤立的,对象之间会存在跨代引用。

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可
能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整
个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样[3]。遍历整个老年代所有对象
的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分
代收集理论添加第三条经验法则:

跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极
少数。

举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以
消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时
跨代引用也随即被消除了。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录
每一个对象是否存在及存在哪些跨代引用,只需在老年代上建立一个全局的数据结构(该结构被称
为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会
存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC
Roots进行扫描。

垃圾回收算法

标记清楚算法

JVM——垃圾回收_2020-04-07-10-08-14.png

原理:

首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。

它是最基础的收集算法,因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

优点:

实现简单,对象不需要移动。

缺点:

  • 效率问题,标记和清除过程的效率都不高
  • 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

JVM——垃圾回收_2020-04-07-10-08-36.png

原理:

它将内存区域划分成相同的两个内存块。每次仅使用一半的空间,JVM生成的新对象放在一半空间中。当一半空间用完时进行GC,把可到达对象复制到另一半空间,然后把使用过的内存空间一次清理掉。

优点:

这种收集算法解决了标记清除算法存在的效率问题。

按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。

缺点:

可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。

标记-整理算法

JVM——垃圾回收_2020-04-07-10-13-55.png

原理:

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

优点:

解决了标记-清理算法存在的内存碎片问题。

缺点:

仍需要进行局部对象移动,一定程度上降低了效率。

分代收集算法

原理:

分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代、老年代 和 永久代,如图所示:

JVM——垃圾回收_2020-04-07-10-16-04.png

  1. 年轻代

    对象被创建时,内存的分配首先发生在年轻代(大对象可以直接被创建在年老代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉,这个GC机制被称为 Minor GC 或 Young GC。

    年轻代上的内存分配是这样的,年轻代可以分为3个区域:Eden区和两个存活区(Survivor 0 、Survivor 1)。

    内存分配过程为:

    绝大多数刚创建的对象会被分配在Eden区,其中的大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;

    当Eden区满的时候,执行Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区Survivor0(此时,Survivor1是空白的,两个Survivor总有一个是空白的)。

    此后,每次Eden区满了,就执行一次Minor GC,并将剩余的对象都添加到Survivor0;当Survivor0也满的时候,将其中仍然活着的对象直接复制到Survivor1,以后Eden区执行Minor GC后,就将剩余的对象添加Survivor1(此时,Survivor0是空白的)。

    当两个存活区切换了几次仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

    总结:

    Eden区是连续的空间,且Survivor总有一个为空。经过一次GC和复制,一个Survivor中保存着当前还活 着的对象,而Eden区和另一个Survivor区的内容都不再需要了,可以直接清空,到下一次GC时,两个Survivor的角色再互换。因此,这种方 式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是复制算法(将Eden区和一个Survivor中仍然存活的对象拷贝到另一个Survivor中)。

  2. 老年代

    对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次 Young GC后存活了下来),则会被复制到年老代。

    年老代的空间一般比年轻代大,能存放更多的对象,在年老代上发生的GC次数也比年轻代少。当年老代内存不足时, 将执行Major GC,也叫 Full GC。  

    可以使用 -XX:+UseAdaptiveSizePolicy 开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄。

    如果对象比较大(比如长字符串或大数组),Young空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象)。

    -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

    可能存在年老代对象引用新生代对象的情况,如果需要执行Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。

    解决的方法是,年老代中维护一个512 byte的块——”card table“,所有老年代对象引用新生代对象的记录都记录在这里。Young GC时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

  3. 永久代

    永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。

    永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。

文章目录
  1. 1. 概述
  2. 2. 对象存活判断
  3. 3. finalize()二次标记
  4. 4. 对象引用分类
  5. 5. 方法区的回收
  6. 6. 分代收集理论
    1. 6.1. 分代假说
    2. 6.2. 跨代引用假说
  7. 7. 垃圾回收算法
    1. 7.1. 标记清楚算法
    2. 7.2. 复制算法
    3. 7.3. 标记-整理算法
    4. 7.4. 分代收集算法
|