阅读 605
JVM性能调优(2) —— 垃圾回收算法和垃圾回收器

JVM性能调优(2) —— 垃圾回收算法和垃圾回收器

系列文章专栏:JVM系列专栏

垃圾回收机制

为什么需要垃圾回收

Java 程序在虚拟机中运行,是会占用内存资源的,比如创建的对象、加载的类型数据等,而且内存资源都是有限的。当创建的对象不再被引用时,就需要被回收掉,释放内存资源,这个时候就会用到JVM的垃圾回收机制。

JVM 启动时就提供了一个垃圾回收线程来跟踪每一块分配出去的内存空间,并定期清理需要被回收的对象。Java 程序无法强制执行垃圾回收,我们可以通过调用 System.gc 方法来"建议" 执行垃圾回收,但是否可执行,什么时候执行,是不可预期的。

垃圾回收发生在哪里

JVM内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,随着线程的创建而创建,销毁而销毁。栈中的栈帧随着方法的调用而入栈,随着方法的退出而出栈,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此这三个区域的内存分配和回收都具有确定性。

而堆和方法区这两个区域则有着显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾回收的重点就是关注堆和方法区中的内存,堆中的回收主要是垃圾对象的回收,方法区的回收主要是废弃常量和无用的类的回收

对象在什么时候可以被回收

一般一个对象不再被引用,就代表该对象可以被回收。主流的虚拟机一般都是使用 可达性分析算法 来判断该对象是否可以被回收,有些内存管理系统也是用 引用计数法 来判断。

引用计数算法

这种算法是通过在对象中添加一个引用计数器来判断该对象是否被引用了。每当对象被引用,计数器就加 1;每当引用失效,计数器就减 1。当对象的引用计数器的值为 0 时,就说明该对象不再被引用,可以被回收了。

引用计数算法实现简单,判断效率也很高,但它无法解决对象之间相互循环引用的问题。两个对象若互相引用,但没有任何其它对象引用他们,而它们的引用计数器都不为零,就无法被回收。

可达性分析算法

GC Roots 是该算法的基础,GC Roots 是所有对象的根对象。在垃圾回收时,会从这些 GC Roots 根对象开始向下搜索,在搜索的这个引用链上的对象,就是可达的对象;而一个对象到 GC Roots 没有任何引用链相连时,就证明此对象是不可达的,可以被回收。

在Java中,可作为 GC Roots 对象的一般包括如下几种:

  • Java虚拟机栈中的引用的对象,如方法参数、局部变量、临时变量等
  • 方法区中的类静态属性引用的对象
  • 方法区中的常量引用的对象,如字符串常量池的引用
  • 本地方法栈中JNI的引用的对象
  • Java虚拟机内部的引用,如基本数据类型的 Class 对象,系统类加载器等

比如下面的代码:

其中,类静态变量 MAPPER,loadAccount 方法的局部变量 account1、account2、accountList 都可以作为 GC Roots(ArrayList 内部是用 Object[] elementData 数组来存放元素的)。

在调用 loadAccount 方法时,堆中的对象都是可达的,因为有 GC Roots 直接或间接引用到这些对象,此时若发生垃圾回收,这些对象是不可被回收的。loadAccount 执行完后,弹出栈帧,方法内的局部变量都被回收了,虽然堆中 ArrayList 对象还指向 elementData 数组,而 elementData 指向 Account 对象,但没有任何 GC Roots 的引用链能达到这些对象,因此这些对象将变为垃圾对象,被垃圾回收器回收掉。

回收方法区

方法区垃圾回收的“性价比”通常是比较低的,方法区的垃圾回收主要回收两部分内容:废弃的常量和不再使用的类型

1、废弃的常量

  • 如常量池中废弃的字面量,字段、方法的符号引用等

2、不再使用的类型

判定一个类型是否属于“不再被使用的类”需要同时满足三个条件:

  • 该类所有的实例都已经被回收,Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

Java中的引用类型

Java 中有四种不同的引用类型:强引用、软引用、弱引用、虚引用,这4种引用强度依次逐渐减弱。

1、强引用

强引用是最普遍的引用方式,如在方法中定义:Object obj = new Object()。只要引用还在,垃圾回收器就不会回收被引用的对象。

2、软引用

软引用是用来描述一些有用但非必须的对象,可以使用 SoftReference 类来实现软引用。对于软引用关联着的对象,在系统将要发生内存溢出异常之前(一般发生老年代GC时),会把这些对象列进回收范围之中。如果回收之后内存还是不足,才会报内存溢出的异常。

这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现内存缓存,当内存快满时,就回收掉这些软引用的对象,然后需要的时候再重新查询。比如下面的代码:

3、弱引用

弱引用是用来描述非必须的对象,可以使用 WeakReference 类来实现弱引用。它只能生存到下一次垃圾回收发生之前(一般发生年轻代GC时),当垃圾回收机制开始时,无论是否会内存溢出,都将回收掉被弱引用关联的对象。

需注意的是,我们使用 SoftReference 来创建软引用对象,使用 WeakReference 来创建弱引用对象,垃圾回收时,是回收它们关联的对象,而不是 Reference 本身。同时,如果 Reference 关联的对象被其它 GC Roots 引用着,也是不能被回收的。如下面的代码,在垃圾回收时,只有 T002 这个 Account 对象能被回收,回收后 reference2.get() 返回值为 null,account、reference1、reference2 所指向的对象都不能被回收。

4、虚引用

最没有存在感的一种引用关系,可以使用 PhantomReference 类来实现虚引用。存在不存在几乎没影响,也不能通过虚引用来获取一个对象实例,存在的唯一目的是被垃圾回收器回收后可以收到一条系统通知。

垃圾回收算法

分代收集理论

大部分虚拟机的垃圾回收器都是遵循分代收集的理论进行设计的,它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般至少将堆划分为新生代和老年代两个区域,然后可以根据不同代的特点采取最适合的回收算法。在新生代中,每次垃圾回收时都有大量对象死去,因为程序创建的绝大部分对象的生命周期都很短,朝生夕灭。而新生代每次回收后存活的少量对象,将会逐步晋升到老年代中存放。老年代每次垃圾收集时只有少量对象需要被回收,因为老年代的大部分对象一般都是全局变量引用的,生命周期一般都比较长。

在Java堆划分出不同的区域之后,垃圾回收器就可以每次只回收其中某一个或者某些部分的区域,因而也有了“Young GC”“Old GC”“Full GC”这样的回收类型的划分。也能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾回收算法,因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾回收算法。

GC类型:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集,包括新生代、老年代、方法区的回收,一般 Full GC 等价于 Old GC。

经典分代模型:

标记-清除算法(Mark-Sweep)

标记-清除算法 分为“标记”“清除”两个阶段,首先从 GC Roots 进行扫描,对存活的对象进行标记,标记完后,再统一回收所有未被标记的对象。

优点:

  • 标记-清除算法不需要进行对象的移动,只需回收未标记的垃圾对象,在存活对象比较多的情况下极为高效。

缺点:

  • 标记-清除算法执行效率不稳定,如果堆中对象很多,而且大部分都是要回收的对象,就必须要进行大量的标记和清除动作,导致标记、清除两个过程的效率随着对象数量增长而降低。
  • 标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法(Copying)

标记-复制算法简称为复制算法,复制算法主要是为了解决标记-清除算法在存在大量可回收对象时执行效率低下和内存碎片的问题。

半区复制算法

它将可用内存划分为大小相等的两块,每次只使用其中的一块。当这一块的内存满了,就从 GC Roots 开始扫描,将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

优点:

  • 每次都是针对整个半区进行内存回收,清理速度快,没有内存碎片产生
  • 每次回收后,对象有序排列到另一个空闲区域,分配内存时也就不用考虑有空间碎片的复杂情况

缺点:

  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销
  • 复制回收算法将可用内存缩小为了原来的一半,内存使用率低

复制算法的优化

大多数对象都是朝生夕灭,新生代中 98% 的对象几乎都熬不过第一轮回收,因此并不需要按照 1∶1 的比例来划分新生代的内存空间。

因此新生代复制算法一般是把新生代分为一块较大的 Eden 区和两块较小的 Survivor(from survivor、to survivor) 区,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾回收时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间,如此往复。当对象经过垃圾回收的次数超过一定阀值还未被回收掉时,就会进入老年代,有些大对象也可以直接进入老年代。

相比半区复制算法:

  • 优点:HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1 : 1,新生代与老年代的比例大概是 1 : 2。内存空间利用率高,只会有 10% 的空闲空间。
  • 缺点:有可能一次 Young GC 后存活的对象超过一个 survivor 区的大小,这时候会依赖其它内存区域进行分配担保,让这部分存活下来的对象直接进入另一个区域,一般就是老年代。

标记-整理算法(Mark-Compact)

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

标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,它不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

优点:

  • 没有内存碎片产生,适合老年代垃圾回收

缺点:

  • 会有对象的移动,老年代存活对象多,移动对象还需要更新指针,因此成本会更高

总结对比

垃圾回收器

垃圾回收算法是内存回收的方法论,垃圾回收器是内存回收的实践者。不同的垃圾回收器有不同的特性,并没有一个万能或最好的垃圾回收器,只能根据不同的业务场景选择最合适的垃圾回收器,所以这节就来了解下各个垃圾回收器的特性。

Stop The World(STW)

先看看jvm的 Stop The World 问题。

1、STW

可达性分析算法从 GC Roots 集合找引用链时,需要枚举根节点,然后从根节点标记存活的对象,根节点枚举以及整理内存碎片时,都会发生 Stop The World,此时 jvm 会直接暂停应用程序的所有用户线程,然后进行垃圾回收。因为垃圾回收时如果还在继续创建对象或更新对象引用,就会导致这些对象可能无法跟踪和回收、跟节点不断变化等比较复杂的问题,因此垃圾回收过程必须暂停所有用户线程,进入 STW 状态。垃圾回收完成后,jvm 会恢复应用程序的所有用户线程。

所有垃圾回收器都无法避免 STW,只能尽量缩短用户线程的停顿时间。系统停顿期间,无法处理任何请求,所有用户请求都会出现短暂的卡顿。如果因为内存分配不合理或垃圾回收器使用不合理,导致频繁的垃圾回收,而且每次回收系统停顿时间过长,这会让用户体验极差。jvm 最重要的一个优化就是通过合理的内存分配,使用合适的垃圾回收器,使得垃圾回收频率最小、停顿时间最短,避免影响系统正常运行。

2、安全点(Safe Point)

用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。安全点可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这个位置暂停。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

jvm 采用主动式中断的方式,在垃圾回收发生时让所有线程都跑到最近的安全点。主动式中断的思想是当垃圾回收需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

3、安全区域(Safe Region)

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾回收过程的安全点。但是,程序“不执行”的时候,线程就无法响应虚拟机的中断请求,如用户线程处于Sleep状态或者Blocked状态,这个时候就没法再走到安全的地方去中断挂起自己。这就需要安全区域来解决了。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾回收都是安全的。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾回收时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了需要暂停用户线程的阶段,如果完成了,那线程就继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

Serial 垃圾回收器

Serial 垃圾回收器是一个单线程回收器,它进行垃圾回收时,必须暂停其他所有用户线程,直到它回收结束。Serial 主要用于新生代垃圾回收,采用复制算法实现。

服务端程序几乎不会使用 Serial 回收器,服务端程序一般会分配较大的内存,可能几个G,如果使用 Serial 回收器,由于是单线程,标记、清理阶段就会花费很长的时间,就会导致系统较长时间的停顿。

Serial 一般用在客户端程序或占用内存较小的微服务,因为客户端程序一般分配的内存都比较小,可能几十兆或一两百兆,回收时的停顿时间是完全可以接受的。而且 Serial 是所有回收器里额外消耗内存最小的,也没有线程切换的开销,非常简单高效。

Serial Old 垃圾回收器

Serial Old 是 Serial 的老年代版本,它同样是一个单线程回收器,主要用于客户端程序。Serial Old 用于老年代垃圾回收,采用标记-整理算法实现。

Serial Old 也可以用在服务端程序,主要有两种用途:一种是与 Parallel Scavenge 回收器搭配使用,另外一种就是作为 CMS 回收器发生失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

ParNew 垃圾回收器

ParNew 回收器实质上是 Serial 回收器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为都与 Serial 回收完全一致,控制参数、回收算法、对象分配规则等都是一致的。除了 Serial 回收器外,目前只有 ParNew 回收器能与 CMS 回收器配合工作,ParNew 是激活CMS后的默认新生代回收器。

ParNew 默认开启的回收线程数与处理器核心数量相同,在处理器核心非常多的环境中,可以使用 -XX: ParallelGCThreads 参数来限制垃圾回收的线程数。

Parallel Scavenge 垃圾回收器

Parallel Scavenge 是新生代回收器,采用复制算法实现,也是能够并行回收的多线程回收器。Parallel Scavenge 主要关注可控制的吞吐量,其它回收器的关注点是尽可能地缩短垃圾回收时的停顿时间。吞吐量就是处理器用于运行程序代码的时间与处理器总消耗时间的比值,总消耗时间等于运行程序代码的时间加上垃圾回收的时间。

Parallel Scavenge 提供了两个参数用于精确控制吞吐量:

  • -XX: MaxGCPauseMillis:控制最大垃圾回收停顿时间,参数值是一个大于 0 的毫秒数,回收器将尽力保证垃圾回收花费的时间不超过这个值。
  • -XX: GCTimeRatio:直接设置吞吐量大小,参数值是一个大于 0 小于 100 的整数,就是垃圾回收时间占总时间的比率。默认值为 99,即允许最大1%(即1/(1+99))的垃圾收集时间。

Parallel Scavenge 还有一个参数 -XX: +UseAdaptiveSizePolicy,当设置这个参数之后,就不需要人工指定新生代的大小、Eden与Survivor区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

Parallel Old 垃圾回收器

Parallel Old 是 Parallel Scavenge 的老年代版本,支持多线程并发回收,采用标记-整理算法实现。在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 这个组合。

CMS 垃圾回收器

CMS(Concurrent Mark Sweep) 是一种以获取最短回收停顿时间为目标的回收器。CMS 用于老年代垃圾回收,采用标记-清除算法实现。

CMS 回收过程

CMS 垃圾回收总体分为四个步骤:

  • 1)初始标记(会STW):初始标记需要 Stop The World,初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。
  • 2)并发标记:并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象引用链的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾回收线程一起并发运行。
  • 3)重新标记(会STW):重新标记需要 Stop The World,重新标记阶段是为了修正并发标记期间,因程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  • 4)并发清除:清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的。

最耗时的并发标记和并发清除阶段是和用户线程并发进行的,总体上来说,CMS 回收过程是与用户线程一起并发执行的,是一款并发低停顿的回收器。

触发CMS的条件

CMS GC 在实现上分成 foreground collectorbackground collector

1、foreground collector

foreground collector 触发条件比较简单,一般是遇到对象分配但空间不够,就会直接触发 GC,来立即进行空间回收。采用的算法是 mark sweep,不压缩。

2、background collector

background collector 是通过 CMS 后台线程不断的去扫描,过程中主要是判断是否符合 background collector 的触发条件,一旦有符合的情况,就会进行一次 background 的 collect。每次扫描过程中,先等 CMSWaitDuration 时间(默认2秒),然后再判断是否满足 background collector 的触发条件。

background collector 的触发条件:

  • 并行 Full GC,如调用了 System.gc()
  • 未配置 UseCMSInitiatingOccupancyOnly 时,会根据统计数据动态判断是否需要进行一次 CMS GC。如果预测 CMS GC 完成所需要的时间大于预计的老年代将要填满的时间,则进行 GC。这些判断是需要基于历史的 CMS GC 统计指标,第一次 CMS GC 时,统计数据还没有形成,是无效的,这时会跟据 Old Gen 的使用占比来判断是否要进行 GC。
  • 未配置 UseCMSInitiatingOccupancyOnly 时,判断 CMS 的使用率大于 CMSBootstrapOccupancy(默认50%)时触发 Old GC。
  • 老年代内存使用率阀值超过 CMSInitiatingOccupancyFraction(默认为92%)时触发 OldGC,CMSInitiatingOccupancyFraction 默认值为 -1,没有配置时默认阀值为 92%。
  • 未配置 UseCMSInitiatingOccupancyOnly 时,因为分配对象时内存不足导致的扩容等触发GC

CMS参数设置

在没有配置 UseCMSInitiatingOccupancyOnly 参数的情况下,会多出很多种触发可能,一般在生产环境会配置 UseCMSInitiatingOccupancyOnly 参数,配了之后就不用设置 CMSBootstrapOccupancy 参数了。

CMSInitiatingOccupancyFraction 设置得太高将会很容易导致频繁的并发失败,性能反而降低;太低又可能频繁触发CMS background collector,一般在生产环境中应根据实际应用情况来权衡设置。

-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSBootstrapOccupancy=92
-xx:CMSWaitDuration=2000
复制代码

CMS 的问题

1、并发回收导致CPU资源紧张

在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS默认启动的回收线程数是:(CPU核数 + 3)/ 4,当CPU核数不足四个时,CMS对用户程序的影响就可能变得很大。

2、无法清理浮动垃圾

在CMS的并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”

3、并发失败(Concurrent Mode Failure)

由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 92% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX: CMSInitiatingOccupancyFraction 参数来设置。

这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure) ,这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。

4、内存碎片问题

CMS是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。

为了解决这个问题,CMS收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:CMSFullGCsBeforeCompaction,这个参数的作用是要求CMS在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为0,表示每次进入 Full GC 时都进行碎片整理)。

G1 垃圾回收器

G1 (Garbage First) 回收器采用面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾回收器。G1设计初衷就是替换 CMS,成为一种全功能收集器。G1 在JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1从整体来看是基于 标记-整理 算法实现的回收器,但从局部(两个Region之间)上看又是基于 标记-复制 算法实现的。

可预期的回收停顿时间

G1 可以指定垃圾回收的停顿时间,通过 -XX: MaxGCPauseMillis 参数指定,默认为 200 毫秒。这个值不宜设置过低,否则会导致每次回收只占堆内存很小的一部分,回收器的回收速度逐渐赶不上对象分配速度,导致垃圾慢慢堆积,最终占满堆内存导致 Full GC 反而降低性能。

G1之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单次回收的最小单元,即每次回收到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾回收。G1会去跟踪各个Region的垃圾回收价值,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的回收停顿时间,优先处理回收价值收益最大的那些 Region。这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 回收器在有限的时间内得到尽可能高的回收效率。

由于 Region 数量比传统回收器的分代数量明显要多得多,因此G1回收器要比其他的传统垃圾回收器有着更高的内存占用负担。G1至少要耗费大约相当于Java堆容量 10%至20% 的额外内存来维持回收器工作。

G1内存布局

G1不再是固定大小以及固定数量的分代区域划分,而是把堆划分为多个大小相等的Region,每个Region的大小默认情况下是堆内存大小除以2048,因为JVM最多可以有2048个Region,而且每个Region的大小必须是2的N次冥。每个Region的大小也可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为1MB~32MB,且应为2的N次幂。

G1也有新生代和老年代的概念,不过是逻辑上的区分,每一个 Region 都可以根据需要,作为新生代的Eden空间、Survivor空间,或者老年代空间新生代默认占堆内存的 5%,但最多不超过 60%,这个默认值可以使用 -XX:G1NewSizePercent 参数设置,最大值可以通过 -XX:G1MaxNewSizePercent 参数设置。新生代 Region 的数量并不是固定的,随着使用和垃圾回收会动态的变化。同样的,G1新生代也有 eden 区和 survivor 区的划分,也可以通过 -XX:SurvivorRatio 设置其比例,默认为 8。

大对象Region

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象,而不是直接进入老年代的 Region。G1 认为一个对象只要大小超过了一个 Region 容量的一半就判定为大对象。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在N个连续的 Humongous Region 之中,G1的大多数行为都把 Humongous Region 作为老年代的一部分来看待。

G1 回收过程

G1 回收器的运作过程大致可分为四个步骤:

  • 1)初始标记(会STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 2)并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
  • 3)最终标记(会STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
  • 4)清理阶段(会STW):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

G1新生代回收

根据 G1 的内存布局举个例子,例如:设置堆内存 4G,就是 4096M,除以2048个Region,每个Region就是2M;新生代期初占5%,就是约100个Region,此时eden区占80个Region,两个survivor区各占10个Region;不过随着对象的在新生代分配,属于新生代的Region会不断增加,eden和survivor对应的Region也会不断增加。直到新生代占用60%,也就是约1200个Region,就会触发新生代的GC,这个时候就会采用复制算法将eden对应Region存活的对象复制到 from survivor 对应的Region。只不过这里会根据用户期望的停顿时间来选取部分最有回收价值的Region进行回收。

G1混合回收

G1有一个参数,-XX:InitiatingHeapOccupancyPercent,它的默认值是 45%,就是如果老年代占堆内存 45% 的 Region 的时候,此时就会触发一次年轻代+老年代的混合回收

混合回收阶段,因为我们设定了最大停顿时间,所以 G1 会从新生代、老年代、大对象里挑选一些 Region,保证指定的时间内回收尽可能多的垃圾。所以 G1 可能一次无法将所有Region回收完,它就会执行多次混合回收,先停止程序,执行一次混合回收回收掉一些Region,接着恢复系统运行,然后再次停止系统运行,再执行一次混合回收回收掉一些Region。可以通过参数 -XX:G1MixedGCCountTarget 设置一次回收的过程中,最后一个阶段最多执行几次混合回收,默认值是8次。通过这种反复回收的方式,避免系统长时间的停顿。

G1还有一个参数 -XX:G1HeapWastePercent,默认值是 5%。就是在混合回收时,Region回收后,就会不断的有新的Region空出来,一旦空闲出来的Region数量超过堆内存的5%,就会立即停止混合回收,即本次混合回收就结束了。

G1还有一个参数 -XX:G1MixedGCLiveThresholdPercent,默认值是85%。意思是回收Region的时候,必须存活对象低于Region大小的85%时才可以进行回收,一个Region存活对象超过85%,就不必回收它了,因为要复制大部分存活对象到别的Region,这个成本是比较高的。

回收失败

1、并发回收失败

在并发标记阶段,用户线程还在并发运行,程序继续运行就会持续有新对象产生,也需要预留足够的空间提供给用户线程使用。G1 为每一个 Region 设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。如果内存回收的速度赶不上内存分配的速度,跟CMS会发生并发失败一样,G1也要被迫暂停程序,导致 Full GC 而产生长时间 Stop The World。

2、混合回收失败

混合回收阶段,年轻代和老年代都是基于复制算法进行回收,复制的过程中如果没有空闲的Region了,就会触发失败。一旦失败,就会停止程序,然后采用单线程标记、清理和内存碎片整理,然后空闲出来一批Region。这个过程是很慢的,因此要尽量调优避免混合回收失败的发生。

总结对比

1、垃圾回收器间的配合使用

2、各个垃圾回收器对比

GC性能衡量指标

一个垃圾收集器在不同场景下表现出的性能也不一样,我们可以借助下面的一些指标来衡量GC的性能。

1、吞吐量

吞吐量是指应用程序所花费的时间和系统总运行时间的比值。系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%

2、停顿时间

指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

3、垃圾回收频率

通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。

文章分类
后端
文章标签