JVM详解2.垃圾收集与内存分配


博客地址:https://spiderlucas.github.io
备用地址:http://spiderlucas.coding.me

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外的人想进来,墙里面的人却想出来。

2.1 对象是否需要回收

2.1.1 引用计数法算法

原理:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器减1,任何时刻计数器都为0的对象就是不可能再被使用的。
优点:实现原理简单,而且判定效率很高。
缺点:很难解决对象之间相互循环引用的问题。

2.1.2 可达性分析算法

原理:通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

Java中的GC Roots对象
  1. 虚拟机栈(栈桢中的本地变量表)中的引用的对象
  2. 本地方法栈中JNI(一般说的Native方法)的引用的对象
  3. 方法区中的类静态属性引用的对象
  4. 方法区中的常量引用的对象

2.1.3 什么是引用

无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。

JDK 1.2 之前

在JDK1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用
缺点:一个对象在这种定义下只有被引用或者没有被引用两种状态,我们希望能描述这样一类对象——当内存空间还足够时,则能保留在内存之中;如果内存在GC之后还是非常紧张,则可以抛弃这些对象(如缓存)。

JDK 1.2 之后

在 JDK 1.2 之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次逐渐减弱。

  1. 强引用:就是指在程序代码之中普遍存在,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用:用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后提供了SoftReference类来实现软引用。
  3. 弱引用:也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的的对象。在JDK1.2之后提供了WeakReference类来实现弱引用。
  4. 虚引用(幽灵引用、幻影引用):是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

更多资料:深入探讨 java.lang.ref 包慎用java.lang.ref.SoftReference实现缓存

2.1.4 finalize()

两次标记过程

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

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

    public void isAlive() {
        System.out.println("yes, I am still alive");
    }

    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        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 QAQ!");
        }
 
        // 以上代码与上面的完全相同,但这次自救却失败了!!!
        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 QAQ!");
        }
    }
}
总结
  • System.gc()底层调用的是Runtime.getRuntime().gc();,该方法的Java doc里边写的是调用此方法suggestsJVM进行GC,即无法保证对垃圾收集器的调用。
  • finalize()方法至多由GC执行一次,用户当然可以手动调用对象的finalize方法,但并不影响GC对finalize()的行为。
  • 虽然可以在finalize()方法完成很多操作如关闭外部资源,但更好的方式应该是try-finally
  • finalize()运行代价高昂,不确定大,无法保证各个对象的调用顺序。
  • 最好的方法就是忘掉有这个方法!

2.1.5 回收方法区

Java虚拟机规范不要求虚拟机在方法区实现垃圾收集;方法区的GC性价比一般比较低。
方法区的GC主要是回收两部分内容:废弃常量和无用的类。

废弃常量

判断常量是否废弃跟对象是一样。常量池中的其他类、接口、方法、字段的符号引用也是如此。

无用的类(必须同时满足以下三个条件)
  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
  2. 加载该类的ClassLoader已经被回收;
  3. 该类对应的Java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
类是否回收
  • 满足上述3个条件的类只是被判定为可以被虚拟机回收,而不是和对象一样,不使用了基于就必然会回收。是否对类进行回收,还需要对虚拟机进行相应的参数设置。
  • 在HotSpot中,虚拟机提供-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。
  • 在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

2.2 垃圾收集算法

2.2.1 标记-清除算法

定义:标记-清除(Mark-Sweep)算法分为标记和清除两个阶段,首先标记出需要回收的对象,标记完成之后统一清除对象。
缺点:效率问题,标记和清除过程效率不高;标记清除之后会产生大量不连续的内存碎片。

2.2.2 复制算法

定义:复制(Copying)算法它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次理掉。
优点:这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可,实现简单,运行高效。
缺点:内存缩小为原来的一半。
使用情况:现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机:默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。

2.2.3 标记-整理算法

定义:标记-整理算法的标记过程与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是对所有存活的对象都向一端移动,然后清理掉边界以外的内存。
优点:解决了复制算法在对象存活率较高情况下需要大量复制导致的效率问题,而且不会缩小内存。

2.2.4 分代收集算法

定义:根据对象存活周期的不同将内存分为几块,一般是把Java堆分为新生代和老年代,根据各个年代的特点采用最适用的算法。
新生代:每次收集都会有大批对象死去,只有少量存活,采用复制算法。
老年代:对象存活率较高、没有额外空间对它进行分配担保,采用标记-清除或标记-整理算法。

2.3 HotSpot算法实现

2.3.1 枚举根节点

可达性分析的效率问题:可作为GC Roots的节点主要在全局性的引用(常量或类的静态属性)与执行上下文(如栈帧的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果逐个检查引用必然会消耗很多时间。
GC停顿:可达性分析在分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,这就是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情成为“Stop The World”)的一个重要原因,即使在号称(几乎)不会发生停顿的CMS收集器中,枚举跟结点也是必须要暂停的。
准确是GC:主流JVM都使用的是准确式GC,即JVM知道内存中某位置的数据类型什么,所以当执行系统停下来的时候,不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机可以有办法知道哪些地方存放着对象的引用。
HotSpot的OOPMap:在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来;在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样GC在扫描的时候就可以直接获得这些信息。

2.3.2 安全点

为什么需要安全点:有了OOPMap,HotSpot可以快而准的完成GC Roots的查找,但如果为每一行代码的指令都生成OOPMap,这样将占用大量的空间。所以HotSpot并没有这么做!
安全点:HotSpot只在特定的位置记录了OOPMap,这些位置称为安全点(Safe Point),即程序不能在任意地方都可以停下来进行GC,只有到达安全点时才能暂停进行GC。

安全点的选择

安全点的选定基本上是以“是否具有让程序长时间执行的特征”进行选定的,既不能选择太少以致于让GC等待太久,与不能太频繁以致于增大系统负荷。具体的安全点有

  1. 循环的末尾
  2. 方法返回前
  3. 调用方法的call之后
  4. 抛出异常的位置
GC时让所有线程停下来
  • 抢先式中断:不需要线程的执行代码主动配合,在GC时先把所有线程中断,然后如果有线程没有运行到安全点,则恢复线程让他们运行到安全点。几乎没有JVM采用这种方式
  • 主动式中断:当GC需要中断线程的时候,不直接对线程操作而是设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

2.3.3 安全区域

安全点的不足:安全点机制保证了程序执行时,在较短的时间就会遇到可以进入GC的安全点,但如果程序处于不执行状态(如Sleep状态或者Blocked状态),这时候线程无法相应JVM的中断请求,无法运行到安全点去中断挂起,JVM也不会等待线程重新被分配CPU时间。
安全区域:安全区域(Safe Region)是指在一段代码片段之中,引用关系不会发生变化,这个区域的任何地方GC都是安全的。可以把安全区域看成是扩展了的安全点。

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

3.4 垃圾收集器

这里讨论的收集器基于JDK 7 Update14的HotSpot虚拟机,这个版本中正式提供了商用的G1收集器。下图展示了HotSpot虚拟机的垃圾收集器,如果两个收集器存在连线,说明可以搭配使用。
HotSpot虚拟机的垃圾收集器

3.4.1 Serial

简介:最基本、最悠久、单线程
缺点:只会使用一条线程完成GC工作,而且在工作时必须暂停其他所有工作线程。
优点:简单而高效(与其他收集器的单线程比),是JVM运行在Client模式下的默认新生代收集器。
Serial/Serial Old收集器运行示意图

使用方式

-XX:+UseSerialGC,设置之后默认使用Serial(年轻代)+Serial Old(老年代) 组合进行GC。

3.4.2 ParNew

简介:Serial的多线程版本,其余行为包括Serial的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial完全一样,默认开启的收集线程数与CPU数量相同。
优点:多线程收集、能与CMS配合工作(这也是它是许多Server模式下虚拟机中首选的原因)
缺点:单线程效率不及Serial。
ParNew/Serial Old收集器运行示意图

使用方式
  1. 设置-XX:+UseConcMarkSweepGC的默认收集器
  2. 设置-XX:+UseConcMarkSweepGC强制指定
  3. 设置-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

3.4.3 Parallel Scavenge

简介:新生代收集器、采用复制算法、并行多线程收集器、关注的目标是达到一个可控制的吞吐量而非尽可能的缩短GC时用户线程的停顿时间。
吞吐量:CPU用于运行用户代码的时间和CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。停顿时间越短适合与用户交互的程序,良好的相应速度能提升用户体验;而高吞吐量可以高效利用CPU时间,适合后台运算。

使用方式
  1. -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数
  2. -XX:GCTimeRatio:直接设置吞吐量大小,是一个大于0且小于100的整数,默认值是99,就是允许最大1%即(1/(1+99))的垃圾收集时间。
  3. -XX:+UseAdaptiveSizePolicy:如果设置此参数,就不需要手工设定新生代的大小、Eden于Survivor区的比例、晋升老年代对象年龄等细节参数来,虚拟机会动态调整。

3.4.4 Serial Old收集器

简介:Serial的老年代版本、单线程、使用标记整理算法
用途:主要是为Client模式下的虚拟机使用;在Server模式下有两大用途,一是在JDK 5及之前版本中配合Parallel Scavenge收集器一起使用,而是作为CMS的后备预案,在并发收集发生Concurrent Mode Failure时使用。

3.4.5 Parallel Old收集器

简介:Parallel Scavenge的老年代版本、多线程、标记整理算法、JDK 6中才出现
用途:直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,可以使用Parallel Scavenge和Parallel Old的组合。
Parallel Scavenge和Parallel Old的组合

3.4.6 CMS

简介:CMS(Concurrent Mark Sweep)以最短回收停顿时间为目标、适合B/S系统的服务端、基于标记清除算法
优点:并发收集、低停顿

工作流程
  1. 初始标记——需要Stop The World,仅仅标记一下GC Roots能直接关联对象,速度很快
  2. 并发标记——进行GC Roots Tracing
  3. 重新标记——需要Stop The World,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,速度很快
  4. 并发清除

CMS收集器

缺点
  1. 对CPU资源非常敏感,在并发阶段它虽然不会导致用户线程停顿,但是会因为占用一部分线程(CPU资源)导致程序变慢
  2. CMS无法处理“浮动垃圾”——浮动垃圾是在并发清理阶段用户线程产生的新的垃圾,所以可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
  3. 由于CMS在垃圾收集阶段用户线程还需要执行,所以不能像其他收集器那样等老年代几乎填满了再进行收集,所以需要预留一部分空间给用户线程。CMS运行期间如果预留的内存无法满足程序需要,就会出现“Concurrent Mode Failure”失败,此时虚拟机将会临时启用Serial Old收集器来进行老年代的垃圾收集,导致长时间停顿。
  4. 由于CMS基于标记清除算法,所以会导致内存碎片。

3.4.7 G1收集器

原理
  1. 堆内存划分:G1收集器将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
  2. 收集策略:G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回价值最大的Region(这也就是Garbage-First名称的来由),有计划地避免在整个Java堆中进行全区域的垃圾收集。
  3. Region不可能是孤立的:把Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?仔细想想就很容易发现问题所在:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保障准确性?这个问题其实并非在G1中才有,只是在G1中更加突出了而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象也面临过相同的问题,如果回收新生代时也不得不同时扫描老年代的话,Minor GC的效率可能下降不少。
  4. 使用Remembered Set来避免全堆扫描:在G1收集器中Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查引是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
优点
  1. 并行与并发:G1能充分使用多CPU、多核来缩短Stop The World的停顿,部分其他收集器需要停顿Java线程执行的GC动作,G1仍然可以通过并发的方式让Java线程继续运行。
  2. 分代收集:保留了分代收集的概念,而且不需要其他收集器配合能独立管理整个堆。
  3. 空间整合:G1从整体看来是基于“标记-整理”算法实现的,从局部(两个Region之间)是基于复制算法实现的,不会产生空间碎片。
  4. 可预测的停顿:G1能让使用者明确制定在长度为M毫秒内,消耗在GC上的时间不得超过N毫秒,这几乎是实时Java(RTJS)的垃圾收集器的特征了。
运作流程

G1运行流程

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)——标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking)——从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking)——为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation)——首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

3.4.8 GC参数总结

参数 描述
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 区域与 Survivor 区域的容量比值,默认为8,代表 Eden : Survivor = 8 : 1
PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
MaxTenuringThreshold 晋升到老年代的对象年龄,每个对象在坚持过一次 Minor GC 之后,年龄就增加1,当超过这个参数值时就进入老年代
UseAdaptiveSizePolicy 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况
ParallelGCThreads 设置并行GC时进行内存回收的线程数
GCTimeRatio GC 时间占总时间的比率,默认值为99,即允许 1% 的GC时间,仅在使用 Parallel Scavenge 收集器生效
MaxGCPauseMillis 设置 GC 的最大停顿时间,仅在使用 Parallel Scavenge 收集器时生效
CMSInitiatingOccupancyFraction 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认值为 68%,仅在使用 CMS 收集器时生效
UseCMSCompactAtFullCollection 设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用 CMS 收集器时生效
CMSFullGCsBeforeCompaction 设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用 CMS 收集器时生效

3.5 理解GC日志

每一种收集器的日志形式都是由它们自身的实现所决定的,换言之每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的GC日志:

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925secs]3324K->152K(11904K),0.0031680 secs]

100.667:[FullGC[Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
  1. 前面的数字(33.125、100.667):代表GC发生的时间,即从JVM启动以来经过的秒数
  2. [GC或[FullGC:代表这次GC的停顿类型,如果有“Full”说明这次GC是发生了Stop-The-World的。新生代也会出现“[Full GC”,这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。
  3. [GC (System.gc())或[Full GC (System.gc()):说明是调用System.gc()方法所触发的收集。
  4. [DefNew、[Tenured、[Perm等:表示GC发生的区域,这里显示的区域名称与使用的GC收集是密切相关的——上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”;如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”;如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”;老年代和永久代同理,名称也是由收集器决定的。
  5. 内部方括号中的3324K->152K(11904K):GC前该内存区域已使用容量 -> GC后该内存区域已使用容量(该内存区域总容量)。
  6. 外部方括号中的3324K->152K(11904K):表示GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)。
  7. 0.0025925secs:该内存区域GC所占用的时间,单位是秒。
  8. [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时间是完全正常的。详细参见:Linux用户态程序计时方式详解

3.6 内存分配与回收策略

对象的内存分配总的来说,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配);对象主要分配在新生代的Eden区上;如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配;少数情况下也可能会直接分配在老年代中。分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

3.6.1 Minor和Full GC

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现Major GC,经常会伴随至少一次的Minor GC(但并非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

3.6.2 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8
 */
public class Allocation {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        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
    }
}
[GC (Allocation Failure) [DefNew: 7482K->380K(9216K), 0.0061982 secs] 7482K->6524K(19456K), 0.0062260 secs] [Times: user=0.00 sys=0.01, real=0.01 secs]
Heap
  def new generation   total 9216K, used 4641K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
   eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf0290f0, 0x00000007bf400000)
   from space 1024K,  37% used [0x00000007bf500000, 0x00000007bf55f318, 0x00000007bf600000)
   to   space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  tenured generation   total 10240K, used 6144K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  60% used [0x00000007bf600000, 0x00000007bfc00030, 0x00000007bfc00200, 0x00000007c0000000)
  Metaspace       used 2968K, capacity 4496K, committed 4864K, reserved 1056768K
   class space    used 327K, capacity 388K, committed 512K, reserved 1048576K
  1. -Xms20M、-Xmx20M、-Xmn10M、-XX:SurvivorRatio=8四个参数保证了整个Java堆大小为20M,新生代10M(eden space 8192K、from space 1024K、to space 1024K)、老年代10M。
  2. 在给allocation4分配空间的时候会发生一次Minor GC,这次GC发生的原因是给allocation4分配所需的4MB内存时,发现Eden区已经被占用了6MB,剩余空间不足以分配 4MB,因此发生Minor GC。
  3. [GC (Allocation Failure) :表示因为向Eden给新对象申请空间,但是Eden剩余的合适空间不够所需的大小导致的Minor GC。
  4. GC期间虚拟机又发现已有的3个2MB对象无法全部放入Survivor空间(Survivor只有1MB),所以只好通过分配担保机制提前转移到老年代。
  5. 这次GC结束后,4MB的allocation4对象被顺利分配到Eden中。因此程序执行完的结果是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(allocation1,2,3占用)。

3.6.3 大对象直接进入老年代

什么是大对象:大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组(byte[]数组就是典型的大对象)。
大对象的影响:大对象对虚拟机的内存分配来说就是一个坏消息(更加坏的情况就是遇到一群朝生夕死的短命 对象,写程序时应该避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置大对象。
设置大对象的参数:可以通过-XX:PretenureSizeThreshold参数设置使得大于这个设置值的对象直接在老年代分配,避免在Eden区及两个Survivor区之间发生大量的内存拷贝。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728(3M)
 */
public class PretenureSizeThreshold {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation = new byte[4 * _1MB];
    }
}
Heap
 def new generation   total 9216K, used 1502K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  18% used [0x00000007bec00000, 0x00000007bed778d8, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4096K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  40% used [0x00000007bf600000, 0x00000007bfa00010, 0x00000007bfa00200, 0x00000007c0000000)
 Metaspace       used 2931K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 321K, capacity 388K, committed 512K, reserved 1048576K
  1. 我们可以看到Eden空间几乎没有被利用,而老年代10MB空间被使用40%,也就是4MB的allocation对象被直接分配到老年代中,这是因为PretenureSizeThreshold被设置为3MB,因此超过3MB的对象都会直接在老年代中进行分配。
  2. PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。

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

对象年龄:虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。
设置对象晋升年龄:通过参数-XX:MaxTenuringThreshold来设置。

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 */
public class MaxTenuringThreshold {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB]; // Eden空间不足GC,allocation1进入Survivor
        allocation3 = null;
        allocation3 = new byte[4 * _1MB]; // Eden空间不足第二次GC
    }
}
[GC (Allocation Failure) [DefNew: 5690K->624K(9216K), 0.0052742 secs] 5690K->4720K(19456K), 0.0053049 secs] [Times: user=0.00 sys=0.01, real=0.01 secs] 
[GC (Allocation Failure) [DefNew: 4720K->0K(9216K), 0.0009947 secs] 8816K->4709K(19456K), 0.0010106 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4260K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf0290f0, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf400000, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4709K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  45% used [0x00000007bf600000, 0x00000007bfa99570, 0x00000007bfa99600, 0x00000007c0000000)
 Metaspace       used 2953K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 327K, capacity 388K, committed 512K, reserved 1048576K

此方法中allocation1对象需要256KB的内存空间,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后会非常干净地变成0KB。而 MaxTenuringThreshold=15时,第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时候新生代仍然有410KB的空间被占用。

3.6.5 动态对象年龄判定

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

/**
 * -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 */
public class Main {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB]; // 第一次GC
        allocation4 = null;
        allocation4 = new byte[4 * _1MB]; // 第二次GC
    }
}
[GC (Allocation Failure) [DefNew: 5946K->880K(9216K), 0.0045988 secs] 5946K->4976K(19456K), 0.0046307 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [DefNew: 5058K->0K(9216K), 0.0012867 secs] 9154K->4965K(19456K), 0.0013125 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4315K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  eden space 8192K,  52% used [0x00000007bec00000, 0x00000007bf036ce8, 0x00000007bf400000)
  from space 1024K,   0% used [0x00000007bf400000, 0x00000007bf4000e0, 0x00000007bf500000)
  to   space 1024K,   0% used [0x00000007bf500000, 0x00000007bf500000, 0x00000007bf600000)
 tenured generation   total 10240K, used 4965K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
   the space 10240K,  48% used [0x00000007bf600000, 0x00000007bfad9500, 0x00000007bfad9600, 0x00000007c0000000)
 Metaspace       used 2957K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 327K, capacity 388K, committed 512K, reserved 1048576K

发现运行结果中Survivor占用仍然为0%,而老年代比预期增加了,也就是说allocation1,allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来达到了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。 我们只要注释一个对象的new操作,就会发现另外一个不会晋升到老年代了。

3.6.5 空间分配担保

  • Minor GC流程:在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小:如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时将进行一次Full GC。
  • 空间分配担保:出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间。一共有多少对象会活下去,在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验,与老年代的剩余空间进行对比,决定是否进行Full GC来让老年代腾出更多空间。
  • 担保失败的解决办法:取平均值进行比较其实仍然是一种动态概率的手段,如果某次Minor GC存活后的对象突增以致于远远高于平均值时,依然会导致担保失败(Handle Promotion Failure)。如果出现HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
JDK 6 Update 24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章