GC 核心关注点都在这里
本文主要把握 3 个问题,哪些内存需要回收,什么时候回收以及如何回收。继续阅读,你会找到答案的。
Java 运行时内存区域可以划分为程序计数器,虚拟机栈,本地方法栈,Java 堆,方法区,运行时常量池这几个部分。程序计数器,虚拟机栈,本地方法栈这 3 个区域会随着线程的存在而存在且在程序编译期间就确定了,所以我们在讨论垃圾回收的时候不考虑他们,主要考虑的就是 Java 堆和方法区,这部分内存在程序运行时是动态分配的,也是垃圾收集器主要关注的区域。
在确定了哪些内存需要回收之后,我们再看看什么时候回收。
我们常使用引用计数法和可达性分析法来确定对象是不是可用的。但是呢,可用和回收之间还隔着一段距离,回收的依据是对象是不是已经“死亡”,不可用的对象到死亡对象还需要走个流程,这个流程主要就是 finalize 方法。
引用计数算法
该算法大致就是给对象添加一个引用计数器每当有一个地方引用它时,计数器值就加 1,当引用失效时,计数器就减 1。计数器为 0 的时候代表没有任何引用,即该对象不可再被使用。
这种方式实现简单,判定效率高,但是存在一个问题,那就对象之间相互引用,此时引用计数器并不为 0 ,但是已经没有任何有效引用了。
可达性分析算法
在数据结构中我们使用可达性来表示图形中的某个点是否可达。这里类比一下就是我们从 GC Roots 出发,若是到达不了某个对象,则表示对象是不可用的。这里比较恶心的就是 GC Roots 是什么东西呢?
在 Java 语言中,可以作为 GC Roots 的对象包括这么几种
1 虚拟机栈中引用的对象。
2 方法区中类静态属性引用的对象。
3 方法区中常量引用的对象。
4 本地方法栈,Native 方法中引用的对象。
到这里我们只是确定了那些对象是不可用的,但是还没有到非死不可的地步,所以 GC 现在还不能回收这些对象。
在可达性分析算法中,想要确定一个对象已经死亡,至少要经历两次标记过程,若是对象与 GC Roots 之间没有相连接的引用链,那么它会被第一次标记并且进行一次筛选。
筛选的条件就是是否有必要执行 finalize 方法,为什么这么做呢,这就相当于给你一个机会(不建议使用),若是你不想让对象这么快死,你就可以在 finalize 方法中重新引用这个对象,将 this 对象指向一个变量即可,这样该对象就变成可达对象了。
假如我们没有重写 finalize 方法,或是虚拟机已经执行过 finalize 方法,那就没有机会了,简单说就是等死吧。因为在第二次标记的时候发现,好吧,你还是没有引用链,正式回收。
这里说一下,我们若是重写了对象的 finalize 方法,则会将这些对象放在一个 F-Quene 的队列中,等待一个虚拟机创建的低优先级的线程 Finalizer 线程去执行它的 finalize 方法,但是呢,又不保证一定会执行结束该方法,应该是分时执行,不然若是有一个方法中发生了死循环,那后面的方法就无法执行了。
你看,finalize 方法还真的是不放弃不抛弃啊,但是呢,这里不建议大家使用这个方法,因为不确定性太大,首先是只会执行一次,其次是能不能顺序执行也不保证。另外,大家也不要想着在这个方法中释放资源,使用 try catch 会更好。
回顾一下哪些内存需要回收,堆和方法区。什么时候回收,对象已死的时候回收,判断对象死没死需要首先判断是不是不可用的对象 + 两次标记。
但是,很可惜,上面说的回收的时点都是在堆中,我们还有方法区需要回收,大家都知道的在方法区(永久代)中的回收效率比较低。在堆中的新生代中的回收效率大概是75%-90%。
永久代的回收主要回收两部分内容:废弃常量和无用的类。
其中废弃常量的回收条件和堆中对象类似,若是没有地方引用该常量,则回收时可能会回收该常量。而要判断一个类是无用的类则复杂的多,需要同时满足以下条件:
1 该类的所有实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
2 加载该类的 ClassLoader 已经被回收。
3 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机满足了以上几个条件之后也不是说就是会回收该类,只能证明该类是无用的,是否对类进行回收,HotSpot 虚拟机提供了 -Xnoclassgc 参数控制。
在大量使用反射、动态代理、CGLib等框架,支持动态生成 JSP 以及 OSGI 这类频繁定义 ClassLoader 的场景中都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
现在已经清楚了哪些内存需要回收,Java 堆和方法区。也知道了什么时候回收。下面就看看如何回收。
标记-清除算法
该算法分为两个阶段,标记和清除。首先要标记出所有需要回收的对象,在标记完成后统一回收所有标记的对象,它是一个基础的算法,有如下不足,一是效率问题,标记和清除的效率都不高,二是空间问题,标记清除之后会产生大量不连续的内存碎片。空间碎片多,可能导致的就是在分配大内存对象时,空间不足,不得不再进行一次 GC。
复制算法
为了解决效率问题,出现了复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存使用完了,就将还存活着的对象复制到另外一块上面。问题你可能发现了,代价太高,内存直接缩小为一半。
在实际应用中,商用虚拟机会采用这种算法来回收新生代,IBM 的研究表明,新生代中的对象 98% 都是朝生夕死的那种,所以我们不需要按照 1:1 来分配内存空间。
而是将实际的空间分为一个 Eden 和两个 Survivor,而 Eden 和 Survivor 的内存比例为 8:1,我们每次是将一个 Eden 和一个 Survivor 的存活的对象移到另一个 Survivor 中,这样我们内存的使用就达到了 90%,但是这样还会出现一个问题,假如出现意外,10% 的内存不够用怎么办?这时会有永久代作为内存担保,不够用直接分配在永久代中即可。
这里解释一下,标记-清除也好,复制算法也好,这只是一种指导思想,具体的虚拟机 GC 实现是什么,都是不太一样的,而且虚拟机本身也是使用分代收集(新生代(新生代又分为一个 Eden,两个 Survivor)和老年代)算法,不同的年代,使用的算法也不一样。
标记-整理算法
复制算法在对象存活率较高的区域中回收时就要频繁的进行复制操作,效率变低,还有一点,必须有空间进行担保(新生代有老年代做担保,老年代肯定不能使用这种算法),根据老年代的特点,就出现了标记-整理算法,标记整理算法和标记清除类似,首先就是标记可回收对象,不过这里的回收之后不会留出碎片,它会将存活对象移到一边,然后清除其余空间,这就很好的解决了标记-清除中的碎片问题。
分代收集算法也就是根据对象存活周期的不同,将内存划为不同的区域,一般是分为新生代和老年代,根据不同的各个年代不同的特点选择合适的收集算法。
比方说新生代对象来的快,死的快,复制算法就很适合。而老年代中对象死的慢,也没有额外空间为其担保,那就使用标记-清楚或是标记-整理。
以上,我们从算法和逻辑上将 GC 的全部功能说了一通。也即是哪些内存需要回收,什么时候回收,如何回收。但是,我们并没有说某个具体的虚拟机的 GC 的实现。今天就先到这里,理解了原理,后面理解具体的实现会容易的多。