本文大纲:
一、概述
1.1 垃圾回收
Garbage Collection,GC。 在程序运行的过程中,对Java堆和方法区中的对象,进行动态的回收的动作。
1.2 垃圾回收为什么发生在堆和方法区中?
在前面的博客LCODER之JVM系列:运行时数据区中,我们可以得知,程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭。栈内存中的栈帧,是随着方法的进入和退出有条不紊的执行着出栈和入栈操作,每一个栈帧中分配多少内存,基本上在类结构确定下来时就已知的,这几个区域的内存分配和回收都具备了确定性,方法或线程结束时,内存自然也就跟着回收了。Java堆和方法区不同,我们只有在程序运行的过程中才会知道会创建哪些对象,这部分内存的分配和回收都是动态的。
二、如何判断对象是否还存活?
在垃圾回收器对堆进行回收前,首先要做的就是确定堆中的对象,哪些还活着,哪些已经不会再被使用。一般使用引用计数法和可达性算法来分析对象是否还存活。
2.1 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就+1,;当引用失效时,计数器值就-1,任何时刻,计数器为0的对象,就是不会再被使用的。 缺点:无法解决循环引用的问题。
2.2 可达性算法分析
通过一系列“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,即从GC Roots到这个对象不可达时,证明此对象是不可引用的。 在Java中,可作为GC Roots的对象包括以下几种:
- 虚拟机栈栈帧中的本地变量表中引用的对象
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中Native方法引用的对象
若对象在进行可达性分析后发现没有与 GC roots 相连接的引用链,那么他将会被第一次标记并进行一次筛选,筛选的条件是该对象是否有必要执行 finalize()方法,当对象没有重写finalize()方法或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没必要执行。 若该对象被判定为有必要执行 finalize方法,则这个对象会被放在一个 F-Queue 队列, finalize方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 F-queue中的对象进行第二次小规模的标记,若对象要在 finalize中成功拯救自己—次要重新与引用链上的任何一个对象建立关联即可(即可以重写finalize()方法来实现,比如可以将自己赋值给某个类变量或者对象的成员变量),那么在第二次标记时他们将会被移出“即将回收”集合。 任何一个对象的 finalize()方法都只会被系统调用一次。
2.3 引用的四种类型
强引用(Strong Reference)
指在程序代码中普遍存在的,类似于“Object o = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
软引用(Soft Reference)
用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
弱引用(Weak Reference)
弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发送之前。当垃圾回收器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
虚引用(Phantom Reference)
虚引用也称为幽灵引用或者幻影引用,一个对象是否有虚引用,完全不会对其生存时间产生影响,也无法通过虚引用来取得一个对象实例。虚引用的唯一作用,就是在对象回收时,收到一个系统通知。
2.4 方法区的回收
方法区中的垃圾回收,效率并不如Java堆。方法区在HotSpot虚拟机中又被称为永久代,这里存放的是一些静态的类和方法、常量、已经编译后的类。这些数据都是不太需要回收的,它们不会频繁的创建和回收。所以在这里进行垃圾回收,一般是回收废弃常量和无用的类。
回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,例如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用这个字面量,如果这个时候发生了内存回收,这个“abc”就会被系统移出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
回收无用的类 与废弃常量相比,无用的类的判断比较苛刻,类需要同时满足以下3个条件,才能算是“无用的类”:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有任何地方引用,无法在任何地方通过反射访问该类的方法
JVM可以对同时满足上述3个条件的无用类进行回收,也仅仅是"可以"而不是必然。在大量使用反射、动态代理等场景都需要JVM具备类卸载的功能来防止永久代的溢出。
三、垃圾回收算法
3.1 标记-清除算法 (Mark-Sweep)
最基础的算法:标记-清除算法,顾名思义,这个算法分为两个阶段:标记、清除。首先标记处所有需要回收的对象,在标记完成之后,统一回收所有被标记的对象。标记的过程是垃圾收集器从GC Roots开始遍历,标记所有被引用的对象,在对象的Header中记录为可达对象。清除的过程是垃圾收集器对堆内存从头到尾仅需线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
标记-清除算法的缺点: 1. 效率低。2. 标记清除之后会产生大量不连续的内存碎片。内存碎片太多,可能导致下次分配大对象时,无法找到连续内存,触发另一次GC动作,更有甚者可能会导致OutOfMemory。
3.2 复制算法(Copying)
复制算法:根据标记-清除算法演变而来,是将可用内存根据可用大小划分成大小相等的两块,每次只使用其中的一块。当这一块内存使用完了,就把其中还存活的对象复制到另一块内存中,再把这一块内存全部清空,这样可以避免产生不连续的内存碎片的问题。但这样做的缺点是,内存缩小到原来的一半,显然,很不划算。
3.3 标记-整理算法(Mark-Compact)
标记-整理算法:主要在老年代使用,标记过程与标记-清除算法一样。整理的过程是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4 分代收集算法(Generational Collection)
在Android虚拟机中,主要使用这种算法进行垃圾回收。在Android高级系统版本中,针对堆内存,有一个Generational Heap Memory的模型,如下图所示:
从图中可以看到,整个堆内存被分为三个区:年轻代(Young Generation)、老年代(Old Generation)、持久代(Permanent Generation)
3.4.1 年轻代(Young Generation)
年轻代分为三个区:Eden区、S0区、S1区,默认比例是8:1:1,S0区和S1区两者都属于Survivor区,实质上是一样的。
年轻代的对象,存活时间较短,因此基于Copying算法来回收。对于年轻代来说,对象就是在Eden区、S0区、S1区之间进行Copy。
回收过程:
- 绝大多数刚刚被创建的对象会存放在Eden。
- 当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC,在Eden区执行第一次Minor GC之后,存活的对象被移动到其中一个Survivor区(如S1,S1为From区域),Eden区会被清空。
- 再次Eden区执行GC后(Minor GC 是对整个新生代检查,不仅仅是Eden区),如果S1区的对象依旧还存活,那么这些存活的对象年龄增加。然后所有还存活的对象会被移动到另一个Survivor区(如S2,S2是下次Minor GC的From区域)中,伊甸园空间和S1会被清空。
- 执行一次Minor GC,对象的年纪+1,执行MaxTenuringThreshold(15)次,也就是对象年纪=15时,依然存活的对象,被移入老年代。
年轻代使用空闲指针的方式来控制GC的触发,指针保持最后一个分配对象在年轻代的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。 执行一次Minor GC,对象的年纪+1,执行15次,也就是对象年纪=15时,依然 并不是所有的新对象都会被放在Eden区,如果是需要大量连续空间的大对象,一出生就被分配在老年代。
3.4.2 老年代(Old Generation)
老年代只有一个区域,这个区域默认大小是年轻代的两倍。老年代存放的是上面年轻代复制过来的对象,一般来说,老年代中的对象生命周期都比较长,比较稳定。在这一区域发送的GC,被称为Full GC,采用的是标记-整理算法来进行垃圾回收。
3.4.3 持久代(Permanent Generation)
这个区域的回收策略,在上面2.4已经解释过了,这里不再赘述。
3.4.4 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。