likes
comments
collection
share

JVM-垃圾收集基础理论

作者站长头像
站长
· 阅读数 6

JVM-垃圾收集基础理论

垃圾收集(Garbage Collection,简称GC),我们要思考垃圾收集过程中的要完成的三件事情:

  • 1)哪些内存需要回收?
  • 2)什么时候回收?
  • 3)如何回收?

1、哪些内存需要回收?

首先:Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。所以,这里的“内存”分配和回收我们不需要考虑。

Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

又由于,对于方法区来说:《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载)。并且 方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型

  • 废弃常量的回收条件:

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

  • 判断类型不在被使用:(需要同时满足下面三个条件)

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

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

这样就使得,我们最终研究的就是对Java堆的内存回收了。

那么堆上的那些内存可以回收呢?这时候就引入了两种判断对象是否存活的算法:1、引用计数算法(Reference Counting)和2、可达性分析算法(Reachability Analysis)。

通过两种算法可以判断最终存活的对象,将那些不存活的对象回收。

  • 引用计数算法(Reference Counting):

    在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

    但是,引用计数会存在一个问题:很难解决对象之间相互循环引用的问题

            A a = new A();
            B b = new B();
    
            a.instance = b;  // a 中有一个字段 instacne 引用 b
            b.instance = a;  // b 中有一个字段 instacne 引用 a
    
  • 可达性分析算法(Reachability Analysis):

    算法思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使的。 JVM-垃圾收集基础理论 在Java中可以作为GC Roots的对象:

    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如:各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
    • 在方法区中常量引用的对象,譬如:字符串常量池(String Table)里的引用。
    • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
    • Java虚拟机内部的引用,如:基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
    • 所有被同步锁(synchronized关键字)持有的对象。
    • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

这里提到GC Roots,我们对GC Roots,在做可达性分析时,做一些介绍:

  • 固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中
  • 迄今为止,所有收集器在根节点枚举(GC Roots)这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。
  • 当要进行GC的时候,用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为OopMap(普通对象指针:Ordinary Object Pointer,OOP) 的数据结构来达到这个目的。

其实上面说,对象没有被引用时,可以回收,其实 垃圾回收机制肯定不能以对象为单位进行回收,要不然工作量太大了,不符合GC的要求;后面也会引入一些概念,记忆集,卡表这些。

1.1 并发的可达性分析

上面讲道可达性分析算法,直接把并发的可达性分析,以及产生的问题,如何解决放在这里讲了。

首先,我们知道在标记 GC Roots 需要暂停用户线程;但是,从GC Roots继续往下遍历,就会出现堆越大,存储的对象越多要标记更多对象而产生的停顿时间自然就更长,这肯定会影响垃圾收集的效率。

那么,如果同时用户线程与收集器是并发工作,上面问题可以解决;但是进而会由于并发过程中收集器在对象图上标记颜色,同时用户线程在修改引用

关系而引发另外两个问题

  • 浮动垃圾:

    一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。

  • 误标:

    另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误。

下面我们通过三色标记来分析 误标,这种致命错误是如何产生的,进而寻求解决办法。

把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。

    显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。

    黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。 JVM-垃圾收集基础理论

上述两种情况,Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

  • 增量更新方式:

    增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

    这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

  • 原始快照方式:

    原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

    这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

CMS是基于增量更新来做并发标记的;G1、Shenandoah则是用原始快照来实现。

2、什么时候回收?

当我们找到需要回收的内存的时候,下一步,需要确定什么时候回收。

这里我们会引入两个概念:安全点、安全区域

只有当用户线程来到 安全点或安全区域的时候,才能够进行GC。

2.1 安全点

也就是说有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。 这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

  • 抢先式中断:

    抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上

  • 主动式中断:(现在几乎所有的垃圾收集器都是用的这种方式)

    主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

2.2 安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

3、如何回收?

上面我们知道了,什么是内存Garbage,如何标记Garbage;和什么时候去回收这些内存Garbage。

下面我们要做的是如何回收,进而引出 垃圾收集算法。

而垃圾收集算法又是基于分代收集理论的,因此,我们先介绍分代收集理论,再介绍垃圾收集算法。

3.1 分代收集理论

(1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

(2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

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

多数的垃圾收集器都是基于(1)、(2)两个理论,将Java堆分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

才有了我们知道的一些Java堆区域划分概念名称:新生代(Young Generation)和老年代(Old Generation),新生代里面的:Eden区,From Survivor, To Survivor 这些。

进而有我们所知道的一些回收类型:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集

    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.2 垃圾收集算法

  • 1、标记-清除算法
  • 2、标记-复制算法
  • 3、标记-整理算法

3.2.1 标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。

它的主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3.2.2 标记-复制算法

该种算法是种 “半区复制”(Semispace Copying)的垃圾收集算法。

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

缺点:

  • 这种复制回收算法的代价是将可用内存缩小为了原来的一半,有一半的空间未利用到。

对其确定进行进一步的优化是:像HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局,就是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。并且HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1。

并且还利用空间担保策略:就是当出现如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。

3.2.3 标记-整理算法

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

但是,是否移动回收后的存活对象是一项优缺点并存的风险决策:

  • 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户(Stop The World)应用程序才能进行。
  • 但如果像 “标记-清除”完全不考虑移动和整理存活对象的话,就会导致堆中存活的对象产生空间碎片问题。

两者各有优势,也有弊端,在进行垃圾回收的时候,我们更关注的是整体垃圾回收阶段的吞吐量;HotSpot虚拟机里面关注吞吐量的ParallelScavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

垃圾收集器的一些基本概念:标记对象、回收时间点、垃圾收集算法;下面就开始主要将 HotSpot中的一些传统垃圾收集器,以及最新的一些垃圾收集器。