GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

时间:2022-05-15 17:20:26

目录:

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

GC之二--GC日志分析(jdk1.8)整理中

GC之三--GC 触发Full GC执行的情况及应对策略

gc之四--Minor GC、Major GC和Full GC之间的区别

GC之六--SystemGC完全解读

一、概述

垃圾收集 Garbage Collection 通常被称为“GC”,它诞生于1960年 MIT 的 Lisp 语言,经过半个多世纪,目前已经十分成熟了。

jvm 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收主要集中于 java 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的.

1.1、执行回收时机

Java的垃圾回收机制是Java虚拟机提供的能力,用于在空闲时间以不定时的方式动态回收无任何引用的对象占据的内存空间。

需要注意的是:垃圾回收回收的是无任何引用的对象占据的内存空间而不是对象本身,很多人回答的含义是回收对象,实际上这是不正确的。

System.gc();
Runtime.getRuntime().gc() ;

上面的方法调用时用于显式通知JVM可以进行一次垃圾回收,但真正垃圾回收机制具体在什么时间点开始发生动作这同样是不可预料的,这和抢占式的线程在发生作用时的原理一样。

程序员只能通过上面的方法建议JVM回收垃圾,但是JVM是否回收,同样是不可预料的。

二、对象存活判断

判断对象是否存活一般有两种方式:

引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。

根搜索算法/可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。在Java语言中,GC Roots包括:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性实体引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI引用的对象

示例1:根据gc结果判断JVM采用何种方式来判断对象存活

package com.jvm.study.part3;

import java.util.concurrent.TimeUnit;

/**
* @VM args:-verbose:gc -XX:+PrintGCDetails
* @author 01107252
*
*/
public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; 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;
System.out.println("1");
System.gc();
System.out.println("2");
objA = null;
objB = null;
System.gc();
System.out.println("3");
}
public static void main(String[] args) throws InterruptedException {
//TimeUnit.SECONDS.sleep(30);
testGC();
System.out.println("4"); } }

结果:

[GC (System.gc()) [PSYoungGen: 6000K->4696K(36864K)] 6000K->4704K(121856K), 0.0027438 secs] [Times: user=0.05 sys=0.02, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 4696K->0K(36864K)] [ParOldGen: 8K->4609K(84992K)] 4704K->4609K(121856K), [Metaspace: 2535K->2535K(1056768K)], 0.0075679 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[GC (System.gc()) [PSYoungGen: 634K->64K(36864K)] 5244K->4673K(121856K), 0.0003509 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (System.gc()) [PSYoungGen: 64K->0K(36864K)] [ParOldGen: 4609K->513K(84992K)] 4673K->513K(121856K), [Metaspace: 2535K->2535K(1056768K)], 0.0063831 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
4
Heap
PSYoungGen total 36864K, used 1270K [0x00000000d7000000, 0x00000000d9900000, 0x0000000100000000)
eden space 31744K, 4% used [0x00000000d7000000,0x00000000d713d890,0x00000000d8f00000)
from space 5120K, 0% used [0x00000000d9400000,0x00000000d9400000,0x00000000d9900000)
to space 5120K, 0% used [0x00000000d8f00000,0x00000000d8f00000,0x00000000d9400000)
ParOldGen total 84992K, used 513K [0x0000000085000000, 0x000000008a300000, 0x00000000d7000000)
object space 84992K, 0% used [0x0000000085000000,0x0000000085080450,0x000000008a300000)
Metaspace used 2541K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 272K, capacity 386K, committed 512K, reserved 1048576K

结果分析:示例中objA和objB存在相互引用,若是采用“引用计数”的方式,理论上不能被回收。但结果可见是被释放了。侧面应正了JVM采用的是非“引用计数”的方式。

示例2:可达性分析

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

三、再谈引用

  无论是通过引用计数算法判断对象的引用数量,还是通过根搜索算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK 1.2之前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味,弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

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

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

  参加《对象的强、软、弱和虚引用

三、生存还是死亡?

  在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

  如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己—只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那它就真的离死不远了。从代码清单3-2中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。

代码清单3-2 一次对象自我拯救的演示

/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize
()方法最多只会被系统自动调用一次
* @author zzm
*/
public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;

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

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

public static void main(String[] args) throws Throwable {
 SAVE_HOOK = new FinalizeEscapeGC();

 //对象第一次成功拯救自己
 SAVE_HOOK = null;
 System.gc();
 // 因为Finalizer方法优先级很低,暂停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();
 // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
 Thread.sleep(500);
 if (SAVE_HOOK != null) {
 SAVE_HOOK.isAlive();
 } else {
 System.out.println("no, i am dead :(");
 }
}
}
运行结果:

finalize mehtod executed!
yes, i am still alive :)
no, i am dead :(
从代码清单3-2的运行结果可以看到,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。

另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

需要特别说明的是,上面关于对象死亡时finalize()方法的描述可能带有悲情的艺术色彩,笔者并不鼓励大家使用这种方法来拯救对象。相反,笔者建议大家尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。有些教材中提到它适合做“关闭外部资源”之类的工作,这完全是对这种方法的用途的一种自我安慰。finalize()能做的所有工作,使用try-finally或其他方式都可以做得更好、更及时,大家完全可以忘掉Java语言中还有这个方法的存在。

四、回收

4.1、回收方法区

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

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

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是“无用的类”:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数(-Xnoclassgc 表示不对方法区进行垃圾回收。请谨慎使用)进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看类的加载和卸载信息。

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

4.2、栈内存的释放

  当在一段代码块定义一个变量时,Java在栈中为这个变量分配内存空间,当该变量退出其作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

4.3、堆(Heap)内存回收--本文关注的重点

  GC为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题。在Java语言中,判断一个内存空间是否符合垃圾收集标准有两个:一个是给对象赋予了空值null,以下再没有调用过,另一个是给对象赋予了新值,这样重新分配了内存空间。

五、垃圾收集算法(内存回收的方法论)

5.1、标记-清除算法

  “标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

5.2、复制算法

  “复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

5.3、标记-压缩算法(标记-整理)

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

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

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

5.4、分代收集算法

GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。

“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

六、垃圾收集器(内存回收的具体实现)

垃圾收集器功能总览

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

上图说明:

新生代:Serial收集器、ParNew收集器、Parallel Scavenge收集器、G1收集器

老年代:CMS(Concurrent Mark Sweep)收集器、Serial Old(MSC)收集器、Parallel Old收集器、G1收集器

收集器之间的连接线的意思是:垃圾收集器之间可以配合工作

6.1、Serial收集器(新生代的垃圾收集器)

  串行收集器是一个新生代收集器,是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收。

收集器配合关系:可以与CMS收集器、Serial Old收集器配合工作。

采用算法:复制算法

参数控制:-XX:+UseSerialGC 串行收集器

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

6.2、ParNew收集器(新生代的垃圾收集器)

ParNew收集器是一个新生代收集器,其实就是Serial收集器的多线程版本。

收集器配合关系:可以与CMS收集器、Serial Old收集器配合工作。

采用算法:复制算法

参数控制:-XX:+UseParNewGC ParNew收集器

       -XX:ParallelGCThreads 限制线程数量

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

6.3、Parallel Scavenge(清除)收集器(新生代的垃圾收集器)

  Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器……看上去和ParNew都一样,那它有什么特别之处呢?

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

  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收集器也经常称为“吞吐量优先”收集器。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作原来不太了解,手工优化存在困难的时候,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

参数控制:-XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

====================================以上都是运行在新生代的垃圾回收器====================================

====================================接下来的是老年代垃圾回收器=========================================

6.4、Serial Old收集器

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

采用算法:复制算法

Serial Old收集器的工作过程如图3-8所示。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

6.6、Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择(还记得上面说过Parallel Scavenge收集器无法与CMS收集器配合工作吗?)。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合“给力”。

直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。Parallel Old收集器的工作过程如图3-9所示。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

参数控制: -XX:+UseParallelOldGC 使用Parallel收集器+ 老年代并行

6.7、CMS收集器

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

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

初始标记(CMS initial mark)

并发标记(CMS concurrent mark)

重新标记(CMS remark)

并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行。老年代收集器(新生代使用ParNew)

优点:并发收集、低停顿

缺点:产生大量空间碎片、并发阶段会降低吞吐量

参数控制:-XX:+UseConcMarkSweepGC 使用CMS收集器

-XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长

-XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理

-XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

6.8、G1(Garbage-First)收集器

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,早在JDK 1.7刚刚确立项目目标,Sun公司给出的JDK 1.7 RoadMap里面,它就被视为JDK 1.7中HotSpot虚拟机的一个重要进化特征。从JDK 6u14中开始就有Early Access版本的G1收集器供开发人员实验、试用,由此开始G1收集器的“Experimental”状态持续了数年时间,直至JDK 7u4,Sun公司才认为它达到足够成熟的商用程度,移除了“Experimental”的标识。

G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

1. 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。

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

上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续)Region的集合。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

G1的新生代收集跟ParNew类似,当新生代占用达到一定比例的时候,开始出发收集。和CMS类似,G1收集器收集老年代对象会有短暂停顿。

收集步骤

1、标记阶段,首先初始标记(Initial-Mark),这个阶段是停顿的(Stop the World Event),并且会触发一次普通Mintor GC。对应GC log:GC pause (young) (inital-mark)

2、Root Region Scanning,程序运行过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成。

3、Concurrent Marking,在整个堆中进行并发标记(和应用程序并发执行),此过程可能被young GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收(图中打X)。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

4、Remark, 再标记,会有短暂停顿(STW)。再标记阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行);G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning (SATB)。

5、Copy/Clean up,多线程清除失活对象,会有STW。G1将回收区域的存活对象拷贝到新区域,清除Remember Sets,并发清空回收区域并把它返回到空闲区域链表中。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

6、复制/清除过程后。回收区域的活性对象已经被集中回收到深蓝色和深绿色区域。

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

七、常用的收集器组合

7.1、 新生代和老年代的收集器组合对应表

参数

新生代GC策略

年老代GC策略

说明

UseSerialGC

Serial

Serial Old

Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。

UseSerialGC(组合2)

Serial

CMS+Serial Old

CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。

UseConcMarkSweepGC

ParNew

CMS

使用-XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。

如果指定了选项-XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。

UseParNewGC

ParNew

Serial Old

使用-XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。

UseParallelGC

Parallel Scavenge

Serial Old

Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。

UseParallelOldGC

Parallel Scavenge

Parallel Old

Parallel Old是Serial Old的并行版本

-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC

G1GC

G1GC

-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC        #开启
-XX:MaxGCPauseMillis =50                  #暂停时间目标
-XX:GCPauseIntervalMillis =200          #暂停间隔目标
-XX:+G1YoungGenSize=512m            #年轻代大小
-XX:SurvivorRatio=6                            #幸存区比例

7.2、 组合下对应的运行示意图

第1个组合:Serial + Serial Old

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

第2个组合:Serial + (CMS + Serial Old)(当CMS进行GC失败时,会自动使用Serial Old策略进行GC)

第3个组合:ParNew+ Serial Old

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

第4个组合:ParNew + CMS

第5个组合:Parallel Scavenge + Parallel Old

GC之一--GC 的算法分析、垃圾收集器、内存分配策略介绍

八、JVM内存分配策略

1. 对象优先在Eden分配

如果Eden区不足分配对象,会做一个minor gc,回收内存,尝试分配对象,如果依然不足分配,才分配到Old区。

示例:

/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
*/
@SuppressWarnings("unused")
public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
System.out.println("1");
allocation3 = new byte[2 * _1MB];
System.out.println("2");
allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
System.out.println("3");
}

结果:

1
2
[GC (Allocation Failure) [DefNew: 7127K->523K(9216K), 0.0090101 secs] 7127K->6667K(19456K), 0.0091057 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
3
Heap
def new generation total 9216K, used 4862K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 52% used [0x00000000fec00000, 0x00000000ff03c988, 0x00000000ff400000)
from space 1024K, 51% used [0x00000000ff500000, 0x00000000ff582ec0, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
Metaspace used 2573K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 285K, capacity 386K, committed 512K, reserved 1048576K

结果分析:

1、jdk8中需要修改默认的收集器,将其修改为串行:-XX:+UseSerialGC

2.大对象直接进入老年代

大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串及数组,虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。-XX:PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效

示例:

/**
* @VM args:-verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728 -XX:+PrintGCDetails -Xloggc:gc.log -XX:+UseSerialGC
* 3145728=3m
* @author lenovo
*/
public static void testPretenureSizeThreshold() {
byte[] allocation;
allocation = new byte[4 * _1MB]; }

结果:

Java HotSpot(TM) 64-Bit Server VM (25.77-b03) for windows-amd64 JRE (1.8.0_77-b03), built on Mar 20 2016 22:01:33 by "java_re" with MS VC++ 10.0 (VS2010)
Memory: 4k page, physical 16199336k(11697456k free), swap 19213992k(13645132k free)
CommandLine flags: -XX:InitialHeapSize=20971520 -XX:MaxHeapSize=20971520 -XX:MaxNewSize=10485760 -XX:NewSize=10485760 -XX:PretenureSizeThreshold=3145728 -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:SurvivorRatio=8 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseSerialGC
Heap
def new generation total 9216K, used 1147K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 14% used [0x00000000fec00000, 0x00000000fed1ef60, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)
Metaspace used 2571K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 285K, capacity 386K, committed 512K, reserved 1048576K

结果分析:

1、要为年轻代配置串行收集器,默认为-XX:+UseParallelGC,否则-XX:PretenureSizeThreshold参数无效的。

2、4m的byte数组直接被分配到老年代,上面gc日志的红色标红部分。

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

  在经历了多次的Minor GC后仍然存活:在触发了Minor GC后,存活对象被存入Survivor区在经历了多次Minor GC之后,如果仍然存活的话,则该对象被晋升到Old区。
虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(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:+UseSerialGC -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
byte[] allocation1, allocation2, allocation3;
allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
System.out.println("1");
allocation2 = new byte[4 * _1MB];
System.out.println("2");
allocation3 = new byte[4 * _1MB];
System.out.println("3");
allocation3 = null;
System.out.println("4");
allocation3 = new byte[4 * _1MB];
System.out.println("5");
}

结果:

1
2
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 798424 bytes, 798424 total
: 5335K->779K(9216K), 0.0067146 secs] 5335K->4875K(19456K), 0.0068249 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
3
4
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 96 bytes, 96 total
: 5034K->0K(9216K), 0.0022269 secs] 9130K->4875K(19456K), 0.0022859 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
5
Heap
def new generation total 9216K, used 4419K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 53% used [0x00000000fec00000, 0x00000000ff050ce8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400060, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 4874K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 47% used [0x00000000ff600000, 0x00000000ffac2bc8, 0x00000000ffac2c00, 0x0000000100000000)
Metaspace used 2573K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 285K, capacity 386K, committed 512K, reserved 1048576K

为-XX:MaxTenuringThreshold=15时的结果是:

1
2
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 798424 bytes, 798424 total
: 5335K->779K(9216K), 0.0072603 secs] 5335K->4875K(19456K), 0.0073546 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
3
4
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 96 bytes, 96 total
: 5034K->0K(9216K), 0.0023141 secs] 9130K->4875K(19456K), 0.0023590 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
5
Heap
def new generation total 9216K, used 4419K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 53% used [0x00000000fec00000, 0x00000000ff050ce8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400060, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 4874K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 47% used [0x00000000ff600000, 0x00000000ffac2bc8, 0x00000000ffac2c00, 0x0000000100000000)
Metaspace used 2573K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 285K, capacity 386K, committed 512K, reserved 1048576K

结果分析:

4.动态对象年龄判定

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

/**
* VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
* -XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大于survivo空间一半
System.out.println("1");
allocation2 = new byte[_1MB / 4];
System.out.println("2");
allocation3 = new byte[4 * _1MB];
System.out.println("3");
allocation4 = new byte[4 * _1MB];
System.out.println("4");
allocation4 = null;
System.out.println("5");
allocation4 = new byte[4 * _1MB];
System.out.println("6");
}

结果:

1
2
3
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age 1: 1048576 bytes, 1048576 total
: 5591K->1024K(9216K), 0.0085177 secs] 5591K->5131K(19456K), 0.0086216 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
4
5
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 96 bytes, 96 total
: 5279K->0K(9216K), 0.0024949 secs] 9387K->5131K(19456K), 0.0025430 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
6
Heap
def new generation total 9216K, used 4419K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 53% used [0x00000000fec00000, 0x00000000ff050d88, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400060, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 5131K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 50% used [0x00000000ff600000, 0x00000000ffb02f78, 0x00000000ffb03000, 0x0000000100000000)
Metaspace used 2573K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 285K, capacity 386K, committed 512K, reserved 1048576K

5.Minor GC后Survivor空间不足就直接放入Old区

6.空间分配担保

  在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC;如果不允许,则也要改为进行一次Full GC。大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。在JDK 6 Update 24之后,这个测试结果会有差异,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化(见代码清单3-10),虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

九、如何监视GC

1.概览监视gc。

jmap -heap [pid] 查看内存分布

jstat -gcutil [pid] 1000 每隔1s输出java进程的gc情况

2.详细监视gc。

在jvm启动参数,加入-verbose:gc -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:./gc.log。

输入示例:

 [GC [ParNew: 11450951K->1014116K(11673600K), 0.8698830 secs] 27569972K->17943420K(37614976K), 0.8699520 secs] [Times: user=11.28 sys=0.82, real=0.86 secs]

表示发生一次minor GC,ParNew是新生代的gc算法,11450951K表示eden区的存活对象的内存总和,1014116K表示回收后的存活对象的内存总和,11673600K是整个eden区的内存总和。0.8699520 secs表示minor gc花费的时间。

27569972K表示整个heap区的存活对象总和,17943420K表示回收后整个heap区的存活对象总和,37614976K表示整个heap区的内存总和。

[Full GC [Tenured: 27569972K->16569972K(27569972K), 180.2368177 secs] 36614976K->27569972K(37614976K), [Perm : 28671K->28635K(28672K)], 0.2371537 secs]

表示发生了一次Full GC,整个JVM都停顿了180多秒,输出说明同上。只是Tenured: 27569972K->16569972K(27569972K)表示的是old区,而上面是eden区。

十、关键术语

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

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

吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)

响应时间:停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。

高吞吐量:而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。