本文主要学习自《深入理解Java虚拟机》,内容顺序大致遵循书本顺序,加上自身对于其中算法的理解学习。今天小泉主要跟大家一起学习一下垃圾回收的整个流程。 前言垃圾回收(Garbage Collection),是一种自动内存管理机制。
GC比Java诞生的更早,最早起源于Lisp语言,1960年MIT的大佬们就开始研究如何能够自动管理内存和垃圾回收技术了。经过这么多年的发展,JVM中的GC机制已经十分健壮与成熟了。 判定垃圾回收的对象回收垃圾之前,首先要找到需要被当作垃圾而回收的对象。 JVM分为五个区域——程序计数器、虚拟机栈、本地方法栈、堆、方法区。我们知道程序计数器与栈均是线程私有的,其生命周期取决于线程的生命周期。因此不需要考虑其中回收的问题。 而堆和方法区属于线程共享部分,是对象实例、变量存放的地方,是能够被动态分配的。因此根据前文定义以及此处的分析,我们可以知道,需要被当作垃圾被回收的是堆和方法区中无法再次被访问的内存区域。 堆的回收那么知道了垃圾回收的对象的定义,那么应当如何判断当前对象是否无法再次被访问即对象“死亡”呢? 引用计数算法(Reference Counting)引用计数算法相对来说比较简单,对象实例化后该实例对象引用计数初始值为1,后续根据该实例对象的引用与释放来进行增1、减1操作,当这一实例对象的计数器归零,则判定该对象“死亡”。 引用计数算法原来相对简单,判定效率相对来说也就高一些,许多地方都采用了引用计数算法,如微软的COM(Component Object Model)技术、Python语言、Object-C语言都采用了引用计数算法的内存管理方式。 当然引用计数算法也有着自己的弊端,比如对于对象之间的相互引用。举个书中给出的小栗子: public clss ReferenceCountingGC { public Object instance = null; private int size = 10;//无实际意义 public statice void testGC() {ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); //相互引用 objA.instance = objB; objB.instance = objA; objA = null; objB = null; } } 对象实例化计数器初始值为1,被引用 1,最后引用释放-1,最后两个对象的计数器值为1,对象未死亡,可在其他地方这两个对象再无使用,也就是该内存不能够被回收。 可达性分析算法(Reachability Analysis)可达性分析算法是目前C#和Java主用的判定算法。其算法流程如下:
从数据结构与算法的角度重构这一过程: 以实例对象为节点,以对象的引用关系为以边形成的无向图,从无向图中选取特定的节点作为特定节点,选出与这些特定节点没有任何关联(直接或者间接可以访问到)的节点。 其实就是图论中无向图的遍历,如果只是从算法角度去考虑,这里可以考虑采用构建邻接矩阵或者邻接链表并使用DFS或者BFS的方式去处理,这个会在以后数据结构与算法系列中推出。 如图,从GC Roots开始搜索到的节点均是存活对象,而未搜索到的节点则会被判定为“死亡”对象。 知道了算法流程,还需要知道一个前提,我们如何选取特定节点也就是根对象? 在Java中,书中给出几种GC Roots的固定来源:
除了固定GC Roots集合,还有一些“临时性”对象会被加入到该集合中,共同构成GC Roots集合。这一部分在以后再继续讲解吧。 那么是不是只要与GC Roots集合无关联即不可达对象就会被回收呢? 不可达就要被回收了吗?不可达还不意味着一定就会被回收。JVM在经过可达性分析算法判定某一对象与GC Roots无关后,会对这一对象做一个标记,后续会进行二次判定,而二次判定的内容是:是否有必要实行对象的finalize(),如果对象没有覆盖finalize()或者finalize()方法已经被虚拟机调用了,那么即可认定为“没有必要执行”。 如果二次判定为“有必要执行”,那么对象会被放置在 因此,不可达对象在此时还有着拯救自己的方法,那就是重写finalize()函数,但注意finalize()函数也只会进入一次,也就是不可达对象仅有一次拯救自己的办法。 方法区的回收对于方法区的回收,在前文介绍JVM内存区域已经提及到了。 针对回收的对象,方法区回收主要分两类,一类是常量池中的废弃常量,一类是不再使用的类型。 废弃常量废弃常量的判定和堆中对象的判定十分相近,不再赘述,可参考对回收中的引用计数。 不再使用的类型同样,针对“不再使用”,需要规定其范围。
达到上述三个要求的类型才能够允许被当垃圾被回收,然而这里需要注意的是,此处也只是“允许”而非必然。 垃圾收集分代收集理论在确定了垃圾回收的对象,那么还需要有着回收垃圾的管理者,也就是垃圾收集器,目前大多数垃圾收集器遵循着分代收集理论,该理论基于两个假说提出:
基于以上两个假说,我们的垃圾收集器会将Java堆划分为多个不同区域,同时将回收对象依据对象熬过垃圾收集的次数划分到各个区域,回收垃圾则在回收时会对某一个或者某几个区域进行回收。 虽然不同的垃圾收集器划分的区域不同,但区域大致会划分为新生代(Young Generaion)和老年代(Old Generation)两个区域。正是由于这样的划分也导致了一个问题——两个区域的对象有着引用关系(称之为跨代引用),这样就对某个区域的回收造成了影响(必须再去查询区域中的存活对象)。 为此引入了第三点假说:
有了第三点假说,我们就可以认为当发生新生代和老年代的跨代引用,老年代的难以消亡特点会使得引用的新生代对象得以存活并随着垃圾收集次数的增加而晋升到老年代。 同时第三点假说告诉我们跨代引用的部分会很少很少,因此我们只需要建立一种数据结构用来标记住老年代中进行了跨代引用的内存区域,将其加入到GC Roots中进行新生代存活对象的判定。 垃圾收集种类根据垃圾收集的区域不同,主要分为以下几种垃圾回收的方式:
垃圾收集器垃圾收集器这里先不做介绍,等深入学习后再跟大家分享。 垃圾收集算法根据判定回收垃圾对象的方式,垃圾回收的算法也分为两种,一种称为“引用计数式垃圾回收”(ReferenceCounting GC),另一种称为“追踪式垃圾回收”(Tracing GC)。由于JVM采用的是可达性分析算法进行“垃圾”的判定,因此一下主要介绍后一种收集算法。 标记-清除算法(Mark-Sweep)标记清除算法是垃圾收集算法的基础。它的思路相对来说是最简单的,按字面意思理解,先“标记”,再“清除”:
标记-清除的过程很简单,因此也有着许多缺点,主要可归为两点:
标记-复制算法(Mark-Copy)标记-复制算法是在标记-清除算法基础上进行了优化。 1969年Fenichel在标记-清除算法的基础上提出一种“半区复制”的垃圾收集算法,其算法流程(标记过程已经省略,标记-整理算法同样省略该过程)如下:
这样的标记-复制算法对于需要回收大量内存的情形十分友好,并且进行内存复制操作之后不需要考虑内存碎片化问题。
对于新生代的垃圾收集,这种方式倒是一种有效的思路,只不过比例不再需要是1:1,IBM针对分代收集理论的第一个假说,新生代中98%的对象熬不过第一轮收集。 Appel式回收将新生代分为三部分:80%的Eden空间、10%的Survivor(称为from)空间、10%的Survivor(称为to)空间。 可供分配使用的内存为80%的Eden空间和10%的名为from的Survivor空间,当可供分配的使用完之后,JVM将进行垃圾回收,其步骤如下:
如果存活对象超过这10%的Survivor空间又该怎么办呢?别急,还有老年代兜着底,拿出一部分用来放置多出的存活对象。 标记-整理算法(Mark-Compact)标记-复制算法对于新生代的垃圾回收相对来说十分有效,但对于老年代来说,这种算法不太适宜,因为老年代需要考虑极端情况即内存全被占用,因此1974年Edwaed LueDers提出了另外有针对性的算法即标记-整理算法。
与标记-清除算法相比,标记-整理算法其实只是多了一个移动可达对象的操作,这样做的好处是内存分配时不用考虑碎片问题,而这样做的坏处是内存回收会变得更加复杂。 总结学习完之后,我们知道,JVM中的垃圾回收主要针对的是Java堆,而本文也主要针对Java堆的垃圾收集进行分析:垃圾回收的对象判定(引用计数算法 && 可达性分析以及二次判定)、划分不同区域并针对不同区域提出的垃圾收集算法。这一过程还是比较复杂的,还是需要大家多多学习呀~ 针对垃圾收集算法,总结了如下一张小表格给大家,希望能够帮助大家快速了解下三种算法的优缺点。
垃圾回收的过程就是JVM回收内存的过程,这对于以后排查内存问题(如内存泄漏、内存溢出等等)有着重大的帮助,希望大家看完之后,能有所收获。 |
|