一个GC过程在逻辑上需要经过两个步骤:先判断,再回收。即先判断哪些对象是存活的、哪些对象是死亡的,然后对死亡的对象进行回收。
JVM的内存分为多个区域,不同区域的实现机制以及功能不同,那么各自的回收目标也不同。一般来说,内存回收主要涉及以下 3 个区域:
方法区的回收目标是回收常量池中的废弃常量与类卸载。
若常量池中某常量没有任何地方引用或者使用,包括该常量不易字面量的形式被使用或引用,则可以被回收。
满足以下条件的类可以被卸载:
类的卸载要求十分严苛。在大量使用反射、动态代理、cglib等字节码框架、动态生成JSP以及OSGI这类频繁自定义 ClassLoader 功能的场景中,都要求JVM具备类卸载功能,以保证方法区不会溢出。
从GC的角度看,JVM的堆内存可以进一步细分为新生代(Young)和老年代(Old)。
存放新实例化的对象,一般占据堆的1/3空间。
由于新生代区要频繁创建对象,因此会频繁触发 MinorGC 进行垃圾回收。新生代又进一步细分为 Eden 区、SurvivorFrom 区和 SurvivorTo 区。MinorGC采用复制算法。
主要存放应用程序中生命周期长的内存对象,一般占据2/3的对空间。
老年代的对象比较稳定,所有 MajorGC 不会频繁执行。在进行 MajorGC 前,一般都已经进行了一次 MinorGC,使得有新生代的对象晋身为老年代,导致空间不够时才出发。当无法找到足够大得连续空间分配给新创建的较大对象时也会提前触发一次MajorGC,进而腾出内存空间。MajorGC采用标记-清除算法。
关于堆中对象存活判定:以标记为基础,并配合其它步骤完成。
给对象添加引用计数器,每有一个地方进行引用,则计数器加1。当计数器为0时,表示该对象可被回收。
引用计数法未被 JVM 采用,原因是无法解决对象间循环引用的问题,如下图所示,当堆中两个对象循环引用,即使它们已经没用了,也无法判定被回收。
该算法的思想是将一系列被称为 GC ROOTS 的对象作为起点(或称根节点),向下搜索,所走过的路径称为引用链(reference chain)。若一个对象没有可以到达 GC ROOTS 的路径,则称对象不可达。对于不可达对象,会被标记为回收状态。
上图中,顺着 GC ROOTS,Obj1、Obj2、Obj3和Obj4都是可以到达的,因此他们为存活对象;而Obj5不可到达,Obj6、Obj7即使存在指向它们的引用,但因无法到达GC ROOTS,因此为需要回收的对象。
在可达性分析算法中,最重要的就是 GC ROOTS。其本质是对象,但并非所有对象都有资格作为 GC ROOTS,只有以下位置的才可以:
对象在经过标记之后,并不会马上被回收,还要经过以下一系列阶段才最终确定需要被回收:
标记-清除算法(mark-sweep),是最基础的垃圾收集算法,它的思想是在对象存活判定标记出需要回收的对象后,统一回收(清除)这些对象的内存。
一是效率问题,标记和清除两个阶段的效率都不高,所谓效率不高,并非指自身的执行效率,而是指回收结果与耗时的收益比不高;
二是空间问题,mark-sweep算法并未整理内存,会产生大量的内存碎片,要分配内存较大的对象时,可能无法找到足够长的连续内存而不得不又触发一次GC。
复制算法(copying)是基于 mark-sweep 算法的改进,其主要思想是将内存划分为不同的区域,包括内存使用区和结果缓冲区。每次只使用一部分内存,在该部分内存满了之后,将仍然存活的对象复制到另外一块区域上面,然后将之前使用过的内存区域全部清理。现代商业虚拟机大都采用复制算法作为新生代区的 GC 算法。
复制算法大大提高了回收效率,也可以避免内存碎片。然而带来了新的问题:由于需要开辟一块内存空间作为每次回收结果的缓冲,因此可用内存无法达到100%,结果缓冲区的大小决定了内存有效的比率。
如何设置结果缓冲区的内存大小(比例)?将其设置为 50% 最能确保每次回收都有足够大小的缓冲区域存放回收结果,毕竟最差的情况就是所有对象都存活,然而内存浪费也太高了。根据IBM的研究,一般情况下,新生代中的对象98%都是 “朝生夕死” 的,也就是说,每次存活对象的比例并不会太高,我们只需要设置一小块内存作为回收结果缓冲即可,他们提出的解决模型如下,将内存划分为 1 块 Eden 与 2 块 Suvivor:
新生代区的划分逻辑
1.因为新生代中的对象创建和垃圾回收十分频繁,每一轮GC后,绝大部分的对象都会被回收,只有少部分得以保存,所以使用复制算法的代价较小。
2.新生代区采用 Eden(8) + Survivor(1) + Survivor(1) 的子区划分方式是对内存使用区和结果缓冲区策略的高效实现。
基于这种模型,每次回收时,将 Eden 和上次回收结果的 Survivor 中存活的对象复制进空闲的 Survivor,然后清理掉被回收的区域即可,简单的示意流程图见下:
值得注意的是,对于Eden-Survivor模型,98%的对象可回收只是理想理论,在某些场景下,回收时存活对象的大小有可能大于空闲Survivor。对于这种Survivor空间大小不够用的情况,需要通过分配担保机制来保证对象能正确留存。所谓的分配担保,就是不够空间survivor存放的对象进入老年代区。
设置老年代区是对 Eden-Survivor 机制的冗余担保策略。此外,为对象标记年龄属性,每当对象在Survivor区躲过一次GC后,其年龄会+1。默认情况下年龄到达15时,由于相对存活稳定,对象也会被转移到老年代中。
复制算法主要适合于新生代的回收,对于老年代这种对象存活率高的区域,因为每次都会复制大量对象,成本收益比较低,使用复制算法明显不合适;相反,标记-清除算法更适合老年代的特征,为了解决标记-清除算法的内存碎片问题,在此基础上,优化为标记-整理算法(mark-compact)。
标记-整理算法主要思想是在标记对象后,将存活对象向内存的一端移动,然后清理掉端边界以外的内存,所谓的整理也可以理解为压缩。
关于标记-清除算法、复制算法和标记整理算法:
1.复制算法规避了标记-清除算法的时间效率过低的问题;
2.标记-整理算法克服了标记-清除算法的内存碎片化的问题。
没有哪一种垃圾收集算法能够适用于所有情况,对于不同的堆内存区域(新生代、老年代),需要根据实际的对象特征,选择合适的算法。
算法 | 优点 | 缺点 | 适用区域 |
---|---|---|---|
标记-清除 | 简单有效 | 1.效率不高;2.有内存碎片问题; | 新生代(对象存活率低,复制成本低,需要提供冗余担保空间) |
复制算法 | 效率较高,无内存碎片问题 | 1.内存利用率达不到100%;2.需要分配担保机制(增设老年代)确保对象存活率较高时的内存分配; | 老年代(对象存活率高,无额外空间进行分配担保) |
标记-整理 | 标记-清除的改良,解决了内存碎片问题 | 1.同样存在效率问题;2.整理过程需要额外的时间开销; | 老年代 |