深入JVM之垃圾收集

前言:

    之前了解了Java虚拟机所管理的内存区域划分情况之后,接着,又对Java虚拟机中的对象进行了深入探秘,下面应该进入Java虚拟机的重点——垃圾收集器的深入之旅;在编程语言界,了解和学习过Java和C++的程序员都知道,Java是不需要手动为对象申请内存和释放内存编写代码的,这些工作由Java虚拟机代为执行,但凡事有利就有弊,伴随着Java虚拟机为Java程序员提供了内存管理上的便利,一旦垃圾收集成为系统达到高并发量的瓶颈时,那么Java程序员很难快速地解决此类问题,此时,对Java虚拟机的垃圾收集技术的深入理解就起到了至关重要的作用,深入理解垃圾收集技术可以帮助我们实施必要的监控和调节;

    不论使用什么垃圾收集技术来实现垃圾收集器,垃圾收集(GC)所需要解决的问题始终是如下的是三个问题:①哪些内存需要回收?②什么时候进行回收?③如何进行回收?

    这三个问题对应到具体的Java程序中,就是:①如何判定对象为垃圾对象?②什么时候进行垃圾对象的回收?③如何进行垃圾对象的回收?

    首先,我们需要确定的是垃圾收集器负责管理的内存区域是哪些;因为只有知道垃圾收集器的主要目标才能进行后续的垃圾回收步骤;我们知道,Java虚拟机管理的内存区域中,程序计数器、Java虚拟机栈和本地方法栈都是线程私有的,其中程序计数器占用内存较小,且一般不需要进行专门的垃圾回收,虚拟机栈和本地方法栈中的栈帧随着方法的执行开始以及结束而入栈和出栈,且栈帧的大小在类结构确定下来之后就是已知的;因此这两个区域的内存分配和回收具有确定性,所以不需要过多地考虑回收的问题,因为方法结束或者线程结束时,这几个区域的内存也就自然被回收了;而Java堆和方法区不一样,由于同一个类的多个实现类需要的内存可能不一样(运行时的多态性)以及一个方法中的多个分支需要的内存也可能不一样(根据程序运行的情况,可能会执行不同的分支),导致了只有在程序执行过程中,才能知道创建哪些对象,因此这部分的内存的分配和回收是具有动态性的,即在Java程序执行的过程中,可能是变化的,不固定的;所以,垃圾回收器的主要目标也在这两块内存区域;

如何判定垃圾对象?

    了解了垃圾收集器的管理区域,接下来,就需要开始解决垃圾收集过程中的三个问题;在进行垃圾回收之前,我们需要确定哪些对象是垃圾对象(不可能再被任何途径使用的对象),才能使垃圾回收器进行垃圾回收的工作(有的放矢);

    判断对象是否已死,一般有两种算法:引用计数法和可达性分析法;

        引用计数法:在一个对象被创建出来之后,在该对象中就会包含一个引用计数器,一旦有地方将该对象赋给reference类型的变量,该计数器中的值加一;相反,一旦该对象的某个引用失效,那么该计数器的值就会减一;一旦引用计数器的值为零,则该对象就是不可能再被使用的,即该对象也就可以被回收了;

        可达性分析法:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到所有的GC Roots都没有任何引用链相连时,该对象就是不可用的、已死的;在Java语言中,可以作为GC Roots的对象包括以下几种:
        a. 虚拟机栈(栈帧中的本地变量表)引用的对象;
        b. 方法区中类静态属性引用的对象;
        c. 方法区中常量引用的对象;
        d. 本地方法栈中JNI(即一般说的Native方法)引用的对象;
    两种判定算法的优缺点:
        引用计数法相对简单,但是不能解决对象之间的循环引用问题;故主流的商用程序语言都不采用引用计数法来判定垃圾对象;
        可达性分析可以解决对象之间的循环引用问题,主流的商用程序语言都使用可达性分析法进行垃圾对象的判定;

引申:

    无论是采用引用计数法还是可达性分析法,都离不开对象的引用;引用计数法根据对象的引用次数进行不可用对象的判定,可达性分析法根据对象的引用链是否可以达到GC Roots来判定对象是否可用;在JDK1.2之前,传统的引用定义为:如果一个reference类型的数据中存储的数值代表着一块内存的起始地址,就称这块内存代表着一个引用;传统的定义使得一个对象只有两种状态:被引用和没有被引用;但是,我们常常希望描述这样一类对象:当内存空间还足够时,则能保留在内存中,如果内存空间在垃圾收集之后还是非常紧张,那么就可以抛弃这一类对象;这种对象适用于很多系统的缓存这样的应用场景;
    基于这样的需求,在JDK1.2之后,Java对引用的概念进行了补充:将引用分为强引用、软引用、弱引用以及虚引用;引用的强度依次降低;

    强引用:程序代码中普遍出现的,类似于“Object obj = new Object();”这类的引用,只要强引用还存在,垃圾收集器将永远不会对其引用的对象进行回收;

    软引用:指还有用但并非必需的对象的引用;软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象放入垃圾回收范围进行第二次回收;JDK1.2后,提供了SoftReference类来实现软引用;

    弱引用:指非必需对象的引用;其强度弱于软引用;被弱引用关联的对象,在下一次垃圾收集器工作时,无论当前内存是否足够,该对象都会被回收;JDK1.2后,提供了WeakReference类来实现弱引用;

    虚引用:幽灵引用或幻影引用,是最弱的一种引用;一个对象是否有虚引用的存在,丝毫不会影响其生存时间,也无法通过虚引用来取得一个对象实例;为一个对象设置虚引用的唯一目的就是:能在这个对象被垃圾回收器回收时,收到一个系统通知;JDK1.2后,提供了PhantomReference类来实现虚引用;

注意

    即使通过可达性分析算法不可达的对象,也并非就是非死不可的对象;真正宣告一个对象死亡(垃圾收集器可以将其回收)至少要经历两次标记过程:第一次标记是通过可达性分析后发现没有与GC Roots相连接的引用链时,将该对象进行标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法;没有必要执行的情况有:对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过;除这两种情况以外的情况(即该对象覆盖了finalize()方法并且虚拟机还未曾调用过该方法)属于有必要执行finalize()方法;
    如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行该队列里的对象的finalize()方法;这里的执行并不意味着虚拟机会等待该方法执行结束,而仅仅是出发该方法,原因在于如果一个对象的finalize()方法执行缓慢或者陷入死循环,则有可能会导致F-Queue队列中的其他对象永久处于等待,甚至会导致整个内存回收系统的崩溃;
    值得注意的是,被标记过一次的对象可以有一次机会逃出被回收的命运:执行其finalize()方法(前提是该对象的finalize()方法被覆盖过且没有被虚拟机调用过);如果该对象的finalize()方法中将该对象自身赋给某个类变量或者对象的成员变量(将其自身与引用链上的任何一个对象关联起来即可);这样的话,当在F-Queue队列中的对象执行完其finalize()方法之后,GC会再次对这些对象进行标记处理,如果这次没有逃出标记为不可用对象的命运,那么该对象将必定成为被垃圾回收器回收的对象;
    还需要额外注意的是,每个对象最多只有一次机会逃出被垃圾收集器回收的命运;一旦该对象的finalize()方法被虚拟机调用过,那么,该对象将不可能有机会逃出被垃圾收集器回收的命运;
    虽然finalize()方法能够拯救对象,但是并不推荐这样做,原因在于该方法的运行代价太过昂贵,不确定性大,并且无法保证各个对象的调用顺序;

    Java堆的回收目标是那些经过两次标记的对象,而方法区的回收目标是废弃常量和无用的类;

    废弃常量的回收:当常量池中的一个字符串“abc”没有在任何地方被引用该字面量或没有任何String对象引用常量池中的“abc”,这时如果发生内存回收,并且有必要的情况下,常量“abc”将会被系统清理出常量池;

    无用的类的回收:一个类成为“无用的类”需要同时满足以下三个条件:①该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例;②加载该类的ClassLoader已经被回收;③该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;满足这三个条件的类可以被垃圾回收,但不代表一定会被回收,是否对无用的类进行回收是由虚拟机提供的-Xnoclassgc参数进行控制的;同时,还可以通过使用-verbose:class、-XX:+TraceClassLoading以及-XX:+TraceClassUnLoading查看类加载和卸载的信息;

    在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出;

如何回收垃圾对象?

    如何回收垃圾就是由虚拟机的垃圾收集器采用的垃圾收集算法所决定的,常见的垃圾收集算法有:标记-清除算法、复制算法、标记-整理算法以及分代收集算法;以下对这四种垃圾收集算法分别进行阐述:

    标记-清除算法:正如算法的名称一样,此算法分为标记和清除两个步骤:首先通过可达性分析法标记出所有需要回收的对象,然后,在标记完成后,对所有被标记的对象进行统一回收;标记-清除算法是最基础的收集算法,但是其存在着两个不足之处:①标记和清除的两个过程的效率都不高了;②容易产生内存碎片,因为标记-清除算法不带有整理内存的功能,空间碎片过多会导致后续需要为较大对象分配内存时,找不到足够的连续内存,从而导致不得不提前触发另一次垃圾收集动作;

    复制算法:为了解决效率的问题,复制算法应运而生,复制算法是将可用内存分为大小相等的两块,每次只是用其中的一块进行内存分配,当这一块的内存用完时,就将该内存中的存活着的对象复制到另一块内存上,然后把已使用的那块内存空间一次性全部清理;这样做的好处是:对整个半区进行回收,内存分配时不用考虑空间碎片等复杂情况,但是,这样做也会造成内存利用率只有50%;

    改进的复制算法:由于现在的虚拟机都采用这种收集算法来回收新生代,且新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例划分内存空间;可以将内存划分为一块较大的Eden空间和两块大小相同的较小的Survivor空间,每次只使用Eden空间和一个Survivor空间进行对象的内存分配,当这两块区域使用完后,将存活着的对象复制到剩下的那个Survivor空间上,最后清理掉使用过的Eden空间和Surivor空间;HotSpot虚拟机将Eden空间和Survivor空间的比例设置为8:1;这样内存的利用率就由原来的50%提高到了90%;需要注意的是,没有办法保证每次回收都只有占用内存不多于10%的对象存活,因此,当Survivor空间不够用时,将会依赖其他内存(老年代)进行分配担保(Handle Promotion);

    标记-整理算法:复制算法适用于对象存活率较低的内存区域,那么对于对象存活率较高的老年代,如果采用复制算法将会大大降低效率,因此,根据老年代的特点,标记整理算法就出现了,标记-整理算法在标记-清除算法的基础上增加了一个后续步骤:整理;

    分代收集算法:分代收集算法相对于前面的三种收集算法并没有什么新颖之处,只不过是根据对象存活周期的不同将内存划分为几块,一般把Java堆分为新生代和老年代;这样就可以根据不同区域对象的存活周期的不同,采用不同的收集算法;一般对于对象存活率低的新生代采用复制算法,对于对象存活率高的老年代采用标记-清除算法或者标记-整理算法;

何时进行垃圾回收?

    根据不同的垃圾收集器的实现,垃圾回收的时刻也不尽相同;

猜你喜欢

转载自blog.csdn.net/boker_han/article/details/79376993
今日推荐