开篇
我们知道JVM的垃圾回收机制实际上是对JVM内存的操作,回收的目的是为了避免内存溢出和内存泄漏的问题。而JVM内存由方法区、堆、虚拟机栈、本地方法栈以及程序计数器5块区域组成,虚拟机栈、本地方法栈、程序计数器是随着Java线程建立而建立,当Java 线程完成之后这三个部分的内存就会被释放掉。
而方法区和堆属于共有线程,是随着JVM启动而建立的,而且这两个区域与另外三个区域也有所不同,一个接口中有多少个实现类(方法区)以及每次程序运行需要创建多少对象(堆)是动态的,也就是说在程序运行时才能知道。
为了让这部分动态的内存分配能够进行合理的回收,就需要垃圾回收算法和垃圾回收器来帮忙了。下面让我们进入今天的主题。
如何判断对象“存活”?
JVM 垃圾回收机制是对堆中没有使用的对象进行回收,那么判断对象是否“存活”就至关重要。在判断对象是否“存活”的方法中,我们会介绍引用计数算法和可达性分析法。
引用计数算法
Java 堆中针对每个对象都设置一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾回收。当一个对象被垃圾回收时,它引用的任何对象计数减1。
这种方法的优点很明显,引用计数回收器执行简单,判定效率高,对程序不被长时间打断的实时环境比较有利。不过缺点也很明显,对于对象循环引用的场景难以判断,同时引用计数器增加了程序执行的开销。Java语言并没有选择这种算法进行垃圾回收。
可达性分析法
可达性分析法也叫根搜索算法,通过称为 GC Roots 的对象作为起点,从上往下进行搜索。搜索所走过的路径称为引用链 (Reference Chain), 当发现某个对象与 GC Roots之间没有任何引用链相连时, 即认为该对象不可达,该对象也就成了垃圾回收的目标。
如图1 所示,从GC Roots 开始没有引用链和Obejct5、Object6 和Object7 相连,因此这三个对象对于GC Roots 而言就是不可达的,会被垃圾回收,即便他们互相都有引用。
图1 可达性分析法
在Java中,可作为GC Roots的对象包括如下四种:
· 虚拟机栈(栈帧中的本地变量表)中引用的对象
· 本地方法栈 中 JNI (Native方法)引用的变量
· 方法区 中类静态属性引用的变量
· 方法区 中常量引用的变量
前面谈到的可达实际上是在判断对象是否被引用,如果没有被引用,垃圾回收器会将其进行回收。不过我们希望存在这样一些对象,当内存空间足够的情况下尽量将其保留在内存中,当内存不够的情况下,再回收这些对象。下面看看如何对如下对象进行处理:
· 强引用(Strong Reference):例如,Object obj = new Object()这类引用,只要强引用存在,垃圾回收器永远不会回收掉被引用的对象。
· 软引用(Soft Reference):在系统将要出现内存溢出之前,会将软引用对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
· 弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾回收发生之前,无论当前内存是否足够,用软引用相关联的对象都会被回收掉。
· 虚引用(Phantom Reference):虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收的时候收到一个系统通知。
垃圾回收算法
上面讲解了如何发现“存活”对象,JVM中会使用可达性分析法,说白了就是看GC Roots在引用链上是否有对应的对象被引用到了。接下来就在这个背景下看看有哪些垃圾回收的算法,这里我们列举出常见的几种:
标记清除算法
该算法分为标记和清除两个阶段,首先通过可达性分析法找到要回收的对象,也就是没有被引用的对象,对其进行标记,然后再对该对象进行清除也就是回收了。
如图2 所示,该算法会对内存空间进行扫描,发现GC Roots 对Object1 和Object2 进行引用,但是对Object2 没有引用。首先标记Object2 没有被引用。
图2
如图3 所示,算法再次对内存进行扫描,清除Object2 对象占用的空间,将其设置为空闲空间。
图3 标记清除算法
该算法的优点就是简单粗暴,没有引用的对象会被清除掉,但是缺点是效率问题。标记和清除操作会扫描整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)才能完成清理工作。同时清理过程容易产生内存碎片,这些空闲的空间无法容纳大对象,如果此时有一个比较大的对象进入内存,由于该内存中没有连续的容纳大对象的空间,就会提前触发垃圾回收。
复制算法
为了解决标记清除法带来的问题,复制算法将内存划分为大小相等的两块,每次使用其中的一块,当这块的内存使用完毕以后,再将对象复制到另外一块上面,然后对已经使用过的内存空间进行清理。这样每次对内存的一半区域进行回收,不用考虑内存碎片的问题。
如图4 所示,上面的区域是垃圾回收之前的内存空间,我们用黑色的虚线将内存分为两个部分。左边的部分是正在使用的空间,右边是预留空间。左边区域中红色的部分是不可回收的内存,也就是说这里面有被GC Roots 引用的对象,另外灰色的部分是可回收的区域,也就是没有被GC Roots 引用的对象,白色区域是未分配的。
如果通过复制算法进行垃圾回收,顺着绿色的箭头向下,在回收后的内存区域可以看到,将左侧红色的内存对象移动到了右侧预留的区域,并且按照顺序排放。然后对左侧运行的内存区域进行清理,成为预留区域等待第二次垃圾回收的执行。
图4 复制算法
复制算法的优点是简单高效,不会出现内存碎片。缺点也明显,内存利用率低,只有一半的内存被利用。特别是存活对象较多时效率明显降低,因为需要移动每个不可回收数据的内存实际位置。
标记整理算法
该算法和标记清除算法相似,但是后续步骤并不是直接对可回收对象进行清理,而是让所有存活对象都移动到内存的前端,然后再清除掉其他可回收的对象所占用的内存空间。
如图5 所示,回收前的内存中红色为不可回收的内存空间,灰色是可回收空间,白色是未分配空间。执行标记整理算法的垃圾回收之后,将不可回收的内存空间整理到内存的前端,同时清除掉可回收的内存空间,此时不可回收空间之后存放的都是白色的未分配空间,供由新对象存放。
图5 标记整理算法
标记整理算法优点是解决了标记清理算法存在的内存碎片问题。缺点也是非常明显,需要进行局部对象移动,一定程度上降低了效率。
分代收集算法
分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块,然后定义回收规则。如图6所示,从左到右分别是年轻代(Young Generation)、老年代(Old Generation) 和 永久代(Permanent Generation),另外年轻代又分为了Eden Space(伊甸空间) 、Survivor Space(幸存者空间)。分代收集的算法在当前商业虚拟机算法中被广泛采用。
图6 分代收集法
上面对分代收集法做了字面的解释,现将该算法的执行过程描述如下:
1)新产生的对象优先分配在Eden区(除非配置了-XX:PretenureSizeThreshold,大于该值的对象会直接进入老年代)。有这样一种情况,当对象刚刚在新生代创建就被回收了,对象从这个区域消失的过程我们称之为 minor GC。
2)当Eden区满了或放不下了,这时候其中存活的对象会复制到from区。如果此时存活下来的对象在from 区都放不下,就会放到老年代,之后Eden 区的内存会全部回收掉。
3)之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,这时候将会把Eden区和from区存活下来的对象复制到to区,此时如果存活下来的对象to区也放不下,会将其移动到年老代,同时会回收掉Eden区和from区的内存。
4)如果按照如上操作将对象在几个区域中移动,会出现对象被多次复制的情况,对象被复制一次,对象的年龄就会+1。默认情况下,当对象被复制了15次(通过:-XX:MaxTenuringThreshold来配置),该对象就会进入老年代了。
5)当老年代满了的情况下,就会发生一次Full GC。
备注:Minor GC指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。Full GC指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Full GC,Full GC的速度一般会比Minor GC慢10倍以上。
垃圾回收器
如果垃圾回收算法是内存回收的方法论的话,那么垃圾回收器就是内存回收的具体实现了。下面会针对JDK1.7 Update 14 之后的HotSpot虚拟机给大家做介绍。
如图7所示,这里将内存分为新生代和老年代,将7种不同垃圾回收器分布于其间,垃圾回收器之间存在连线,说明它们可以搭配使用。
虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。Hotspot实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。
图7垃圾回收器的分类