所谓垃圾收集,就是清理已经不再使用的内存空间,提高内存的利用率。
由于程序计数器、虚拟机栈、本地方法栈都随线程而生而灭,栈中的内存空间也都基本在编译期间就可以确定,所以不需要进行垃圾收集;而方法区和Java 堆则不一样,它们具有不确定性,只有在程序运行期间才能确定会创建哪些对象,这部分内存的回收和分配都是动态的,本篇文章后续讲述的“垃圾收集”就是针对这部分内存。
谈到垃圾收集,就需要问三个问题:
- What: 哪些内存需要回收?
- When: 什么时候回收?
- How: 如何回收?
让我们从这三个基本问题开始进入本篇的学习吧!
哪些内存需要回收?
需要回收的内存肯定是没有再被引用的对象实例,它们永远都无法再被继续调用了,也就是“名存实亡”了。那么该如何知道对象是否被引用了呢?
引用计数算法
这个算法很简单高效,就是一个对象引用它的时候计数器就加1,引用失效(例如A = null
)就减1,当一个对象的引用计数器为0时就说明没有对象引用它了,那就表示它“死亡”了。
然而至少 Java 虚拟机里并没有用引用计数算法进行内存管理,因为它无法解决相互循环引用的问题。
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC() {
ReferenceCountingGC refA = new ReferenceCountingGC();
ReferenceCountingGC refB = new ReferenceCountingGC();
refA.instance = refB;
refB.instance = refA;
refA = null;
refB = null;
System.gc();
}
}
例如上面一段代码,两个对象分别把持着对方对象的引用,这将导致它们的引用计数器都不能变为0,从而也不能被回收。
可达性分析算法
在主流的商用程序设计语言中都基本上采用可达性分析算法来判断对象是否存活的。这个算法就是把一系列的GC Root
作为根节点,从它们开始向下遍历,若一个对象到任何GC Root
间均不可达,那它就是不可用的对象,可以被回收,因此这种算法又叫做根节点枚举。
GC Root
却是不可达的,所以会被判定为可回收对象。在 Java 语言中,可作为
GC Root
的对象包括下面几种:
- 虚拟机栈中引用的对象(也就是局部变量引用的对象)
- 方法区的静态变量所引用的对象
- 方法区中常量引用的对象
- 本地方法栈 JNI 引用的对象
就拿上面那段代码来说,在执行完
refA = null;
refB = null;
以后,那两个实例对象虽然都怀着对方的引用,但均无法作为GC Root
,又没有其他GC Root
与他们建立联系,所以都会被视为可以回收的对象而被回收。
生存还是死亡
然而通过可达性分析被标记为可以回收的对象就一定“非死不可”吗?不一定,要真正宣告一个对象死亡,至少要经历两次标记过程:在对象被可达性分析算法标记为可以回收后,会进行一次筛选,筛选出有必要执行finalize
方法的对象,然后把它们放置在一个低优先级的线程队列中依次执行finalize
方法。
那什么叫没有必要执行 finalize
方法呢?就是当对象没有覆盖默认的finalize
方法或者这个finalize
方法之前被虚拟机调用过一次。这两种情况下虚拟机都会认为没有必要执行finalize
方法,这样对象就真的死亡了。
而在对象执行完finalize
方法之后,虚拟机会对这些对象进行第二次标记,若它还没有与任何GC Root
建立联系的话,那就真的会被回收。也就是说,只要覆盖对象的finalize
方法,使它与任何一个局部变量或静态变量建立引用关系,那么它就可以免除被回收的命运,从而“自救成功”。但这种自救只能拯救一次,因为一个对象的finalize
方法最多只能被执行一次。
什么时候回收?
在进行可达性分析的时候,所有线程都需要暂停下来,因为若是出现分析过程中对象的引用关系还在不断变化的状况,分析结果的准确性就无法达成保障。那么问题就来了,系统应该在什么时候暂停所有的线程并进行 GC 呢?
安全点
Java 程序执行时并非在任何地方都能停下来开始 GC,只有在到达一些特定的地方才行,这些地方叫安全点(Safepoint)。 安全点的选择既不能太少以致于长时间没有进行 GC,又不能太频繁以致于过分增大运行时的负荷。所以安全点的选择基本上是以附近的语句“是否具有让程序长时间执行的特征”为标准进行选定的,而“长时间执行”执行最明显的特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等地方附近都会有安全点。
对于安全点,还有一个问题需要考虑,如何在 GC 发生时让所有的线程都跑到最近的安全点上停下来。主要有两种方案可供选择:
抢先式中断:GC 发生时,先将所有线程都暂停下来,若是有些线程不在安全点,则恢复线程,让它跑到最近的安全点再停下来。这种方案几乎没有虚拟机采用。
主动式中断:GC 发生时,系统先将一个标志位设置为真,各个线程在执行到安全点的时候判断这个标志位,若发现其为真就中断挂起。直到所有线程都停下来之后再进行可达性分析等操作。这个方法简单高效,使用比较普遍。
安全区域
上面的方法似乎完美解决了这个问题,但只是对于正在执行的线程,若是那些处于睡眠或阻塞状态的线程,它们显然无法到达安全点,而 JVM 也不可能等待 CPU 重新调度它。这个时候就需要安全区域(Safe Region)了。
安全区域是指在一段引用关系不会发生变化的代码,在这个区域中的任何时候开始 GC 都是安全的。安全区域可以看作是安全点的拓展。
当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。
如何回收?
主要的垃圾收集算法有下面三种:
标记 - 清除算法
如同它的名字一样,它只有两个步骤,先是按照可达性算法标记哪些内存需要回收,然后再一次性清理掉这些内存。缺点也很明显:一是效率问题,标记和清除这两个过程的效率都比较低;二是这种算法会产生很多内存碎片。
复制算法
这种算法的核心是“复制”,先把内存分成大小相等的两部分,每次都只使用其中的一部分,当使用的那一部分需要内存回收的时候,就将还存活着的对象复制到另一半上,然后再将先前的那一半一次清理掉。这种方法简单高效,解决了内存碎片的问题,但缺点是每次都只使用总内存的一半,浪费比较大。
标记 - 复制算法示意图然而现在流行的商业虚拟机都是采用这种算法来回收新生代,不过不是将内存空间1:1等分,而是将内存分成一块大的 Eden 分区和两块小的 Survivor 分区,大小比例为8:1,每次使用 Eden 和其中的一块 Survivor 分区。当回收的时候,将 Eden 和 Survivor 中还存活的对象一次性复制到剩下的 Eden 分区上。
类似下图所示: 主流虚拟机采用的复制算法示意图
为何可以这样做呢?IBM公司专门做过研究,新生代中的对象 98% 都是“朝生暮死的”,也就是说只有很少一部分的对象会在一次 GC 中存活下来,所以只占 10% 的 Survivor分区也是基本够用的。不过,98% 的对象可回收只是一般情况下,无法保证每次存活的对象都只有不到 10%,那么就需要分配担保了,意思就是当 Survivor 分区内没有足够的空间存放上一次从新生代存活的对象的时候,多出的这部分对象会直接进入老年代区域。
标记 - 整理算法
由于复制算法需要花很多时间进行存活对象的复制,效率比较低,而且还需要分配担保。而对于老年代区域的对象,没有额外的区域进行分配担保,就无法使用复制算法,于是“标记 - 整理”算法应运而生。
该算法的标记过程与“标记 - 清除”算法一样,但后续步骤不是对可回收对象进行回收,而是让存活的对象都向同一端移动,然后清理掉端边界以外的内存,如图所示: 标记 - 整理算法示意图分代收集算法
主流的商业虚拟机都采用“分代收集算法”,就是将内存区域根据对象存活周期将内存分成几块。具体是分成新生代和老年代,再根据不同的年代使用适合它们的垃圾收集算法。
例如新生代中,每次垃圾收集会有大量的对象死去,存活的较少,故可以采用复制算法。
而老年代中,对象存活率高、没有额外的空间进行分配担保,故可以使用“标记 - 清除”或“标记 - 整理”算法。
总结
本篇文章介绍了垃圾收集的三个核心部分:垃圾收集的对象的判定,垃圾收集的时间和垃圾收集的方法。下一节将介绍 HotSpot 虚拟机中几种具体的垃圾收集器是如何实现的。
网友评论