深入理解Java虚拟机读书笔记 - 垃圾收集算法
概述
垃圾收集即GC。经过半个多世纪的发展,目前内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那我们为什么还要去了解GC和内存分配呢?答案很简单:当需要排查各种内存溢出,内存泄漏问题时,当垃圾手机成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
GC瞄准谁
我们已知程序计数器,虚拟机栈以及本地方法栈都是随着线程而生,随着线程而亡。所以这部分跟随线程生死的运行时区域在线程结束时内存也自然的被回收了。这一部分的区域就不需要过多的考虑内存的回收的问题。而Java堆和方法区则不一样。一个接口中的多个实现类可能需要的内存相差很大,一个方法的多个分支需要的内存也可能不一样。我们只有在程序运行阶段才会知道会创建哪些对象,这部分的内存分配及回收都是动态的。垃圾收集器关注的也正是这部分内存。
哪些内存需要回收
引用计数算法
给对象添加一个引用计数器,当有一个地方应用它时计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象不可能再被使用。
优点:
实现简单,判定效率高。适用于大部分情况,但Java并没有选择其来管理内存。
缺点:很难解决对象间循环引用问题。
例子:
public class ReferenceCountingGC {
public Object instance = null;
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;
// 该程序是无意义的相互引用且不能再被访问
// 此处若发生GC,引用计数算法是无法通知GC收集器回收他们的
// 但在Java中会将他们回收,因为Java中并不是通过该算法来管理内存的
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
可达性分析算法
通过一系列的称为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链(Reference Chain),当一个对象到GC Root不可达时,则证明这个对象是不可用的。如下图,对象object5、 object6、 object7,虽然相互有关联但是它们到GC Root不可达,所以它们被判定为是可回收的对象。
在Java语言中,可作为GC Root对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中的引用对象。
- 方法区中的静态属性或常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
后来Java又对引用的概念进行了扩充。引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
- 强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了SoftReference类来实现软引用。
- 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2之后,提供了WeakReference类来实现弱引用。
- 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2之后,提供了PhantomReference类来实现虚引用。
非死不可吗?
要真正宣告一个对象的死亡至少要经历两次标记过程。如果对象在进行可达性分析后发现没有与GC Roots相连接的引用连,那他将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行了。
如果该对象被判定为有必要执行finalize()方法。那么这个对象将会被放置在F-Quere队列之中。 并会被虚拟机创建的,低优先级的Finalizer线程去执行该对象的finalize()方法。但并不会等待它结束,因为对象在finalize()方法执行中如果出现执行缓慢或者发生死循环。将会导致F-Queue队列中其他对象永久处于等待。甚至导致整个内存回收系统崩溃。之后GC将会对F-Queue之中的对象进行第二次标记。如果在第二次标记前这些对象在自己的finalize()方法中可以拯救自己(重新与引用链上的任何一个对象建立关联即可)也是可以成功存活下来并被移除“即将回收”的集合的。 如果此时还没有逃脱,那就真的要被回收了。
虽然其有点儿C/C++中析构函数的意思,但其其实并不是,相反却是Java程序员期初为了更好的被C/C++程序员所接受而做的一种妥协。
注意:finalize()方法的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。所以尽量使用try-finally来代替它。
几种垃圾收集算法
标记—清除算法
算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程就是使用可达性算法进行标记的。
不足:
- 效率问题,标记和清除两个过程的效率都不高
- 空间问题,标记清除之后会产生大量不连续的内存碎片,导致以后分配较大对象时内存不足以至于不得不提前触发另一次垃圾收集动作。
复制算法
复制算法:将可用内存按照容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。
内存分配时不用考虑内存碎片问题,只要一动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价是将内存缩小为原来的一半。
实际应用中将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
Hotspot虚拟机中默认的Eden和Survivor的大小比例是8:1。
缺点:
- 在对象存活率较高的情况下,效率变低
- 有额外空间分配担保负担,无法应对被使用的内存100%对象存活的极端情况,致使老年代不能使用。
标记-整理算法
标记整理算法(Mark-Compact),标记过程仍然和“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
分代收集算法
根据对象存活周期的不同将内存分为几块。一般把Java堆分为新生代和老年代,根据各个年代的特点采用最合适的收集算法。在新生代中,每次垃圾收集时有大批对象死去,只有少量存活,可以选用复制算法。而老年代对象存活率高,使用标记清理或者标记整理算法。
什么时候回收?
当这三个分代的堆空间比较紧张或者没有足够的空间来为新到的请求分配的时候,垃圾回收机制就会起作用。有两种类型的垃圾回收方式:次收集和全收集。当新生代堆空间满了的时候,会触发次收集将还存活的对象移到年老代堆空间。当年老代堆空间满了的时候,会触发一个覆盖全范围的对象堆的全收集。
次收集
- 当新生代堆空间紧张时会被触发
- 相对于全收集而言,收集间隔较短
全收集
- 当老年代或者持久代堆空间满了,会触发全收集操作
- 可以使用System.gc()方法来显式的启动全收集
- 全收集一般根据堆大小的不同,需要的时间不尽相同,但一般会比较长。不过,如果全收集时间超过3到5秒钟,那就太长了