乐趣区

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 对象

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

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),这四种引用强度依次逐渐减弱。

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

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

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

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

更多资料:深入探讨 java.lang.ref 包、慎用 java.lang.ref.SoftReference 实现缓存
2.1.4 finalize()
两次标记过程
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法。当对象没有覆盖 finalize 方法,或者 finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
如果这个对象有必要执行 finalize()方法,那么这个对象将会被放置在一个名为 F -Queue 的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的 Finalizer 线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象 finalize()方法中执行缓慢,或者发生死循环,将很可能会导致 F -Queue 队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

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 主要是回收两部分内容:废弃常量和无用的类。
废弃常量
判断常量是否废弃跟对象是一样。常量池中的其他类、接口、方法、字段的符号引用也是如此。
无用的类(必须同时满足以下三个条件)

该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
加载该类的 ClassLoader 已经被回收;
该类对应的 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 等待太久,与不能太频繁以致于增大系统负荷。具体的安全点有:

循环的末尾
方法返回前
调用方法的 call 之后
抛出异常的位置

GC 时让所有线程停下来

抢先式中断:不需要线程的执行代码主动配合,在 GC 时先把所有线程中断,然后如果有线程没有运行到安全点,则恢复线程让他们运行到安全点。几乎没有 JVM 采用这种方式。

主动式中断:当 GC 需要中断线程的时候,不直接对线程操作而是设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

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

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

3.4 垃圾收集器
这里讨论的收集器基于 JDK 7 Update14 的 HotSpot 虚拟机,这个版本中正式提供了商用的 G1 收集器。下图展示了 HotSpot 虚拟机的垃圾收集器,如果两个收集器存在连线,说明可以搭配使用。
3.4.1 Serial
简介:最基本、最悠久、单线程缺点:只会使用一条线程完成 GC 工作,而且在工作时必须暂停其他所有工作线程。优点:简单而高效(与其他收集器的单线程比),是 JVM 运行在 Client 模式下的默认新生代收集器。
使用方式
-XX:+UseSerialGC,设置之后默认使用 Serial(年轻代)+Serial Old(老年代) 组合进行 GC。
3.4.2 ParNew
简介:Serial 的多线程版本,其余行为包括 Serial 的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 完全一样,默认开启的收集线程数与 CPU 数量相同。优点:多线程收集、能与 CMS 配合工作(这也是它是许多 Server 模式下虚拟机中首选的原因)缺点:单线程效率不及 Serial。
使用方式

设置 -XX:+UseConcMarkSweepGC 的默认收集器
设置 -XX:+UseConcMarkSweepGC 强制指定
设置 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

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

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于 0 的毫秒数

-XX:GCTimeRatio:直接设置吞吐量大小,是一个大于 0 且小于 100 的整数,默认值是 99,就是允许最大 1% 即(1/(1+99))的垃圾收集时间。

-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 的组合。
3.4.6 CMS
简介:CMS(Concurrent Mark Sweep)以最短回收停顿时间为目标、适合 B / S 系统的服务端、基于标记清除算法优点:并发收集、低停顿
工作流程

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

缺点

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

3.4.7 G1 收集器
原理

堆内存划分:G1 收集器将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

收集策略:G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回价值最大的 Region(这也就是 Garbage-First 名称的来由),有计划地避免在整个 Java 堆中进行全区域的垃圾收集。

Region 不可能是孤立的:把 Java 堆分为多个 Region 后,垃圾收集是否就真的能以 Region 为单位进行了?仔细想想就很容易发现问题所在:Region 不可能是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其他对象引用,而是可以与整个 Java 堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个 Java 堆才能保障准确性?这个问题其实并非在 G1 中才有,只是在 G1 中更加突出了而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象也面临过相同的问题,如果回收新生代时也不得不同时扫描老年代的话,Minor GC 的效率可能下降不少。

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

优点

并行与并发:G1 能充分使用多 CPU、多核来缩短 Stop The World 的停顿,部分其他收集器需要停顿 Java 线程执行的 GC 动作,G1 仍然可以通过并发的方式让 Java 线程继续运行。
分代收集:保留了分代收集的概念,而且不需要其他收集器配合能独立管理整个堆。
空间整合:G1 从整体看来是基于“标记 - 整理”算法实现的,从局部(两个 Region 之间)是基于复制算法实现的,不会产生空间碎片。
可预测的停顿:G1 能让使用者明确制定在长度为 M 毫秒内,消耗在 GC 上的时间不得超过 N 毫秒,这几乎是实时 Java(RTJS)的垃圾收集器的特征了。

运作流程

如果不计算维护 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]

前面的数字(33.125、100.667):代表 GC 发生的时间,即从 JVM 启动以来经过的秒数

[GC 或[FullGC:代表这次 GC 的停顿类型,如果有“Full”说明这次 GC 是发生了 Stop-The-World 的。新生代也会出现“[Full GC”,这一般是因为出现了分配担保失败之类的问题,所以才导致 STW)。

[GC (System.gc())或 [Full GC (System.gc()):说明是调用 System.gc() 方法所触发的收集。

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

内部方括号中的 3324K->152K(11904K):GC 前该内存区域已使用容量 -> GC 后该内存区域已使用容量(该内存区域总容量)。

外部方括号中的 3324K->152K(11904K):表示 GC 前 Java 堆已使用容量 ->GC 后 Java 堆已使用容量(Java 堆总容量)。

0.0025925secs:该内存区域 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 时间是完全正常的。详细参见: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

-Xms20M、-Xmx20M、-Xmn10M、-XX:SurvivorRatio= 8 四个参数保证了整个 Java 堆大小为 20M,新生代 10M(eden space 8192K、from space 1024K、to space 1024K)、老年代 10M。
在给 allocation4 分配空间的时候会发生一次 Minor GC,这次 GC 发生的原因是给 allocation4 分配所需的 4MB 内存时,发现 Eden 区已经被占用了 6MB,剩余空间不足以分配 4MB,因此发生 Minor GC。

[GC (Allocation Failure):表示因为向 Eden 给新对象申请空间,但是 Eden 剩余的合适空间不够所需的大小导致的 Minor GC。
GC 期间虚拟机又发现已有的 3 个 2MB 对象无法全部放入 Survivor 空间(Survivor 只有 1MB),所以只好通过分配担保机制提前转移到老年代。
这次 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

我们可以看到 Eden 空间几乎没有被利用,而老年代 10MB 空间被使用 40%,也就是 4MB 的 allocation 对象被直接分配到老年代中,这是因为 PretenureSizeThreshold 被设置为 3MB,因此超过 3MB 的对象都会直接在老年代中进行分配。
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。

退出移动版