Overview
- 垃圾收集考虑三件事:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
- 重点考虑Java堆中动态分配和回收的内存。
Is Object alive?
引用计数法
- 给对象添加一个引用计数器。
- 该方法实现简单,判定效率高。但是它很难解决对象之间相互循环引用的问题,因此几乎很少有JVM选用该方法。eg:
public class ReferenceCountingGC {
public Object instance = null;
// 占点内存,以便在GC日志中看清楚是否被回收过
private static final int _1MB = 1024 * 1024;
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
}
可达性分析算法
- 在主流商用程序语言(Java, C#, Lisp)的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。
- 该算法的基本思路是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索。搜索的路径称为引用链(Reference Chain)。
- 当一个对象到GC Roots没有任何引用链相连(即不可达)时,则该对象是不可用的。不可达的对象是可回收的。如下:
- 在Java中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI引用的对象。
再谈引用
- 上面两种方法都是通过“引用”来判定对象是否alive。
- 但是在JDK1.2之前,Java中的引用定义很传统:若reference类型的数据中存储的数值是另一块内存的起始地址,则称这块内存代表着一个引用。
- 但是考虑这样的场景:我们希望描述这样的一类对象,当内存空间足够时,则能保留在内存中,否则可抛弃。 <-- 很多系统的缓存功能都符合这样的应用场景。
- 于是在JDK1.2之后,Java扩充了引用的概念:将引用分为强引用(Strong Reference), 软引用(Soft Reference), 弱引用(Weak Reference), 虚引用(Phantom Reference) 4种,强度依次减弱。
- 强引用:最常见的,类似Object obj = new Object()
- 软引用:用以描述一些还有用但是非必须的对象。对于该类对象,系统在将要发生内存溢出异常之前,会回收这些对象。JDK1.2之后提供了SoftReference类。
- 弱引用:也是用以描述非必须对象,但强度更弱于软引用。被弱引用关联的对象只能生存到下一次GC之前,而无论当前内存是否足够。
- 虚引用:PhantomReference类。
生存还是死亡
- 即使是在可达性分析算法中不可达的对象,也并非“非死不可”的。
- 要真正宣告一个对象死亡,至少要经历两次标记过程:TBD...
回收方法区
- 一般来说,在方法区进行垃圾收集的"性价比"比较低:在堆中,尤其是新生代中,常规应用一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
- 永久代的垃圾收集主要回收两部分:废弃常量和无用的类。
- 回收废弃常量与回收Java堆中的对象非常相似。假设一个字符串"abc"已经进入常量池,但当前系统中没有任何一个String对象引用常量池中的"abc"常量,则"abc"常量就会被系统清理出常量池。
- 相比之下判定一个类是否是“无用的类”的条件则相对苛刻很多。需同时满足:
- 该类所有的实例已经被回收;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
标记-清除算法
- 算法分为“标记”和“清除”两个阶段。
- 该算法是后续收集算法的基础。
- 不足:
- 效率问题:标记和清除两个过程的效率都不高;
- 空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次GC。
复制算法
- 它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。
- 当使用的块用完时,就将还存活着的对象复制到另一块上面,然后再将已使用过的内存空间一次清理掉。
- 这样使得每次都是对整个半区进行内存回收,内存分配时也不用考虑内存碎片等情况,只用移动堆顶指针,顺序分配即可。
- 代价是内存缩小为原来的一半。
- 现在的商业JVM都采用这种收集算法。IBM的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例划分内存空间。而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。
标记-整理算法
- 复制收集算法在对象存活率较高时就需要较多的复制操作,效率将会变低。更关键的是,若不想浪费一般的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以老年代一般不能采用这种算法。
- 标记-整理算法:标记过程仍同于标记-清除算法。但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端外边的内存。
分代收集算法
- 当前商业JVM的GC都采用“分代收集”(Generational Collection)算法。
- 这种算法就是根据对象存货周期地不同将内存划分为几块。
- 一般是把Java堆分为新声代和老生代,从而根据各个年代的特点采用最适当的收集算法。
Summary
- 本篇主要覆盖了GC的两大问题:
- 哪些对象需要回收:最基础的是引用计数法,简单但无法解决循环引用问题。在此基础上,更常用的是可达性分析算法。
- 如何回收:最基础的是标记-清除法,即先标记出哪些对象需要回收,然后逐一清除,不足是会产生大量内存碎片,从而导致后续可能触发多次GC。此外更高效的是复制法,该方法确保了连续内存,但是将内存缩小至原内存一般,并且在对象存活率较高时会引入大量复制操作,效率降低。还有标记-整理算法。 另外,商业JVM都会采用分代收集算法,即将内存划分为几块,根据年代选取合适的收集算法。