JVM 学习笔记(三):垃圾回收概念与算法

垃圾回收的概念和意义

Java 语言的一个重要特性是引入了自动的内存管理机制,使得开发人员不用自己来管理应用中的内存。

C/C++ 开发人员需要通过 malloc/free 和 new/delete 等函数来显式的分配和释放内存。这对开发人员提出了比较高的要求,容易造成内存访问错误和内存泄露等问题。一个常见的问题是会产生 “悬挂引用(dangling references)”,即一个对象引用所指向的内存区块已经被错误的回收并重新分配给新的对象了,程序如果继续使用这个引用的话会造成不可预期的结果。开发人员有可能忘记显式的调用释放内存的函数而造成内存泄露。

自动的内存管理则是把管理内存的任务交给编程语言的运行环境来完成。开发人员并不需要关心内存的分配和回收的底层细节。Java 平台通过垃圾回收器来进行自动的内存管理。

Java 的垃圾回收器要负责完成 3 件任务:分配内存、确保被引用的对象的内存不被错误回收以及回收不再被引用的对象的内存空间。

垃圾回收是一个复杂而且耗时的操作。如果 JVM 花费过多的时间在垃圾回收上,则势必会影响应用的运行性能。

一般情况下,当垃圾回收器在进行回收操作的时候,整个应用的执行是被暂时中止(stop-the-world)的。这是因为垃圾回收器需要更新应用中所有对象引用的实际内存地址。

不同的硬件平台所能支持的垃圾回收方式也不同。比如在多 CPU 的平台上,就可以通过并行的方式来回收垃圾。而单 CPU 平台则只能串行进行。

不同的应用所期望的垃圾回收方式也会有所不同。服务器端应用可能希望在应用的整个运行时间中,花在垃圾回收上的时间总数越小越好。

而对于与用户交互的应用来说,则可能希望所垃圾回收所带来的应用停顿的时间间隔越小越好。对于这种情况,JVM 中提供了多种垃圾回收方法以及对应的性能调优参数,应用可以根据需要来进行定制。

常用的垃圾回收算法:

引用计数法(Reference Counting)

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

引用计数器的实现也非常简单,只需要为每个对象配备一个整型的计数器即可。但是它有两个非常严重的问题:

(1)无法处理循坏引用的情况。因此,在 Java 的垃圾回收器中,没有使用这种算法。

(2)引用计算器需要在每次引用产生和消除的时候,需要伴随一个加法操作和减法操作对系统性能有一定的影响。

JVM-Reference-Counting

标记清除法(Mark-Sweep)

标记清除算法是现代垃圾回收算法的思想基础。标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后清除阶段,清除所有未被标记的对象。

标记清除算法可能产生的最大问题是空间碎片。

JVM-Mark-Sweep

【名词解释】

  • 可达对象:指通过根对象进行引用搜索,最终可以达到的对象。
  • 不可达对象:通过根对象进行引用搜索,最终没有被引用到的对象。

复制算法(Copying)

复制算法的核心思想是:将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清楚正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

如果系统中的垃圾对象很多,复制算法需要复制的对象数量就会相对较少。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象是在垃圾回收过程中,统一被复制到新的内存空间中的,因此,可确保回收后的内存空间是没有碎片的。虽然有以上两个优点,但是复制算法的代价却是将系统内存折半,因此,单纯的复制算法也很难让人接受。

复制算法示意图:
JVM-Copying

改进的复制算法:
JVM-Copying-Improvement

标记压缩法(Mark-Compact)

标记压缩算法是一种老年代的回收算法。它在标记清除算法的基础上做了一些优化。和标记清除算法一样,标记压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不只是简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后清理边界外所有的空间。

这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此性价比较高。

标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记清除压缩算法(MarkSweepCompact)。

JVM-Mark-Compact

分代算法(Generational Collecting)

前文介绍了复制、标记清除、标记压缩等垃圾回收算法。在所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有独特的优势和特点。因此,根据垃圾回收对象的特性,使用合适的算法回收,才是明智的选择。分代算法就是基于这种思想,它将内存区间根据对象的特点分成几块,根据每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率。

一般来说,Java 虚拟机将所有的新建对象都放入成为新生代的内存区域,新生代的特点是对象的朝生夕灭,大约 90% 的新建对象会被很快回收,因此,新生代比较适合使用复制算法。当一个对象经历过几次回收后依然存活,对象就会被放入成为老年代的内存空间。在老年代中,几乎所有的对象都是经历几次垃圾回收后依然得以存活的。因此,可以认为这些对象在一段时期内,甚至在应用程序的整个生命周期中,将是常驻内存的。

在极端情况下,老年代对象的存活率可以达 100%。如果依然使用复制算法回收老年代,将需要复制大量对象。再加上老年代的回收性价比也要低于新生代,因此这种做法是不可取的。根据分代的思想,可以对老年代的回收使用与新生代不同的标记压缩或标记清除算法,以提高垃圾回收效率。

JVM-Generational-Collecting

分区算法(Region)

分代算法将按照对象的生命周期长短划分成成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

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

JVM-Region

对象的可触及性判断

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

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

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

引用和可触及性的强度,在 Java 中提供了 4 个级别的引用:强引用、软引用、弱引用和虚引用。除了强引用外,其他三种引用均可以在 java.lang.ref 包中找到它们的身影。

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

强引用

强引用具备以下特点:

  • 可以直接访问目标对象。
  • 所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出 OOM 异常,也不会回收强引用所指向的对象。
  • 可能导致内存泄露。

软引用

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

弱引用 —— 发现即回收

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

注意:软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

虚引用 —— 对象回收跟踪

虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象,和没有引用几乎是一样的,随时都可能被垃圾回收器回收。虚引用必须和引用队列一起使用,它的作用在于跟踪垃圾回收的过程。

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

垃圾回收时的停顿现象

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