JVM 之 GC算法

前言


本节我们将学习一下JVM的垃圾回收机制(Garbage Collection)以及一些和GC密切相关的点。


概述

对于垃圾JVM的垃圾回收机制这里我们称为GC,众所周知,java语言不需要像c++那样需要自己申请内存,自己释放内存,这些都是JVM帮我们做好了的,但是对于一名java程序员,想要更近自己的水平更上一层楼,就要去了解GC的工作原理,根据原理才能写出更好的更优的程序,这里我们先初步讲解一下GC的工作原理。

首先我们在讲解之前我们需要了解一下JVM内存运行时数据区的三个重要的地方。

  • 堆(heap) : 他是最大的一块区域,用于存放对象实例和数组,是全局共享的。
  • 栈(stack) : 全称为虚拟机栈,主要存储基本数据类型,以及对象的引用,私有线程。
  • 方法区(Method Area) : 在class被加载后的一些信息 如常量,静态常量这些被放在这里,在Hotspot里面我们将它称之为永生代。

关于具体结构可以用下面这张图来对内存运行时数据区有一个大致的概括
这里写图片描述

  • 堆(heap):前面已经说了他是最大的,也是最重要的一块区域,这里也称为逻辑堆,主要用来存放对象实例与数组,对于所有的线程来说他是共享的,对于Heap堆区是动态分配内存的,所以空间大小和生命周期都不是明确的,而GC的主要作用就是自动释放逻辑堆里实例对象所占的内存,而在逻辑堆中还分为新生代与老年代,用来区分对象的存活时间,在新生代中还被细致的分为 EdenSurvivorFrom以及SurvivorTo这三部分。

首先我们应该知道若无特殊说明,所谓的GC活动区域就是堆区。因而在了解GC之前我们需要了解
随着JAVA(目前是JAVA 9) 的更新迭代,JVM的GC也经历了从分代到分区(后面我们将学习到)的实质改变,这种改变的本原因是由堆的改变引起的。

  • 堆的组成部分
    通常而言堆内存空间由以下三部分组成:
    • 年轻代
      所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制“年老区(Tenured)”。需要注意,Survivor的两个区是对称的,没先后关系,所以同一个区中可能同时存在从Eden复制过来 对象,和从前一个Survivor复制过来的对象,而复制到年老区的只有从第一个Survivor去过来的对象。而且,Survivor区总有一个是空的。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
    • 年老代
      在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
    • 永生代
      严格来讲永生代已经不再属于堆内存范畴之内了,它是方法区的一部分,但是它与GC即年轻代和年老代有着密切的联系,因此为了便于理解我们将永生代也纳入到我们的学习中来,以便于理解。
      永生代用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。
  • 堆的物理结构
    堆的物理结构在JVM发展史上有过性质的更变,即从分代到分区的演变。
    • 分代
      这里写图片描述
    • 分区
      这里写图片描述

GC算法

由上我们可以知道了堆的组成部分堆的物理结构 ,那么接下来我们讲学习JVM GC的算法和执行原理

  • GC算法
    由于在JVM发展史上堆区物理结构由分代发展到了分区,因而GC算法也会有相应的更变,但逻辑出发点都是基于根搜索算法来实现各种GC算法的,下面我们具体学习一下GC算法。

    • 根搜索算法
      在主流商用语言(如Java、C#)的主流实现中, 都是通过可达性分析算法来判定对象是否存活的: 通过一系列的称为 GC Roots 的对象作为起点, 然后向下搜索; 搜索所走过的路径称为引用链/Reference Chain, 当一个对象到 GC Roots 没有任何引用链相连时, 即该对象不可达, 也就说明此对象是不可用的, 如下图: Object5、6、7 虽然互有关联, 但它们到GC Roots是不可达的, 因此也会被判定为可回收的对象:
      这里写图片描述
      在Java, 可作为GC Roots的对象包括:

      • 方法区: 类静态属性引用的对象
      • 方法区: 常量引用的对象
      • 虚拟机栈(本地变量表)中引用的对象
      • 本地方法栈JNI(Native方法)中引用的对象

      注意: 即使在可达性分析算法中不可达的对象, VM也并不是马上对其回收, 因为要真正宣告一个对象死亡, 至少要经历两次标记过程:
      第一次是在可达性分析后发现没有与GC Roots相连接的引用链,
      第二次是GC对在F-Queue执行队列中的对象进行的小规模标记(对象需要覆盖finalize()方法且没被调用过).

    • 引用计数法
      引用计数法是最经典的一种垃圾回收算法。其实现很简单,对于一个A对象,只要有任何一个对象引用了A,则A的引用计算器就加1,当引用失效时,引用计数器减1.只要A的引用计数器值为0,则对象A就不可能再被使用。

      虽然其思想实现都很简单(为每一个对象配备一个整型的计数器),但是该算法却存在两个严重的问题:

      • 无法处理循环引用的问题,因此在Java的垃圾回收器中,没有使用该算法。
      • 引用计数器要求在每次因引用产生和消除的时候,需要伴随一个加法操作和减法操作,对系统性能会有一定的影响。
      • 例子:

        对象A和对象B,对象A中含有对象B的引用,对象B中含有对象A的引用。此时对象A和B的引用计数器都不为0,但是系统中却不存在任何第三个对象引用A和B。也就是说A和B是应该被回收的垃圾对象,但由于垃圾对象间的互相引用使得垃圾回收器无法识别,从而引起内存泄漏(由于某种原因不能回收垃圾对象占用的内存空间)。如下图:不可达对象出现循环引用,它的引用计数器不为0。
        这里写图片描述

      注意:由于引用计数器算法存在循环引用以及性能的问题,java虚拟机并未使用此算法作为垃圾回收算法。

    • 标记清除法
      标记清除法是现代垃圾回收算法的思想基础。标记清除法将垃圾回收分为两个阶段:标记阶段和清除阶段。

      • 标记:在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象,因此未被标记的对象就是未被引用的垃圾对象。

      • 清除:在清除阶段,清除所有未被标记的对象。这种方法可以解决循环引用的问题,只有两个对象不可达,即使它们互相引用也无济于事。也是会被判定位不可达对象。

      标记清除算法可能产生的最大的问题就是空间碎片。如下图所示,简单描述了使用标记清除法对一块连续的内存空间进行回收。
      从根节点开始(在这里仅显示了两个根节点),所有的有引用关系的对象均被标记为存活对象(箭头表示引用)。从根节点起,不可达对象均为垃圾对象。在标记操作完成后,系统回收所有不可达对象。
      这里写图片描述
      从上图可以看出,回收后的内存空间不再连续。在对象的对空间分配过程中,尤其是大对象的内存分配,不连续内存空间的工作效率要低于连续空间的,这也是该算法的缺点。

      注意:标记清除算法先通过根节点标记所有可达对象,然后清除所有不可达对象,完成垃圾回收。后面会讲到标记压缩算法,注意两者的区别。

  • 复制算法
    算法思想:将原有的内存空间分为两块相同的存储空间,每次只使用一块,在垃圾回收时,将正在使用的内存块中存活对象复制到未使用的那一块内存空间中,之后清除正在使用的内存块中的所有对象,完成垃圾回收。

    • 优点:如果系统中的垃圾对象很多,复制算法需要复制的存活对象就会相对较少(适用场景)。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。而且,由于存活对象在垃圾回收过程中是一起被赋值到另一块内存空间中的,因此,可确保回收的内存空间是没有碎片的。
    • 缺点:但是复制算法的代价是将系统内存空间折半,只使用一半空间,而且如果内存空间中垃圾对象少的话,复制对象也是很耗时的,因此,单纯的复制算法也是不可取的。
    • 例子:A、B两块相同的内存空间(原有内存空间折半得到的两块相同大小内存空间AB),A在进行垃圾回收,将存活的对象复制到B中,B中的空间在复制后保持连续。完成复制后,清空A。并将空间B设置为当前使用内存空间。
      这里写图片描述
      在java中的新生代串行垃圾回收器中,使用了复制算法的思想,新生代分为eden空间、from空间和to空间3个部分,其中from和to空间可以看做用于复制的两块大小相同、可互换角色的内存空间块(同一时间只能有一个被当做当前内存空间使用,另一个在垃圾回收时才发挥作用),from和to空间也称为survivor空间,用于存放未被回收的对象。

      在垃圾回收时,eden空间中存活的对象会被复制到未使用的survivor空间中(图中的to),正在使用的survivor空间(图中的from)中的年轻对象也会被复制到to空间中(大对象或者老年对象会直接进入老年代,如果to空间已满,则对象也会进入老年代)。此时eden和from空间中剩余对象就是垃圾对象,直接清空,to空间则存放此次回收后存活下来的对象。

      注意:复制算法比较适用于新生代。因为在新生代中,垃圾对象通常会多于存活对象,算法的效果会比较好。

  • 标记压缩算法
    标记压缩算法是老年代的回收算法,它在标记清除算法的基础上做了优化。和标记清除算法一样,标记压缩算法也首先从根节点开始,对所有可达的对象做一次标记,但之后,它并不是简单的清理未标记的对象,而是将所有的存活对象压缩到内存空间的一端,之后,清理边界外所有的空间。这样做避免的碎片的产生,又不需要两块相同的内存空间,因此性价比高。

    • 例子:通过根节点标记出所有的可达对象后,沿着虚线进行对象的移动,将所有的可达对象移到一端,并保持他们之间的引用关系,最后,清理边界外的空间。
      这里写图片描述

      注意:标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片的整理,因此也称之为标记清除压缩算法。

  • 分代算法
    分代算法思想:将内存空间根据对象的特点不同进行划分,选择合适的垃圾回收算法,以提高垃圾回收的效率。
    这里写图片描述
    通常,java虚拟机会将所有的新建对象都放入称为新生代的内存空间。新生代的特点是:对象朝生夕灭,大约90%的对象会很快回收,因此,新生代比较适合使用复制算法

    当一个对象经过几次垃圾回收后依然存活,对象就会放入老年代的内存空间,在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的,因此,认为这些对象在一段时间内,甚至在程序的整个生命周期将是常驻内存的。老年代的存活率是很高的,如果依然使用复制算法回收老年代,将需要复制大量的对象。这种做法是不可取的,根据分代的思想,对老年代的回收使用标记清除或者标记压缩算法可以提高垃圾回收效率。

    对于新生代和老年代来说,通常新生代回收的频率很高,但是每次回收的时间都很短,而老年代回收的频率比较低,但是被消耗很多的时间。为了支持高频率的新生代回收,虚拟机可能使用一种叫做卡表的数据结构,卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用,这样以来,新生代GC时,可以不用花大量时间扫描所有老年代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有当卡表的标记为1时,才需要扫描给定区域的老年代对象,而卡表为0的所在区域的老年代对象,一定不含有新生代对象的引用。

    如下图表示:卡表中每一位表示老年代4KB的空间,卡表记录为0的老年代区域没有任何对象指向新生代,只有卡表为1的区域才有对象包含新生代对象的引用,因此在新生代GC时,只需要扫面卡表为1所在的老年代空间,使用这种方式,可以大大加快新生代的回收速:
    这里写图片描述

    注意:分代算法使用卡表能大大地提高GC效率。

  • 分区算法
    这里写图片描述
    如上图所示,分区算法将整个堆空间划分为连续的不同小区间,每一个小区间都独立使用,独立回收。

    相同的条件下,堆空间越大,一次GC所需的时间就越长,从而产生的停顿时间就越长。为了更好的控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理的回收若干个小区间,而不是整个堆空间,从而减少一个GC的停顿时间。

    注意:可以控制一次回收多少个小区间,更好地控制GC产生的停顿时间


猜你喜欢

转载自blog.csdn.net/u012437781/article/details/80180793