美文网首页JVM
GC策略算法浅析

GC策略算法浅析

作者: 红Bean | 来源:发表于2018-11-27 20:07 被阅读25次

    GC

    • GC(Garbage Collection)是Java虚拟机(JVM)垃圾回收器提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。

    • 在C/C++里是由程序猿自己去申请、管理和释放内存空间,因此没有GC的概念。而在Java中,后台专门有一个专门用于垃圾回收的线程来进行监控、扫描,自动将一些无用的内存进行释放,这就是垃圾收集的一个基本思想,目的在于防止由程序猿引入的人为的内存泄露。

      引用

      • Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

      • Java中的对象引用有四种类型:强引用,软引用, 弱引用,虚引用。

        1. 强引用: 强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。显式地设置对象为null,或超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象。
        2. 软引用: 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。
        3. 弱引用: 弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
        4. 虚引用: 顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
      • 引用类型 被垃圾回收时间 用途 生存时间
        强引用 从来不会 对象的一般状态 JVM停止运行终止
        软引用 再内存不足时 对象缓存 内存不足时终止
        弱引用 在垃圾回收时 对象缓存 gc运行后终止
        虚引用 Unknown Unknown Unknown

    内存区域划分

    • 内存区域划分为两个部分:线程共享内存,线程私有内存。

    • 线程私有内存是每条线程都有一个独立的内存,独立储存互不影响。它和线程同生共死,在编译时确定内存大小和生存周期,因此不需要过多考虑内存回收问题。

    • 线程共享内存是被所有的线程共享的一块内存区域。

      线程私有内存

      • 程序计数器:

        1. 程序计数器是一块较小的内存空间。可以看做是当前线程所执行的字节码的行号指示器。在JVM的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

        2. 由于JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了在线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。所以,程序计数器是线程私有的内存区域。

        3. Java虚拟机规范中唯一一个没有规定任何OutOfMemoryError情况的区域。

      • Java虚拟机栈:

        1. Java虚拟机栈描述的是Java方法执行的内存模型:每个方法执行的同时会创建一个栈帧,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧用于存储局部变量表、操作数栈、动态链接、方法返回等信息。 每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

        2. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常;(当前大部分JVM都可以动态扩展,只不过JVM规范也允许固定长度的虚拟机栈)

      • 本地方法栈:

        1. 本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法服务(也就是字节码),而本地方法栈为虚拟机使用到的Native方法服务(Native方法就是一个java调用非java代码实现的接口)。

    线程共享内存

    • Java堆:

      1. JVM 所管理的最大的一块内存空间。Java堆的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。

      2. Java虚拟机规范规定,如果在堆上没有内存完成实例分配,并且堆上也无法再扩展时,将会抛出OutOfMemoryError异常。

      3. 我们平常所说的垃圾回收,主要回收的就是堆区。为了提升垃圾回收的性能,又把堆分成两块区新生代(young)老年代(old),更细一点划分新生代又可划分为Eden区和2个Survivor区(From Survivor区和To Survivor区)。下图中区的大小都是默认比例,都是可以通过jvm参数修改的。

        新生代 老年代.PNG
        • Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:Minor GC、Full GC ( 或称为 Major GC )。这是分代回收算法。Minor GC 是发生在新生代中的垃圾收集动作,所采用的是复制算法。Full GC 是发生在老年代的垃圾收集动作,所采用的是标记-清除算法。
      • 新生代:

        1. 新生代几乎是所有 Java 对象出生的地方,即 Java 对象申请的内存以及存放都是在这个地方。Java 中的大部分对象通常不需长久存活,具有朝生夕灭的性质。所以新生代是 GC 收集垃圾的频繁区域(新陈代谢快)。
        2. 当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳
          ( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数来设定 ),这些对象就会成为老年代。
      • 老年代:

        1. 除了从新生代到老年代的对象,对于一些较大的对象 ( 即需要分配一块较大的连续内存空间 ) 则是直接进入到老年代。
        2. 老年代里面的对象几乎个个都是在 Survivor 区域中熬过来的,它们是不会那么容易就 "死掉" 了的。因此,Full GC 不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 更耗时(新陈代谢慢)。
      • 问题:从老生代对象对新生代对象的引用怎么办呢?

        ​ 如果只扫描新生代区域的话,那么从老生代对新生代的引用就不会被检测到。这样一来,如果一个年轻的对象只有来自老生代对象的引用,就会被误认为已经“死亡”了。因此,在分代回收中,会对对象的更新进行监视,将从老生代对新生代的引用,记录在一个叫做记录集(remembered set)的表中。在执行Minor GC的过程中,这个记录集也作为一个根来对待。

    • 方法区:

      1. 方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
      2. Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样 不需要连续的内存和可以选择固定大小或者可扩展之外,还可以选择不实现垃圾回收。这区域的内存回收目标主要是针对常量池的回收和类型的卸载。
      3. Java虚拟机规范规定,当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError异常。
      4. 运行时常量池是方法区的一部分。常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性,里面可以存放编译期生成的常量;运行期间的常量也可以添加进入常量池中。

    Stop the world

    • 不管选择哪种GC算法,stop-the-world都是不可避免的。Stop-the-world意味着从应用中停下来并进入到GC执行过程中去。一旦Stop-the-world发生,除了GC所需的线程外,其他线程都将停止工作,中断了的线程直到GC任务结束才继续它们的任务。GC调优通常就是为了改善stop-the-world的时间。

    哪些是垃圾

    • 通过算法如何去判断对象之中那些是“存活”着,那些已经”死去“(即不可能再被任何途径使用的对象)。

      引用计数算法

      • 引用计数(Reference Count)方式是GC算法中最简单也最容易实现的一种。它给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

        GC 引用计数算法.PNG
    • 优点:

      1. 引用计数收集器执行简单,判定效率高,交织在程序运行中。当对象不再被引用的瞬间就会被释放,这也是一个优点。
      2. 其他的GC机制中,要预测一个对象何时会被释放是很困难的,而在引用计数方式中则是立即被释放的。而且,由于释放操作是针对每个对象个别执行的,因此和其他算法相比,由GC而产生的中断时间(Pause time)就比较短,这也是一个优点。
    • 缺点:

      1. 引用计数最大的缺点,就是无法释放循环引用的对象。
      GC 引用计数算法 缺点.PNG
      1. 引用计数的第二个缺点就是,引用计数管理并不适合并行处理。如果多个线程同时对引用计数进行增减的话,引用计数的值就可能会产生不一致的问题(结果则会导致内存错误)。为了避免这种情况的发生,对引用计数的操作必须采用独占的方式来进行。如果引用操作频繁发生,每次都要使用加锁等并发控制机制的话,其开销也是不可小觑的。

    可达性分析算法

    • GC Root: 是可达性分析算法中作为起点的对象。在Java中可作为GC Root对象包括以下几种:
      1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
      2. 方法区中的类静态属性引用的对象。
      3. 方法区中的常量引用的对象。
      4. 本地方法栈中JNI(Native方法)的引用对象。
    • 该算法的基本思想就是通过一系列被称为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到任何一个GC Root没有任何引用链相连时,则证明此对象不可用。
      • 自我救赎: 被认定为不可达对象后,并不会马上回收,而是要经过只是两次的标记过程:

        1. 如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法(垃圾回收器准备释放内存的时候,会先调用f该方法)。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过(任何对象的finalize()方法只会被系统自动调用一次),虚拟机将这两种情况都视为没有必要执行。
        2. 如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
      • 优点: 可以解决放循环引用的对象的清空。

      • 缺点: 实现较为复杂。

    垃圾收集算法

    • 垃圾收集算法中很多都是基于可达性分析进行是现实的。

      标记—清除算法

      • 标记—清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象。


        垃圾收集算法.PNG
      • 注意: ·

        1. 标记是通过对象内部的标志(Flag)来实现的。
        2. 标记阶段和清除阶段都需要对全部对象进行遍历。
        3. 在清除阶段会对存活对象的标记清除掉,以便为下一次GC操作做好准备。
      • 优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。

      • 缺点:

        1. 标记和清除过程的效率都不高。

        2. 标记清除后会产生大量不连续的内存碎片。虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败的

    标记—整理算法(标记—压缩算法)

    • 该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于该算法的收集器的实现中,一般增加句柄和句柄表。(不同的虚拟机的对象访问方式有所不同,主流的访问方式有两种:使用句柄间接访问实例数据、指针直接访问实例数据。)

    • 在整理阶段,由于要移动可达对象,那么需要考虑移动对象时的顺序,一般分为下面三种:

      1. 任意顺序 - 即不考虑原先对象的排列顺序,也不考虑对象间的引用关系,随意的移动可达对象,这样可能会有内存访问的局部性问题。
      2. 线性顺序 - 在重新排列对象时,会考虑对象间的引用关系,比如A对象引用了B对象,那么就会尽可能的将A,B对象排列在一起。
      3. 滑动顺序 - 顾名思义,就是在重新排列对象时,将对象按照原先堆内存中的排列顺序滑动到堆的一端。

      现在大多数的垃圾收集算法都是按照任意顺序滑动顺序去实现的。下面我们分别来看下它们各自的算法原理。

    • 优点: 不会再有碎片的问题了。

    • 缺点: GC暂停的时间会增长。

      • 整理算法:

        Two Fingers 算法:

        • 它在压缩阶段移动对象时是任意顺序移动的,它最适用于处理包含固定大小对象的内存区域。

        • Two Fingers需要遍历堆内存两次。

          1. 第一次遍历是将堆末尾的可达对象移动到堆开始的空闲内存单元。
          2. 第二次遍历则需要修改可达对象的引用,因为一些可达对象已经被移动到别的地址,而原先引用它们的对象还指向着它们移动前的地址。
        • 在这两次遍历过程中,首尾两个指针分别从堆的头尾两个位置向中间移动,直至两个指针相遇,由于它们的运动轨迹酷似两根手指向中间移动的轨迹,因此称为Two Finger算法。

        • 第一次遍历: 第一次遍历的原理是,头指针(free)沿着堆头向堆尾前进,直到找到一个空闲的内存单元(即没有被标记为可达对象的内存单元),如遇到可达对象,则清除其标记。接着尾指针(scan)从堆尾向堆头方向前进,直到找到一个被标记为可达的内存单元。最后,可达对象从尾指针(scan)指向的位置移动到头指针(free)指向的位置,当前free指针指向的位置写到原当前尾指针scan指向的位置, 为下一次的遍历 - 更新对象相互间的引用做好准备。

          image
        • 第二次遍历: 而第二次的遍历则是为了更新引用关系。第二次遍历,collector先会对根对象进行遍历,比如根对象2引用着位置6的内存单元,根据算法,该位置大于等于end指针所指向的位置 - 即第一次遍历free指针和scan指针相遇的位置,那么我们就认为这个位置的对象已经被移动,需要更新根对象2的引用关系,即从引用位置6改为引用位置2(位置6的内存单元中记录着该对象被移动后的新位置)。同理,在移动G对象的时候,也是要判断看G所引用的内存单元位置是否大于end指针指向的位置,如果小于,则不处理。否则则修改G的引用关系。

          image

    LISP2算法

    • Lisp2算法是一种应用更为广泛的压缩算法,它属于滑动顺序算法中的一种。

    • 它跟Two-Finger算法的不同还在于它可以处理不同大小的对象,而不再是固定大小的对象。同时,计算出来的可达对象的迁移地址需要额外的空间进行存储而不再是复写原先对象所在的位置。最后,Lips2算法需要进行3次堆内存的遍历。

    • 第一次遍历:第一次遍历,collecor仅仅是计算和记录可达对象应该迁移去的地址。

      1. 指针free, scan同时指向堆起始位置,同时scan指针向堆尾移动,目的是要找到被标记的可达对象。

      2. 找到可达对象后,在scan指针对应的位置分配一个额外的空间来存储该可达对象应该迁移到的地址 - 就是free指针指向的位置0,同时free指针向堆尾移动B对象大小的距离- free指针指向的位置。最后scan指针继续往前走,直到寻找到下一个可达对象D - scan指针指向的位置。

      3. 同理,在可达对象D处分配一块空间来保存对象D应该迁移到的位置,由于B对象已经占用了2个内存单元,所以对象E的迁移地址是从位置2开始,也就是当前free指针指向的位置。

      4. 指针free,scan继续向前移动。

      5. 第一次遍历完后,所有的可达对象都有了对应的迁移地址,free指针指向位置9,因为所有的可达对象总共占了9个单元大小的空间。

        image
    • 第二次遍历:第二次遍历主要是修改对象间的引用关系,基本跟Two Finger算法的第二次遍历一样。

      1. 修改根对象的引用关系,根对象1引用对象B,对象B的迁移地址为0,于是collector将根对象对B对象的引用指向它的迁移地址 - 位置0, 现在A对象所处的位置。
      2. 同理,对于根对象2,3都执行同样的操作,将它们对其所引用的对象的引用修改为对应的它们所引用的对象的迁移地址。
      3. 通过scan指针遍历堆内存,更新所有的可达对象对其引用对象的引用为其引用对象的迁移地址。比如说,对于可达对象B, 它引用了对象D,D的迁移地址是2,那么B直接将其对D对象的引用重新指向2这个位置。
      4. 第二次遍历结束后的对象之间的引用关系。
    • 第三次遍历:第三次遍历则是根据可达对象的迁移地址去移动可达对象,比如说可达对象B,它的迁移地址是0,那么就将其移动到位置0,同时去除可达对象的标记,以便下次垃圾收集。

      image

    复制算法

    • 该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。

    • 复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。

    • 优点:

      1. 标记阶段和复制阶段可以同时进行。
      2. 每次只对一块内存进行回收,运行高效。
      3. 只需移动栈顶指针,按顺序分配内存即可,实现简单。
      4. 内存回收时不用考虑内存碎片的出现(得活动对象所占的内存空间之间没有空闲间隔)。
    • 缺点: 可一次性分配的最大内存缩小了一半。

    其他算法

    增量回收

    • 在对实时性要求很高的程序中,比起缩短GC的平均中断时间,往往更重视缩短GC的最大中断时间。在一些实时性要求很高的程序中,必须能够对GC所产生的中断时间做出预测。例如,可以将“最多只能中断10毫秒”作为附加条件。在一般的GC算法中,作出这样的保证是不可能的,因为GC产生的中断时间与对象的数量和状态有关。
    • 因此,为了维持程序的实时性,不等到GC全部完成,而是将GC操作细分成多个部分逐一执行。这种方式被称为增量回收(Incremental GC)。在增量回收中,由于GC过程是渐进的,在回收过程中程序本身会继续运行,对象之间的引用关系也可能会发生变化。如果已经完成扫描和标记的对象被修改,对新的对象产生了引用,这个新对象就不会被标记,明明是“存活”对象却被回收掉了。在增量回收中为了避免这样的问题,和分代回收一样也采用了写屏障。当已经被标记的对象的引用关系发生变化时,通过写屏障会将新被引用的对象作为扫描的起始点记录下来。由于增量回收的过程是分步渐进式的,可以将中断时间控制在一定长度之内。另一方面,由于中断操作需要消耗一定的时间,GC所消耗的总时间就会相应增加,正所谓有得必有失。

    并行回收

    • 最近的计算机中,一块芯片上搭载多个CPU核心的多核处理器已经逐渐普及。在这样的环境中,就需要通过利用多线程来充分发挥多CPU的性能。并行回收正是通过最大限度利用多CPU的处理能力来进行GC操作的一种方式。并行回收的基本原理是,是在原有的程序运行的同时进行GC操作,这一点和增量回收是相似的。
    • 不过,相对于在一个CPU上进行GC任务分割的增量回收来说,并行回收可以利用多CPU的性能,尽可能让这些GC任务并行(同时)进行。由于软件运行和GC操作是同时进行的,因此就会遇到和增量回收相同的问题。为了解决这个问题,并行回收也需要用写屏障来对当前的状态信息保持更新。不过,让GC操作完全并行,而一点都不影响原有程序的运行,是做不到的。因此在GC操作的某些特定阶段,还是需要暂停原有程序的运行。++Z
    • 在多核化快速发展的现在,并行回收也成了一个非常重要的话题,它的算法也在不断进行改善。在硬件系统的支持下,无需中断原有程序的完全并行回收器也已经呼之欲出。今后,这个领域相当值得期待。

    相关文章

      网友评论

        本文标题:GC策略算法浅析

        本文链接:https://www.haomeiwen.com/subject/ctmnqqtx.html