深入理解JVM之垃圾收集器与内存分配策略

来源:互联网 发布:淘宝挖掘冷门暴利行业 编辑:程序博客网 时间:2024/05/16 09:34

      读完JVM的第三章最大的收获是知道了垃圾收集的算法,了解了JDK1.7中提供的垃圾收集器的特点以及运作原理,通过代码实例验证了Java虚拟机中自动分配内存及回收的主要规则。了解了内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及提供大量的调节参数,是因为只有根据实际应用需求,实现方式选择最优的收集方式才能获取最高的性能。没有固定收集器、参数组合,也没有最优的调优方法,虚拟机也就没有什么必然的内存回收行为。在学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数。

一:概述

      1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。在Lisp还在胚胎时期,人们就在思考GC需要完成的3件事:哪些内存需要回收;什么时候回收;如何回收。目前内存的动态分配与内存回收技术已经相当成熟。我们了解GC和内存分配是为了:当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

       我们知道Java内存运行时的区域各个部分中,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不絮地执行者出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。但是Java堆和方法区就不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。(第三章讨论的“内存”分配与回收也仅指这一部分内存)

二:对象已死吗?

      在堆里面存放着Java世界中介乎所有的对象实例,垃圾收集器在堆进行回收前,第一件事是要确定这些对象中哪些还“活着”,哪些已经“死去”(即不可能再被任何途径使用的对象)

引用计数算法

       判断对象是都存活很多人的第一想法就是引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失败时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。客观的说,引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,例如微软公司的COM(Component Object Model)技术、使用ActionScript3的FlashPlayer、Python语言和游戏脚本领域被广泛应用Squirrel中都使用引用计数算法进行内存管理。但是主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因就是引用计数算法很难解决对象之间相互循环引用的问题。

/** * testGC()方法执行后,objA和objB会不会被GC呢? * @author Peter * */public class ReferenceCountingGC {public Object instance = null;private static final int _1MB = 1024 * 1024;/** * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过 */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,objA和objB是否能被回收?System.gc();}}
       运行结果:[Full GC (system) [Tenured: 0k -> 210k(10240k), 0.0149142 secs]   4603k->210k(19456k), [Perm:2999k->2999k(21248k)],0.0150007 secs][times:user=0.01  sys=0.00,real = 0.02 secs]

       从结果中可以看出,GC日志中包含“4603k->210k”,意味着虚拟机并没有因为这两个对象相互引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。

可达性分析算法

       在大多数主流的商用程序语言(Java、c#)实现中都是用可达性分析来判定对象是否存活的。算法思路为:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连接时,则证明此对象是不可用的。如下图所示:


       上图所示:对象object5、object6、object7虽然相互有关联,但是它们到GC Roots是不可达的,所以它们将判定为可回收对象。而object4、object3、object2、object1它们都可以达到GC Roots。故可将它们判定为不可回收对象。

        在Java语言中,可作为GC Roots的对象包括下面几种:

        1:虚拟机栈(栈帧中的本地变量表)中引用的对象;

        2:方法区中类静态属性引用的对象;

        3:方法区中常量引用的对象;

        4:本地方法栈中JNI(即一般说的Native方法)引用的对象。

再谈引用

     无论是通过引用计数算法还是可达性分析算法来判断对象是否存活,其中都离不开“引用”这个词语。在JDK1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。但是这还不是很完美,我们希望能有这样的一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。(很对系统的缓存功能都符合这样的应用场景)

      在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种。

      强引用:就是指在程序代码中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

      软引用:用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。

      弱引用:用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论当内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。

      虚引用:又称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时受到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

生存还是死亡

      即使在对象可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”状态,要真正宣告一个对象的死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是有没有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢或者发生了死循环,将会很可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个变量或者对象的成员变量,那在第二次标记时它将被移除“即将回收”的集合;如果对象这个时候还没有逃脱,那基本上它就真的被回收了。

       下面是一个Demo:

/** * 此代码演示了两点: * 1:对象可以在被GC时自我救赎 * 2:这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次 * @author Peter * */public class FinalizeEscapeGC {public static FinalizeEscapeGC SAVE_HOOK = null;public void isAlive(){System.out.println("yes, i am still alive:)");}@Overrideprotected void finalize()throws Throwable{super.finalize();System.out.println("finalize method executed!");FinalizeEscapeGC.SAVE_HOOK = this;}public static void main(String[] args)throws Exception{SAVE_HOOK = new FinalizeEscapeGC();//对象第一次成功拯救自己SAVE_HOOK = null;System.gc();//因为finalize方法优先级很低,所以暂停0.5秒以等待它Thread.sleep(500);if(SAVE_HOOK!=null){SAVE_HOOK.isAlive();}else{System.out.println("no, i am dead:(");}//下面这段代码与上面的完全相同,但是这次自救却失败了SAVE_HOOK = null;System.gc();//因为finalize方法优先级很低,所以暂停0.5秒以等待它Thread.sleep(500);if(SAVE_HOOK!=null){SAVE_HOOK.isAlive();}else{System.out.println("no, i am dead:(");}}}
         运行结果:

finalize method executed!yes, i am still alive:)no, i am dead:(
        从上面代码可以看到:SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。可以看出两段完全一样的代码片段,执行结果一次成功逃脱了,一次失败了,是因为任何一个对象finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。因此第二段代码的自救失败了。

回收方法区

      很多人认为方法区(或者HotSpot虚拟机中的永久代) 是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。

       永久代垃圾收集主要分为两部分:废弃常量和无用的类。回收废弃常量和回收Java Heap中的对象非常类似。判断废弃常量:比如回收常量池中字面量,假设一个字符串“abc”已经进入常量池,但是当前系统没有任何一个String对象是叫做“abc”的,即没有任何String对象引用常量池中的“abc”,也没有其他方法引用了这个字面常量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口),方法,字段的符号引用也与此类似。

        无用类有3个条件:1,该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;2,加载该类的ClassLoader已经被回收;3,该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。虚拟机可以对满足上述3个条件的无用类可以进行回收,这里是“可以”,而并不是和对象一样,不使用了必须回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:TraceClassLoading可以在Product版的虚拟机中使用,-XX:TraceClassLoading参数需要FastDebug版的虚拟机支持。

三:垃圾收集算法

        这里介绍垃圾收集算法主要分为4种:标记——清除算法、复制算法、标记——整理算法、分代收集算法。

标记——清除算法(效率问题、空间问题)

        标记——清除算法是最基础的算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有标记的对象(即对象的标记判定)。它主要有两个不足:一个是效率问题(标记和清除两个过程的效率都不太高);另一个是空间问题(标记清除之后会产生大量的不连续的内存碎片,空间碎片太多可能会导致以后垃程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作)。


        上面的示意图很好的展示了“标记——清除”算法的过程:其中绿色背景的是存活的对象,黑色的代表可以回收的对象,空白代表未使用的对象;其中很好的展示了清除完毕后会产生大量的不连续的内存碎片。

复制算法(内存缩小一半,存活率高的对象不足)

        复制算法是为了解决“标记——清除算法”的效率问题,它将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半。


        上面的示意图很好的展示了复制算法的示意图,其中深黄色背景的为保留区域,绿色背景的为存活对象,黑色背景的为可回收对象,空白为未使用内存。它很好的展示了内存只只用了一半。

        现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两个较小的Survivor空间,每次使用Eden和其中的一个Survivor空间,回收时将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉刚才使用的Eden和Survivor空间。HotSpot虚拟机默认的Eden和Survivor的大小比例为8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法每次保证回收都只有不多于10%的对象存活,当survivor空间不够用时,需要依赖其它内存(这里指老年代)进行分配担保。

标记——整理算法

       复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

       根据老年代的特点,有人提出了另外一种“标记——整理”算法,标记过程仍然与“标记——清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。


        

       上面的示意图很好的展示了“标记——整理”算法示意图,其中绿色背景的代表存活对象,黑色背景的代表可回收对象,空白表示未使用的内存,很好的展示了所有对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法(把内存分为不同的代,每个不同代用不同算法即对前面算法的整合,让其发挥各自优势)

       当前商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象已死,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记——清理”或者“标记——整理”算法来进行回收。

四:HotSpot的算法实现

枚举根节点

        从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很对应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

        另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指整个分析期间整个执行系统看起来就像冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话操作结果的准确性就无法得到保证。这点是导致GC运行时必须停顿Java执行线程的其中一个重要原因(即:Stop The World)。即使在号称不会停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

        目前主流的JVM使用的都是准确时GC,所以当系统停顿后,并不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象的引用。在HotSpot实现中,是使用一组称为OopMap的数据结构来达成这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC扫描时就可以直接得知这些信息了。

安全点

      在OopMap的帮助下,HotSpot可以快速且准确地定位完成GC Roots枚举,但是一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。但是,在实际中,HotSpot也确实没有为每条指令都生成OopMap,前面已提到,只是在“特定的位置”记录了这些信息,这些点成为安全点(Safepoint)。即程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。Safepoint的选定即不能太少(让GC等待时间太长)也不能太多(过分增大运行时负荷)。故Safepoint的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都很短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”最明显的特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所有具有这些功能的指令才会产生Safepoint。

       对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括JNI调用的线程)都“跑”到最近的安全点上再停顿下来。两种方案:抢先式中断和主动式中断,抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方在不安全点上,就恢复线程,让它“跑”到安全点上。(现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而相应GC事件)。主动式中断:当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。(轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方)。

安全区域

      Safepoint几乎完美的解决了如何进入GC的问题,但实际情况却不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是程序“不执行”的时候就没法解决(程序不执行是指没有分配CPU时间,例如:线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新分配CPU时间,这时候就需要安全区域来解决问题)。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的(我们可以把Safepoint看做是被扩展了的Safepoint)。

      当线程执行到Safepoint中的代码时,首先标识自己已经进入了Safepoint,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

五:垃圾收集器

      垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。JVM规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商。不同版本的虚拟机所提供的垃圾收集器可能会差别很大。(这里说的是JDK1.7 Update14之后的HotSpot虚拟机)


       上面示意图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。(在这张图中我们要明确一个观点:虽然我们是在对各个收集器进行比较,但是并非是为了挑选出一个最好的收集器。因为直到现在为止,还没有一个最好的万能的收集器,所以我们要具体应用选用最合适的收集器)

Serial收集器:单线程收集器

        Serial收集器是一个单线程收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。就像Stop The World一样,但是这项工作实际上是由虚拟机在后台自动发起和自动完成的,用户在不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是难以接受的。


         上面很好的展示了“Serial/Serial Old”收集器运行的示意图,虽然Serial收集器被描述成为“老而无用,食之无味弃之可惜”的鸡肋了,但实际上到目前为止,它依然是虚拟机运行在client模式下的默认新生代收集器。相对于其他收集器而言:它简单而高效,对于限定在单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew收集器

       ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:ServivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。


        上面很好的展示了ParNew收集器运行时的示意图,ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多的创新之处,但它却是许多运行在Server模式下的虚拟机中首先的新生代收集器,其中有一个与性能无关但很重要的原因是,出Serial收集器外,目前只有ParNew收集器能与CMS收集器配合工作。ParNew收集器是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:UseParNewGC选项来强制指定它。ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百得保证可以超越Serial收集器。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多(比如32个,现在CPU动辄就4核加超线程,服务器超过32个逻辑CPU的情况越来越多了)的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

Parallel Scavenge收集器:“吞吐量优先”收集器

       Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器.....;Parallel Scavenge收集器的特点是它的关注点与其他收集器

不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。(吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%)。


       停顿时间越短就越适合需要与用户交互的程序,良好的相应速度能提升用户体验,而高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

       Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

       MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。此参数不能故意调小来试图保证系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快,但是这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。

      GCTimeRatio参数的值应当是一个大于0且小于100的整数,即垃圾收集时间占总时间的比率,相当于吞吐量的倒数,如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1/(1+19)),默认值为99,就是允许最大1%(即1/(1+99))的垃圾收集时间。

      由于与吞吐量有关故也成“吞吐量优先”收集器。除了上述两个参数外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy。这是一个开关参数,当这个参数打开,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大吞吐量,这种调节方式称为GC自适应的调节策略。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

Serial Old收集器

        Serial Old是Serial收集器的一个老年代版本,它同样是一个单线程收集器,使用“标记——整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用;另一个用途是作为CMS收集器的后备预案,在并发收集发生Concurrent Model Failure时使用。


        上面示意图很好的展示了Serial Old收集器运行时的示意图,展现了它是一个串行GC。

Parallel Old收集器

        Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记——整理”算法。这个收集器在JDK1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬状态。原因是:如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”。直到Parallel Old收集器的出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。示意图如下:


CMS收集器(并发标记——清除收集器)

        CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

         从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记——清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

         初始标记(CMS initial mark):需要Stop The World,它仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;

         并发标记(CMS concurrent mark):进行GC Roots Tracing的过程;

         重新标记(CMS remark):需要Stop The World,它是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

         并发清除(CMS concurrent sweep)

         由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。所以,从总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。下面的示意图会很清楚的看到CMS收集器的运作步骤中并发和需要停顿的时间。


       CMS收集器是一款优秀的收集器,主要优点为:并发收集、低停顿,Sun公司一些官方文档也称为并发低停顿收集器(Concurrent Low Pause Collector)。但CMS还远远达不到完美的程度,主要有3个缺点:

       1:CMS收集器对CPU资源非常敏感。其实面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用变慢,总吞吐量降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是让CPU不足4个时(比如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出了一般的运算能力去执行线程收集器,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。为了解决这一情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让GC线程、用户线程交替运行,尽量减少GC线程独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,也就是速度下降的没有那么明显。实践证明,增量式CMS收集器效果很一般,在目前版本中,i-CMS已经被声明为“deprecated”,即不在提倡用户使用

       2:CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。

       3:CMS是一款基于“标记——清除”算法实现的收集器,由于算法固有的缺点,故收集结束后会有大量的空间碎片产生。

G1(Garbage-First)收集器

      G1收集器是当今收集器技术发展的最前沿成果之一,G1是一款面向服务端应用的垃圾收集器,HotSpot开发者团队赋予它的使命是未来可以替换掉JDK1.5中发布的GMS收集器。与其它收集器相比,G1收集器具备如下特点:

       1:并行与并发:G1能充分利用多CPU、多核环境的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿时间,部分其它收集器原本需要停顿Java线程执行GC的动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

       2:分代收集:与其它收集器一样,分代概念仍然在G1中依然得以保留。虽然G1收集器不需要其它收集器的配合就能够独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集结果。

       3:空间整合:与CMS的“标记——整理”不同,G1从整体来看是基于“标记——整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提起触发下一次GC。

       4:可预测的停顿:这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征。

       在G1之前的其他收集器其进行收集的范围是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大区别,它将Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的融合。

        G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间的大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

        G1收集器把内存化整为零的思路理解起来似乎很容易,但其中的实现细节远远没有想象中那么简单。比如:把Java堆分为多个Region后,垃圾收集器是否就能真的以Region为单位进行垃圾收集了呢?听起来顺理成章,但仔细想来就会发现其中的问题:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其它对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定对象确定对象是否存活的时候,需要扫描整个Java堆才能确保准确性。这个问题并非在G1中才有,只是在G1中这个问题更加突出。在以前的分代收集中,新生代规模一般都比老年代小许多,新生代的收集也比老年代要频繁,那回收新生代对象时也会面临这个问题,如果回收新生代时不得不同时扫描老年代的话,那么Minor GC的效率可能下降不少。

        在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的虚引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都会有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Writer Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入了Remembered Set即可保证不对全堆扫描也不会有遗漏。

       如果不维护Remembered Set的操作,G1收集器的运作大致可划分为一下几个步骤(和CMS收集器前几个步骤很相似):

       初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next to at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短;

       并发标记(Concurrent Marking):从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行;

       最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set种,这阶段需要停顿线程,但是可并行执行;

       筛选回收(Live Data Counting and Evacuation):首先对各个Region的可回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,从Sun公司透露的信息来看,这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。如下图所示:


理解GC日志

       

       上图为GC日志示意图,最前面的数字“33.125:”和“100.667”:代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数;

        GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集停顿的时间类型,而不是用来区分新生代GC还是老年代GC。如果有“Full”说明了这次GC是发生了Stop-The-World的,例如新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败了的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC(System)”。


        接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代的名字会变为“[ParNew”,意为:“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代名字为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定。

        后面方括号内部的“3324K->152K(3712K)”,含义是“GC前该内存区域已使容量—>GC后该内存区域已使用容量(该内存区域总量)”。而在方括号之外的“3324k->152K(11904k)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

        再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待县城阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

垃圾收集器参数总结

参数

描述

UseSerialGC

虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收

UseParNewGC

打开此开关后,使用ParNew+Serial Old的收集器组合进行内存回收

UseConcMarkSweepGC

打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用

UseParallelGC

虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge+Serial Old(PS MarkSweep)的收集器组合进行内存回收

UseParallelOldGC

打开此开关后,使用Parallel Scavenge+Parallel Old的收集器组合进行内存回收

SurvivorRatio

新生代中Eden区域与Surivor区域的容量比值,默认为8,代表Eden:Surivor=8:1

PretenureSizeThreshold

直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配

MaxTenuringThreshold

晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值时就进入老年代

UseAdaptiveSizePolicy

动态调整Java堆中各个区域的大小以及进入老年代的年龄

HandlePromotionFailure

是否允许分配担保失败,即老年代的剩余空间不足以应对新生代的整个Eden和Survivor区的所有对象都存活的极端情况

ParallelGCThreads

设置并行GC时进行内存回收的线程数

GCTimeRatio

GC时间占总时间的比率,默认值为99,即允许1%d的GC时间。仅在使用Parallel Scavenge收集器时生效

MaxGCPauseMillis

设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效

CMSInitiaingOccupancyFraction

设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效

UseCMSCompactAtFullCollection

设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS收集器时生效

CMSFullGCsBeforeCompaction

设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS收集器时生效

六:内存分配与回收策略

        Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。对象的内存分配往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还是虚拟机中与内存相关的参数的设置。

对象优先在Eden分配

       大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。 虚拟机提供了-XX:+PrintGCDetails这个收集器参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。

/** *  * @author Peter * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * */public class TestAllocation {private static final int _1MB = 1024*1024;public static void testAllocation(){byte[] allocation1, allocation2, allocation3, allocation4;allocation1 = new byte[2*_1MB];allocation2 = new byte[2*_1MB];allocation3 = new byte[2*_1MB];allocation4 = new byte[4*_1MB];//出现一次Minor GC}}

        在运行时通过-Xms20M -Xmx20M  -Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给了新生代,剩下的10MB分配给了老年代; -XX:+PrintGCDetails ;-XX:SurvivorRatio=8决定了新生代Eden区与一个Survivor区的空间比例是8:1。

        从上面的输出结果也可以清晰看到“eden space 8192k、from space 1024k、to space 1024k”的信息,新生代总可用空间为9216k(Eden区+1个Survivor区的总量);执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次GC的结果是新生代6651K变为148K,而内存总量则几乎没有减少(因为allocation1、allocation2、allocation3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB),所以只好通过分配担保机制提前转移到老年代去。

      这次GC完毕,4MB的allocation4对象顺利分配在Eden中,因此程序执行完的结果是Eden占用4MB(被allocation4占用),survivor空闲,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。通过GC日志可以证实这一点。

大对象直接进入老年代

        大对象:需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(最大的坏消息就是遇到一群“朝生夕灭”的“短命大对象”),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

/** * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:PretenureSizeThreshold=3145728 * @author Peter * */public class TestPretenureSizeThreshold {private static final int _1MB = 1024 * 1024;public static void testPretenureSizeThreshold(){byte[] allocation;allocation = new byte[4*_1MB];//直接分配在老年代中}}

       从运行结果来看,Eden空间几乎没有被使用,而老年代的10M空间使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数一样直接写3MB),因此超过3MB的对象都会直接在老年代进行分配。

长期存活的对象将进入老年代

        既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1,。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
       
/** * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 * -XX:MaxTenuringThreshold = 1 * -XX:+PrintTenuringDistribution * @author Peter * */public class TestTenuingThreshold {private static final int _1MB = 1024 * 1024;@SuppressWarnings("unused")public static void testTenuingThreshold(){byte[] allocation1, allocation2, allocation3;allocation1 = new byte[_1MB / 4];//什么时候进入老年代取决于XX:MaxTenuringThreshold设置allocation2 = new byte[4 * _1MB];allocation3 = new byte[4 * _1MB];allocation3 = null;allocation3 = new byte[4 * _1MB];}}
         当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后非常干净地变为0KB。而MaxTenuringThreshold=15时,第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时新生代仍然有404KB被占用。
        MaxTenuringThreshold=1时:
        MaxTenuringThreshold=15时:


动态对象年龄判定

        为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

/** * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 * -XX:+PrintTenuringDistribution * @author Peter * */public class TestTenuringThreshold2 {private static final int _1MB = 1024 * 1024;@SuppressWarnings("unused")public static void testTenuringThreshold2(){byte[] allocation1, allocation2, allocation3, allocation4;allocation1 = new byte[_1MB / 4];//allocation1 + allocation2大于survivor空间一半allocation2 = new byte[_1MB / 4];allocation3 = new byte[_1MB * 4];allocation4 = new byte[_1MB * 4];allocation4 = null;allocation4 = new byte[4 * _1MB];}}
        执行coding,并设置-XX:MaxTenuringThreshold=15,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了6%,也就是说,allocation1、allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是童年的,满足同年对象达到了Survivor空间中的一半规则。我们注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。

空间分配担保

       在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

        上面的冒险是指:新生代使用复制收集算法,但为了内存利用率,只使用了其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况为内存回收后新生代中所有对象都存活),就需要老年代进行担保,把Survivor无法容纳的对象直接进入老年代。与生活中贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会存活下来再实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。取平均值进行比较其实仍然是一种动态概率的手段,仍有可能会导致担保失败。如果出现HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

/** * VM参数:-Xms20M -Xmx20M -Xmx10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure * @author Peter * */public class TestHandlePromotion {private static final int _1MB = 1024 * 1024;@SuppressWarnings("unused")public static void testHandlePromotion(){byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;allocation1 = new byte[2 * _1MB];allocation2 = new byte[2 * _1MB];allocation3 = new byte[2 * _1MB];allocation1 = null;allocation4 = new byte[2 * _1MB];allocation5 = new byte[2 * _1MB];allocation6 = new byte[2 * _1MB];allocation4 = null;allocation5 = null;allocation6 = null;allocation7 = new byte[2 * _1MB];}}

       以HandlePromotionFailure=false参数来运行的结果:

        以HandlePromotionFailure=true参数来运行的结果


         在JDK6 Update 24之后,这个测试结果会有差异,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update24之后的规则变化为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。


七:注释

1:前面的垃圾收集会涉及到并发编程中概念会涉及并发并行

      并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;

      并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

2:Minor GC和Full GC

      新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

      老年代GC(Full GC/Major GC):指发生在老年代GC,出现Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Minor GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

阅读全文
0 0
原创粉丝点击