您当前的位置:首页 > 计算机 > 编程开发 > Java

JVM中的垃圾回收概念和算法

时间:04-25来源:作者:点击数:

垃圾回收概念和算法

垃圾回收是Java体系最重要的组成部分之一。和C/C++的手工内存管理不同,Java虚拟机提供了一套全自动的内存管理方案,尽可能地减少开发人员在内存资源管理方面的工作量。要掌握这套内存管理方案,我们就必须理解垃圾回收器以怎样的原理工作,下面就来介绍垃圾回收器的基本算法和工作方式。

1.垃圾回收的基本概念

实际开发中无论服务器资源还是网络资源都是有限的,Java程序运行在JVM上,而启动时会被分配到内存空间,并且使用类加载子系统把class文件中的类加载到内存中,并且分配给堆空间或栈空间等。如果存在大量的不会被使用的对象在堆空间中,那么在运行过程中动态加载类的实例的时候会出现堆空间不足OOM,在JVM中这种不会被使用的对象就是"垃圾"。而“回收”,也相当于把垃圾桶“倒掉”。这样内存空间里就会有空闲的区域被腾出来。如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。如果大量不会被使用的对象一直占着空间不放,需要内存空间时,就无法使用这些被垃圾对象占用的内存,从而有可能导致内存溢出。因此,对内存空间的管理来说,识别和清理垃圾对象是至关重要的。

垃圾回收并不是Java虚拟机独创的,早在20世纪60年代,垃圾回收就已经被Lisp语言所使用。现在,除了Java以外,C#、Python等语言都使用了垃圾回收的思想。可以说,这种自动化的内存管理方式已经成为现代开发语言必备的标准。

2.常用的垃圾回收算法

2.1 引用计数法

  • 引用计数法是最经典也是最古老的一种垃圾收集方法,在微软的COM组件技术、Adobe的ActionScript.3中,都可以找到引用计数器的身影。
  • 引用计数器的实现很简单,对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1,当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,则对象A就不可能再被使用。
  • 引用计数器的实现也非常简单,只需要为每个对象配备一个整型的计数器即可。但是,引用计数器有两个非常严重的问题:
    • 无法处理循环引用的情况。因此,在Java的垃圾回收器中,没有使用这种算法
    • 引用计算器要求在每次因引用产生和消除的时候,需要伴随一个加法操作和减法操作,对系统性能会有一定的影响。
  • 一个简单的循环引用问题描述如下:
    • 有对象A和对象B,对象A中含有对象B的引用,对象B中含有对象A的引用。此时,对象A和B的引用计数器都不为0。但是,在系统中,却不存在任何第3个对象引用了A或B。也就是说,A和B是应该被回收的垃圾对象,但由于垃圾对象间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。
      image-20220420145817775
      image-20220420150127259
  • 名词解释
    • 可达对象:指通过根对象进行引用搜索,最终可以达到的对象。
    • 不可达对象:通过根对象进行引用搜索,最终没有被引用到的对象。

2.2 标记清除法

  • 标记清除算法是现代垃圾回收算法的思想基础。标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。标记清除算法可能产生的最大问题是空间碎片。
  • 下图所示,使用标记清除算法对一块连续的内存空间进行回收。从根节点开始(这里显示了2个根),所有的有引用关系的对象均被标记为存活对象(箭头表示引用)。从根节点起,不可达的对象均为垃圾对象。在标记操作完成后,系统回收所有不可达的空间。
image-20220420221507652
  • 上图所示,回收后的空间是不连续的。在对象的堆空间分配过程中,尤其是大对象的内存分配,不连续内存空间的工作效率要低于连续的空间。因此,这也是该算法的最大缺点
  • 注意:标记清除算法先通过根节点标记所有可达对象,然后清除所有不可达对象,完成垃圾回收。

2.3 复制算法

  • 复制算法的核心思想是:将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
  • 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量就会相对较少。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象是在垃圾回收过程中,统一被复制到新的内存空间中的,因此,可确保回收后的内存空间是没有碎片的。虽然有以上两大优点,但是,复制算法的代价却是将系统内存折半,因此,单纯的复制算法也很难让人接受。
  • 下图所示,A、B两块相同的内存空间,A在进行垃圾回收时,将存活对象复制到B中。B中的空间在复制后保持连续。复制完成后,清空A。并将空间B设置为当前使用空间。
image-20220420222354748
  • 在Java的新生代串行垃圾回收器中,使用了复制算法的思想。新生代分为eden空间、from空间和to空间3个部分。其中fom和to空间可以视为用于复制的两块大小相同、地位相等、且可进行角色互换的空间块。from和to空间也称为survivor空间,即幸存者空间,用于存放末被回收的对象。下图所示。
    image-20220420224106114
    在垃圾回收时,eden空间中的存活对象会被复制到未使用的survivor空间中(假设是to),正在使用的survivor空间(假设是from)中的年轻对象也会被复制到to空间中(大对象,或者老年对象会直接进入老年代,如果to空间已满,则对象也会直接进入老年代)。此时,eden空间和fom空间中的剩余对象就是垃圾对象,可以直接清空,to空间则存放此次回收后的存活对象。这种改进的复制算法,既保证了空间的连续性,又避免了大量的内存空间浪费。上图所示,显示了复制算法的实际回收过程。当所有存活对象都复制到survivor区后简单地清空eden区和备用的survivor区(图中为fom)即可。
  • 注意:复制算法比较适用于新生代。因为在新生代,垃圾对象通常会多于存活对象。复制算法的效果会比较好。
  • 名词解释
    • 新生代:存放年轻对象的堆空间。年轻对象指刚刚创建的,或者经历垃圾回收次数不多的对象。
    • 老年代:存放老年对象的堆空间。老年对象指经历过多次垃圾回收依然存活的对象。

2.4 标记压缩法

  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
  • 标记压缩算法是一种老年代的回收算法。它在标记清除算法的基础上做了一些优化。和标记清除算法一样,标记压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不只是简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比较高。如下图所示,在通过根节点标记出所有可达对象后,沿虚线进行对象移动,将所有的可达对象都移动到一端,并保持它们之间的引用关系,最后,清理边界外的空间,即可完成回收工作。
    image-20220420224841207
  • 标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记清除压缩(MarkSweepCompact)算法。

2.5 分代算法

  • 分代算法核心思想是在复制、标记清除、标记压缩等垃圾回收算法。并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。因此,根据垃圾回收对象的特性,使用合适的算法回收,才是明智的选择。
  • 分代算法就是基于以上的思想,它将内存区间根据对象的特点分成几块,根据每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率。
  • 一般来说,Java虚拟机会将所有的新建对象都放入称为新生代的内存区域,新生代的特点是对象朝生夕灭,大约90%的新建对象会被很快回收,因此,新生代比较适合使用复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老年代的内存空间。在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的。因此,可以认为这些对象在一段时期内,甚至在应用程序的整个生命周期中,将是常驻内存的。
  • 在极端情况下,老年代对象的存活率可以达到100%。如果依然使用复制算法回收老年代,将需要复制大量对象。再加上老年代的回收性价比也要低于新生代,因此这种做法是不可取的。根据分代的思想,可以对老年代的回收使用与新生代不同的标记压缩或标记清除算法,以提高垃圾回收效率。如下图所示,显示了这种分代回收的思想。
    image-20220420230426847
  • **注意:**分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。
  • 对于新生代和老年代来说,通常,新生代回收的频率很高,但是每次回收的耗时都很短,而老年代回收的频率比较低,但是会消耗更多的时间。为了支持高频率的新生代回收,虚拟机可能使用一种叫作卡表(Card Table)的数据结构。卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用。这样在新生代GC时,可以不用花大量时间扫描所有老年代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有当卡表的标记位为1时,才需要扫描给定区域的老年代对象,而卡表位为0的所在区域的老年代对象,一定不含有新生代对象的引用。如下图所示,卡表中每一位表示老年代4KB的空间,卡表记录为0的老年代区域没有任何对象指向新生代,只有卡表位为1的区域才有对象包含新生代引用,因此在新生代GC时,只需要扫描卡表位为1所在的老年代空间。使用这种方式,可以大大加快新生代的回收速度
    image-20220420231815448

2.6 分区算法

  • 分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间,如下图所示。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
  • 一般来说,在相同条件下,堆空间越大,一次GC时所需要的时间就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。
    image-20220420232213321

3.判断可触及性

垃圾回收的基本思想是考察每一个对象的可触及性,即从根节点开始是否可以访问到这个对象,如果可以,则说明当前对象正在被使用,如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了,一般来说,此对象需要被回收。但事实上,一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,需要给出一个对象可触及性状态的定义,并规定在什么状态下,才可以安全地回收对象。

简单来说,可触及性可以包含以下3种状态。

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()函数中复活。
  • 不可触及的:对象的finalize()函数被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()函数只会被调用一次。

以上3种状态中,只有在对象不可触及时才可以被回收。

3.1 对象的复活

  • 例题代码
    image-20220420234117752
  • 执行结果
    image-20220420234146305
  • 分析结果
    可以看到,在代码第12行将user设置为nul后,进行GC,结果发现obj对象被复活了。第18行,再次释放对象引用并运行GC,对象才真正地被回收。这是因为第一次GC时,在finalize()函数调用之前,虽然系统中的引用已经被清除,但是作为实例方法finalize(),对象的his引用依然会被传入方法内部,如果引用外泄,对象就会复活,此时,对象又变为可触及状态。而finalize()函数只会被调用一次,因此,第2次清除对象时,对象就再无机会复活,因此就会被回收。
  • 注意:finalize()函数是一个非常糟糕的模式,不推荐使用finalize()函数释放资源。
    • 第一,因为finalize()函数有可能发生引用外泄,在无意中复活对象;
    • 第二,由于finalize()是被系统调用的,调用时间是不明确的,因此不是一个好的资源释放方案,推荐在try-catch-finally语句中进行资源的释放。

3.2 引用和可触及性的强度

在Jva中提供了4个级别的引用:强引用、软引用、弱引用和虚引用。除强引用外,其他3种引用均可以在java.lang.ref包中找到它们的身影。开发人员可以在应用程序中直接使用它们。其中FinalReference意味“最终”引用,它用以实现对象的finalize()方法。

强引用就是程序中一般使用的引用类型,强引用的对象是可触及的,不会被回收。相对的,软引用、弱引用和虚引用的对象是软可触及弱可触及和虚可触及的,在一定条件下,都是可以被回收的。

  • 例子:StringBuffer str new StringBuffer ("Hello world");
    假设以上代码是在函数体内运行的,那么局部变量str将被分配在栈上,而对象StringBuffer实例被分配在堆上。局部变量str指向StringBuffer实例所在堆空间,通过str可以操作该实例,那么str就是StringBuffer实例的强引用,如下图所示。
    image-20220421000025548
  • 强引用具备以下特点:
    • 强引用可以直接访问目标对象。
    • 强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象。
    • 强引用可能导致内存泄漏。

3.3 软引用

——可被回收的引用

软引用是比强引用弱一点的引用类型。一个对象只持有软引用,那么当堆空间不足时,就会被回收。软引用使用java.lang.ref.SoftReference类实现。

实例演示软引用会在系统堆空间不足时被回收

  • 代码示例
    image-20220421002422427
  • 执行结果 --使用参数-Xmx10m 执行上面的代码会得到
    id=1  name = guye (第一次从软引用中获取数据)
    After GC
    id=1  name = guye  (GC没有清除软引用)
    null (由于内存紧张,软引用被清除)
    
  • 分析结果
    因此,从该示例中可以得到结论:GC未必会回收软引用的对象,但是,当内存资源紧张时,软引用对象会被回收,所以软引用对象不会引起内存溢出。

3.4 弱引用

——发现及回收

弱引用是一种比软引用较弱的引用类型。在系统GC时,只要发现弱引用,不管系统堆空间使用情况如何,都会将对象进行回收。但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。一旦一个弱引用对象被垃圾回收器回收,便会加入到一个注册的引用队列中(这一点和软引用很像)。弱引用使用java.lang.ref,WeakReference类实现。

  • 示例代码
    image-20220421003443257
  • 执行结果
    image-20220421003510788
  • 结果分析
    可以看到,在GC之后,不管当前内存空间足够与否,都会回收它的内存。
  • 弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
  • 注意:软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用

4.3 虚引用

——对象回收跟踪

虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象,和没有引用几乎是一样的,随时都可能被垃圾回收器回收。当试图通过虚引用的get()方法取得强引用时,总是会失败。并且,虚引用必须和引用队列一起使用,它的作用在于跟踪垃圾回收过程。

当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况

4.垃圾回收时的停顿现象

垃圾回收器的任务是识别和回收垃圾对象进行内存清理。为了让垃圾回收器可以正常且高效地执行,大部分情况下,会要求系统进入一个停顿的状态。停顿的目的是终止所有应用线程的执行,只有这样,系统中才不会有新的垃圾产生,同时停顿保证了系统状态在某一个瞬间的一致性,也有益于垃圾回收器更好地标记垃圾对象。因此,在垃圾回收时,都会产生应用程序的停顿。停顿产生时,整个应用程序会被卡死,没有任何响应,因此这个停顿也叫做“Stop-The-World”(STW)。

  • 示例代码
    code-snapshot (1)
  • jvm参数:
    image-20220421011316323
  • 执行结果(部分)
    image-20220421011544768
  • 分析结果
    注意画红框的部分,原本应该每0.1秒进行的输出在这几处有着明显的时间间隔。找到对应的GC日志输出如下:
    image-20220421011945277
    注意画红框的部分的GC时间戳和实际花费的时间,不难发现,出现了停顿现象。由此可见,每一次应用程序的意外停顿都可以在GC日志中找到对应的线索给予解释。这也间接证明了GC对于应用程序的影响。
方便获取更多学习、工作、生活信息请关注本站微信公众号城东书院 微信服务号城东书院 微信订阅号
推荐内容
相关内容
栏目更新
栏目热门