JVM - 浅谈 GC 垃圾回收

浅谈 GC 垃圾回收

  • 一、关于回收目标
  • 二、方法区回收
    • 1. 常量回收
    • 2. 类卸载
  • 三、堆回收
    • 1. 堆空间的划分
      • (1) 新生代
      • (2) 老年代
    • 2. 对象存活判定
      • (1) 标记算法
        • a. 引用计数法
        • b. 可达性分析算法
      • (2) 死亡判定
    • 3. 垃圾回收算法
      • (1) 标记-清除算法
      • (2) 复制算法
      • (3) 标记-整理算法
    • 4. 总结
  • 参考资料

一、关于回收目标

一个GC过程在逻辑上需要经过两个步骤:先判断,再回收。即先判断哪些对象是存活的、哪些对象是死亡的,然后对死亡的对象进行回收。

JVM的内存分为多个区域,不同区域的实现机制以及功能不同,那么各自的回收目标也不同。一般来说,内存回收主要涉及以下 3 个区域:

  • 虚拟机栈/本地方法栈:顾名思义,该部分内存以栈的形式作为实现,那么在进/出栈时内存会自动释放,类似于C语言的自动变量区域内存
  • 堆:内存回收的主要目标,可以认为类似于C语言中的动态内存分配区域,只是C语言通过malloc与free函数手动进行管理,而Java通过GC自动管理
  • 方法区:该区域回收效果很弱,虚拟机规范强制要求在这里进行回收。回收目标是常量池的回收和对类型的卸载

二、方法区回收

方法区的回收目标是回收常量池中的废弃常量类卸载

1. 常量回收

若常量池中某常量没有任何地方引用或者使用,包括该常量不易字面量的形式被使用或引用,则可以被回收。

2. 类卸载

满足以下条件的类可以被卸载:

  • 该类所有实例已被回收
  • 该类的 ClassLoader 已被回收
  • 该类的类型信息,即 java.lang.Class 没有任何地方引用(一般为反射使用)

类的卸载要求十分严苛。在大量使用反射、动态代理、cglib等字节码框架动态生成JSP以及OSGI这类频繁自定义 ClassLoader 功能的场景中,都要求JVM具备类卸载功能,以保证方法区不会溢出。

三、堆回收

1. 堆空间的划分

从GC的角度看,JVM的堆内存可以进一步细分为新生代Young)和老年代Old)。

JVM - 浅谈 GC 垃圾回收_第1张图片

(1) 新生代

存放新实例化的对象,一般占据堆的1/3空间。

由于新生代区要频繁创建对象,因此会频繁触发 MinorGC 进行垃圾回收。新生代又进一步细分为 Eden 区、SurvivorFrom 区和 SurvivorTo 区。MinorGC采用复制算法

  • Eden:Java 新对象的出生地(如果新创建的对象占用内存很大,则会直接分配到老年代)。当 Eden 区内存不足时就会出发 MinorGC,对新生代区进行一次垃圾回收
  • SurvivorFrom:前一次 GC 的幸存对象,作为当前 GC 的被扫描对象
  • SurvivorTo:保留了一次 MinorGC 过程中的幸存者

(2) 老年代

主要存放应用程序中生命周期长的内存对象,一般占据2/3的对空间。

老年代的对象比较稳定,所有 MajorGC 不会频繁执行。在进行 MajorGC 前,一般都已经进行了一次 MinorGC,使得有新生代的对象晋身为老年代,导致空间不够时才出发。当无法找到足够大得连续空间分配给新创建的较大对象时也会提前触发一次MajorGC,进而腾出内存空间。MajorGC采用标记-清除算法

2. 对象存活判定

关于堆中对象存活判定:以标记为基础,并配合其它步骤完成。

(1) 标记算法

a. 引用计数法

给对象添加引用计数器,每有一个地方进行引用,则计数器加1。当计数器为0时,表示该对象可被回收。

引用计数法未被 JVM 采用,原因是无法解决对象间循环引用的问题,如下图所示,当堆中两个对象循环引用,即使它们已经没用了,也无法判定被回收。

JVM - 浅谈 GC 垃圾回收_第2张图片

b. 可达性分析算法

该算法的思想是将一系列被称为 GC ROOTS 的对象作为起点(或称根节点),向下搜索,所走过的路径称为引用链(reference chain)。若一个对象没有可以到达 GC ROOTS 的路径,则称对象不可达。对于不可达对象,会被标记为回收状态

JVM - 浅谈 GC 垃圾回收_第3张图片
上图中,顺着 GC ROOTS,Obj1、Obj2、Obj3和Obj4都是可以到达的,因此他们为存活对象;而Obj5不可到达,Obj6、Obj7即使存在指向它们的引用,但因无法到达GC ROOTS,因此为需要回收的对象。

在可达性分析算法中,最重要的就是 GC ROOTS。其本质是对象,但并非所有对象都有资格作为 GC ROOTS,只有以下位置的才可以:

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

(2) 死亡判定

对象在经过标记之后,并不会马上被回收,还要经过以下一系列阶段才最终确定需要被回收:

JVM - 浅谈 GC 垃圾回收_第4张图片

  • 一次标记:即通过标记算法将对象标记为待回收状态,并进入一个待回收对象集合;
  • 筛选:对一次标记之后的待回收对象进行过滤,如果该对象覆盖了 finalize 方法,并且该方法未执行过,则将该对象放入 F-QUEUE;反之,对象没有覆盖 finalize 方法或者 finalize 方法已经被执行过了,该对象不会进行任何处理;
  • F-QUEUE:一个队列,JVM 会通过一个 Finalizer 线程去执行这个队列中对象的 finalize 方法,并且只保证该方法的执行,不保证该方法成功执行完成。因为若 finalize 方法有死循环,会造成 F-QUEUE 后续未被执行对象的持续等待,导致整个内存回收系统崩溃。根据这个特点,对象可以在执行 finalize 方法时进行 “自救”,所谓的自救,就是将对象重新与 GC ROOTS 相关联;
  • 二次标记:GC 会对 FQUEUE 中的对象进行额外的一次标记,若对象 “自救” 成功,则会从待回收对象集合中移除;若对象 “自救” 失败,它仍然会处于待回收对象集合中,等待真正被回收;
  • 回收:对象通过垃圾收集进行回收,释放内存空间;

3. 垃圾回收算法

(1) 标记-清除算法

标记-清除算法(mark-sweep),是最基础的垃圾收集算法,它的思想是在对象存活判定标记出需要回收的对象后,统一回收(清除)这些对象的内存。

JVM - 浅谈 GC 垃圾回收_第5张图片
mark-sweep简单有效,但是存在两点不足:

  • 一是效率问题,标记和清除两个阶段的效率都不高,所谓效率不高,并非指自身的执行效率,而是指回收结果与耗时的收益比不高;

  • 二是空间问题,mark-sweep算法并未整理内存,会产生大量的内存碎片,要分配内存较大的对象时,可能无法找到足够长的连续内存而不得不又触发一次GC。

(2) 复制算法

复制算法(copying)是基于 mark-sweep 算法的改进,其主要思想是将内存划分为不同的区域,包括内存使用区结果缓冲区。每次只使用一部分内存,在该部分内存满了之后,将仍然存活的对象复制到另外一块区域上面,然后将之前使用过的内存区域全部清理。现代商业虚拟机大都采用复制算法作为新生代区的 GC 算法

复制算法大大提高了回收效率,也可以避免内存碎片。然而带来了新的问题:由于需要开辟一块内存空间作为每次回收结果的缓冲,因此可用内存无法达到100%,结果缓冲区的大小决定了内存有效的比率。

如何设置结果缓冲区的内存大小(比例)?将其设置为 50% 最能确保每次回收都有足够大小的缓冲区域存放回收结果,毕竟最差的情况就是所有对象都存活,然而内存浪费也太高了。根据IBM的研究,一般情况下,新生代中的对象98%都是 “朝生夕死” 的,也就是说,每次存活对象的比例并不会太高,我们只需要设置一小块内存作为回收结果缓冲即可,他们提出的解决模型如下,将内存划分为 1 块 Eden 与 2 块 Suvivor

JVM - 浅谈 GC 垃圾回收_第6张图片

  • Eden:主存储区,新对象的创建都在这块区域
  • Survivor:分为两块,一块作为上次回收结果的缓存(Survivor From),一块作为下一次回收的缓存区域(Survivor To)

新生代区的划分逻辑
1.因为新生代中的对象创建和垃圾回收十分频繁,每一轮GC后,绝大部分的对象都会被回收,只有少部分得以保存,所以使用复制算法的代价较小。
2.新生代区采用 Eden(8) + Survivor(1) + Survivor(1) 的子区划分方式是对内存使用区和结果缓冲区策略的高效实现

基于这种模型,每次回收时,将 Eden 和上次回收结果的 Survivor 中存活的对象复制进空闲的 Survivor,然后清理掉被回收的区域即可,简单的示意流程图见下:

JVM - 浅谈 GC 垃圾回收_第7张图片
值得注意的是,对于Eden-Survivor模型,98%的对象可回收只是理想理论,在某些场景下,回收时存活对象的大小有可能大于空闲Survivor。对于这种Survivor空间大小不够用的情况,需要通过分配担保机制来保证对象能正确留存。所谓的分配担保,就是不够空间survivor存放的对象进入老年代区。

设置老年代区是对 Eden-Survivor 机制的冗余担保策略。此外,为对象标记年龄属性,每当对象在Survivor区躲过一次GC后,其年龄会+1。默认情况下年龄到达15时,由于相对存活稳定,对象也会被转移到老年代中。

(3) 标记-整理算法

复制算法主要适合于新生代的回收,对于老年代这种对象存活率高的区域,因为每次都会复制大量对象,成本收益比较低,使用复制算法明显不合适;相反,标记-清除算法更适合老年代的特征,为了解决标记-清除算法的内存碎片问题,在此基础上,优化为标记-整理算法(mark-compact)。

标记-整理算法主要思想是在标记对象后,将存活对象向内存的一端移动,然后清理掉端边界以外的内存,所谓的整理也可以理解为压缩

JVM - 浅谈 GC 垃圾回收_第8张图片

关于标记-清除算法、复制算法和标记整理算法:
1.复制算法规避了标记-清除算法的时间效率过低的问题;
2.标记-整理算法克服了标记-清除算法的内存碎片化的问题。

4. 总结

没有哪一种垃圾收集算法能够适用于所有情况,对于不同的堆内存区域(新生代、老年代),需要根据实际的对象特征,选择合适的算法。

算法 优点 缺点 适用区域
标记-清除 简单有效 1.效率不高;2.有内存碎片问题; 新生代(对象存活率低,复制成本低,需要提供冗余担保空间)
复制算法 效率较高,无内存碎片问题 1.内存利用率达不到100%;2.需要分配担保机制(增设老年代)确保对象存活率较高时的内存分配; 老年代(对象存活率高,无额外空间进行分配担保)
标记-整理 标记-清除的改良,解决了内存碎片问题 1.同样存在效率问题;2.整理过程需要额外的时间开销; 老年代

参考资料

  1. JVM垃圾回收理论

你可能感兴趣的:(JVM,jvm,java,编程语言)