JVM垃圾回收小结

发布于 2019-08-13  97 次阅读


垃圾回收的说明

垃圾回收要解决下面的问题:
Who:哪些是不再使用要被当做“垃圾”回收处理的对象?也就是要确定垃圾对象。
Where:在哪里执行垃圾回收?明确要清理的内存区域。
When:什么时候执行GC操作?即JVM触发GC的时机。
How:怎么样进行垃圾对象处理?即GC的实现算法。

其中,对于第二点,oracle Hotspot VM的 GC操作主要回收的内存区域是JVM中的堆

WHO----GC如何判断对象是否可以被回收

引用计数法 (不采用)

每个对象创建的时候,会分配一个引用计数器,当这个对象被引用的时候计数器就加1,当不被引用或者引用失效的时候计数器就会减1。任何时候,对象的引用计数器值为0就说明这个对象不被使用了,就认为是“垃圾”,可以被GC处理掉。
【优点】算法实现简单。
【缺点】不能解决对象之间循环引用的问题。有垃圾对象不能被正确识别,这对垃圾回收来说是很致命的,所以GC并没有使用这种搜索算法。

可达性分析算法(根搜索算法)

引用

在Java中,还存在一些其它的情况,这就要从引用讲起了。我们平时使用的Java对象通常认为只有两种状态,一种是被引用了,在程序中还在使用,另一种是没有被引用,可以被JVM回收。但实际上,Java中的引用一共有四种,它们分别是强引用软引用弱引用虚引用

算法简介

以一些特定的对象作为基础原始对象,或者称作“根”,不断往下搜索,到达某一个对象的路径称为引用链
如果一个对象和根对象之间有引用链,即根对象到这个对象是可到达的,则这个对象是活着的,不是垃圾,还不能回收。例如,假设有根对象O,O引用了A对象,同时A对象引用了B对象,B对象又引用了C对象,那么对象C和根对象O之间的路径的可达的,C对象就不能当做垃圾对象。引用链为O->A->B->C。
反之,如果一个对象和根对象之间没有引用链,根对象到这个对象的路径是不可达的,那么这个对象就是可回收的垃圾对象。

  • 评价:
    【优点】可找到所以得垃圾对象,并且完美解决对象之间循环引用的问题。
    【缺点】不可避免地要遍历全局所有对象,导致搜索效率不高。

根搜索算法是现在GC使用的搜索算法。
JVM中对堆内存进行回收时,需要判断对象是否仍在使用中,如果仍在使用就不回收,那么,如何判断一个堆中的对象是否仍处于被使用状态呢? —— 使用对象是通过其引用来实现的,因此,只要收集JVM中所有活跃状态的引用(注意,是引用而不是对象),然后基于这些引用进行搜索,就可以确认所有处于使用状态的对象;那些搜索不到的对象,也就是不可达的,可以认为已经没有用了,便将之回收。

GC Roots

1.虚拟机栈(栈帧中的本地变量表)中引用的对象;
2.方法区中的类静态属性引用的对象
3.方法区中的常量引用的对象
4.原生方法栈(Native Method Stack)中 JNI 中引用的对象。
其中,对方法区(也称为永久区)的定义是:各个线程共享的内存区域,存储已被虚拟机加载的类信息,变量,静态变量等数据。

JVM中对内存进行回收时,需要判断对象是否仍在使用中,可以通过GC Roots Tracing辨别。
方法: 通过一系列名为”GCRoots”的对象作为起始点,从这个节点向下搜索,搜索走过的路径称为ReferenceChain,当一个对象到GCRoots没有任何ReferenceChain相连时,(图论:这个对象不可到达),则证明这个对象不可用。

How --- 垃圾回收算法

这里讨论的是oracle的Hotspot VM常见的垃圾回收算法。使用的搜索算法都是基于根搜索算法实现的。

Mark-Sweep: 标记-清除算法


标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段首先通过根节点,标记所有从根节点开始的较大对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。该算法最大的问题是存在大量的空间碎片,因为回收后的空间是不连续的。在对象的堆空间分配过程中,尤其是大对象的内存分配,不连续的内存空间的工作效率要低于连续的空间。

从概念上来讲,标记-清除算法使用的方法是最简单的,只需要忽略这些对象便可以了。也就是说当标记阶段完成之后,未被访问到的对象所在的空间都会被认为是空闲的,可以用来创建新的对象。这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小。对空闲列表的管理会增加分配对象时的工作量。这种方法还有一个缺陷就是——虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败(在Java中就是一次 OutOfMemoryError)。

Copying: 复制算法


将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。因此在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象在垃圾回收过程中统一被复制到新的内存空间中,因此,可确保回收后的内存空间是没有碎片的。该算法的缺点是将系统内存折半。

Java 的新生代串行垃圾回收器中使用了复制算法的思想。新生代分为 eden 空间、from 空间、to 空间 3 个部分。其中 from 空间和 to 空间可以视为用于复制的两块大小相同、地位相等,且可进行角色互换的空间块。from 和 to 空间也称为 survivor 空间,即幸存者空间,用于存放未被回收的对象。在垃圾回收时,eden 空间中的存活对象会被复制到未使用的 survivor 空间中 (假设是 to),正在使用的 survivor 空间 (假设是 from) 中的年轻对象也会被复制到 to 空间中 (大对象,或者老年对象会直接进入老年带,如果 to 空间已满,则对象也会直接进入老年代)。此时,eden 空间和 from 空间中的剩余对象就是垃圾对象,可以直接清空,to 空间则存放此次回收后的存活对象。这种改进的复制算法既保证了空间的连续性,又避免了大量的内存空间浪费。

标记-复制算法与标记-整理算法非常类似,它们都会将所有存活对象重新进行分配。区别在于重新分配的目标地址不同,复制算法是为存活对象分配了另外的内存 区域作为它们的新家。标记复制算法的优点在于标记阶段和复制阶段可以同时进行。它的缺点是需要一块能容纳下所有存活对象的额外的内存空间。

Mark-Compact: 标记-压缩(整理)算法


复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在年轻代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。

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

标记-压缩算法修复了标记-清除算法的短板——它将所有标记的也就是存活的对象都移动到内存区域的开始位置。这种方法的缺点就是GC暂停的时间会增 长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。相对于标记-清除算法,它的优点也是显而易见的——经过整理之后,新对象的分 配只需要通过指针碰撞便能完成(pointer bumping),相当简单。使用33这种方法空闲区域的位置是始终可知的,也不会再有碎片的问题了。

安全点和安全区域

Safepoint & SafeRegion

垃圾收集器

垃圾收集器是内存回收的具体实现。JAVA虚拟机规范中对垃圾收集器应当如何实现并没有任何规定。下图是HotSpot虚拟机的垃圾收集器。

图中共展示了7种作用域不同分代的收集器。

如果两个收集器之间存在连线,说明它们可以搭配使用。

虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。

Generational Collecting: 分代回收算法

分代回收器是增量收集的另一个化身,根据垃圾回收对象的特性,不同阶段最优的方式是使用合适的算法用于本阶段的垃圾回收,分代算法即是基于这种思想,它将内存区间根据对象的特点分成几块,根据 每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率。以 Hot Spot 虚拟机为例,它将所有的新建对象都放入称为年轻代的内存区域,年轻代的特点是对象会很快回收,因此,在年轻代就选择效率较高的复制算法。当一个对象经过几 次回收后依然存活,对象就会被放入称为老生代的内存空间。在老生代中,几乎所有的对象都是经过几次垃圾回收后依然得以幸存的。因此,可以认为这些对象在一 段时期内,甚至在应用程序的整个生命周期中,将是常驻内存的。如果依然使用复制算法回收老生代,将需要复制大量对象。再加上老生代的回收性价比也要低于新 生代,因此这种做法也是不可取的。根据分代的思想,可以对老年代的回收使用与新生代不同的标记-压缩算法,以提高垃圾回收效率。

总结与比较

算法名优势缺陷
Mark-Sweep / 标记-清除简单效率低下且会产生很多不连续内存,分配大对象时,容易提前引起另一次垃圾回收。
Copying / 复制效率较高,不用考虑内存碎片化存在空间浪费
Mark-Compact / 标记-整理避免了内存碎片化GC 暂停时间增长

Reference