JVM性能调优(2) —— 垃圾回收器和回收策略

时间:2024-01-14 12:03:14

一、垃圾回收机制

1、为什么需要垃圾回收

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

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

2、垃圾回收发生在哪里

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

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

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

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

1)引用计数算法:

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

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

2)可达性分析算法:

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

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

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

比如下面的代码:

JVM性能调优(2) —— 垃圾回收器和回收策略

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

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

JVM性能调优(2) —— 垃圾回收器和回收策略

4、回收方法区

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

1)废弃的常量:

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

2)不再使用的类型:

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

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

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

5、Java中的引用类型

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

1)强引用:

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

2)软引用:

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

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

JVM性能调优(2) —— 垃圾回收器和回收策略

3)弱引用:

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

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

JVM性能调优(2) —— 垃圾回收器和回收策略

4)虚引用:

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

二、垃圾回收算法

1、分代收集理论

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

在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。

经典分代模型:

JVM性能调优(2) —— 垃圾回收器和回收策略

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

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

优点:

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

缺点:

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

JVM性能调优(2) —— 垃圾回收器和回收策略

3、标记-复制算法(Copying)

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

1)半区复制算法

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

优点:

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

缺点:

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

JVM性能调优(2) —— 垃圾回收器和回收策略

2)复制算法的优化

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

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

JVM性能调优(2) —— 垃圾回收器和回收策略

相比半区复制算法:

优点:HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 : 1 : 1,新生代与老年代的比例大概是 1 : 2。内存空间利用率高,只会有 10% 的空闲空间。

缺点:有可能一次 Young GC 后存活的对象超过一个 survivor 区的大小,这时候会依赖其它内存区域进行分配担保,让这部分存活下来的对象直接进入另一个区域,一般就是老年代。

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

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

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

JVM性能调优(2) —— 垃圾回收器和回收策略

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

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

5、总结对比

JVM性能调优(2) —— 垃圾回收器和回收策略

三、垃圾回收器

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

1、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状态,这个时候就没法再走到安全的地方去中断挂起自己。这就需要安全区域来解决了。

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

2、Serial 垃圾回收器

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

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

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

3、Serial Old 垃圾回收器

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

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

4、ParNew 垃圾回收器

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

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

5、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区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

6、Parallel Old 垃圾回收器

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

7、CMS 垃圾回收器

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

1)CMS 回收过程:

CMS 垃圾回收整个过程分为四个步骤:

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

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

2)CMS 的问题:

① 并发回收导致CPU资源紧张:

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

② 无法清理浮动垃圾:

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

③ 并发失败(Concurrent Mode Failure):

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

这里会有一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。所以参数 -XX: CMSInitiatingOccupancyFraction 设置得太高将会很容易导致大量的并发失败产生,性能反而降低;太低又可能频繁触发CMS回收,所以在生产环境中应根据实际应用情况来权衡设置。

-XX: CMSInitiatingOccupancyFraction 参数值默认为-1,计算出来的阀值是92%,也可以自己指定值,同时还需要设置 -XX:+UseCMSInitiatingOccupancyOnly,让JVM使用设定的回收阈值,如果不设置,JVM仅在第一次使用设定值,后续则自动调整。

而且,CMS并不是时时刻刻都在执行GC的,可以通过 -XX:CMSWaitDuration 参数设置CMS GC线程的间隔时间,默认值为2000毫秒。

④ 内存碎片问题:

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

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

8、G1 垃圾回收器

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

1)G1 回收器的特点:

② 可预期的回收停顿时间

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

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

③ 占用更高的内存

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

2)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:G1MaxNewSizePercent 参数设置。

3)大对象Region

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

3)G1 回收过程

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

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

4)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,这个成本是比较高的。

5)回收失败

① 并发回收失败

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

② 混合回收失败

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

9、总结对比

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

JVM性能调优(2) —— 垃圾回收器和回收策略

2)各个垃圾回收器对比

JVM性能调优(2) —— 垃圾回收器和回收策略

10、GC性能衡量指标

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

1)吞吐量

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

2)停顿时间

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

3)垃圾回收频率

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

四、内存设置和查看GC日志

1、设置JVM内存

1)JVM内存分配有如下一些参数:

  • -Xms:堆内存大小
  • -Xmx:堆内存最大大小
  • -Xmn:新生代大小,扣除新生代剩下的就是老年代大小
  • -Xss:线程栈大小
  • -XX:NewSize:初始新生代大小
  • -XX:MaxNewSize:最大新生代大小
  • -XX:InitialHeapSize:初始堆大小
  • -XX:MaxHeapSize:最大堆大小
  • -XX:MetaspaceSize:元空间(永久代)大小,jdk1.8 之前用 -XX:PermSize 设置
  • -XX:MaxMetaspaceSize:元空间(永久代)最大大小,jdk8 之前用 -XX:MaxPermSize 设置
  • -XX:SurvivorRatio:新生代 Eden 区和 Survivor 区的比例,默认为 8,即 8:1:1

一般 -Xms 和 -Xmx 设置一样的大小,-XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 设置一样的大小。-Xms 等价于 -XX:InitialHeapSize,-Xmx等价于-XX:MaxHeapSize;-Xmn等价于-XX:MaxNewSize。

2)在IDEA中可以按照如下方式设置JVM参数:

JVM性能调优(2) —— 垃圾回收器和回收策略

3)命令行启动时可以按照如下格式设置:

java -jar -Xms1G -Xmx1G -Xmn512M -Xss1M -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=128M app.jar

2、查看GC日志

1)设置GC参数:

可以在启动时加上如下参数来查看GC日志:

  • -XX:+PrintGC:打印GC日志
  • -XX:+PrintGCDetails:打印详细的GC日志
  • -XX:+PrintGCTimeStamps:打印每次GC发生的时间
  • -Xloggc:./gc.log:设置GC日志文件的路径

例如,我在IDEA中添加了如下JVM启动参数:

-Xms1G
-Xmx1G
-Xmn512M
-Xss1M
-XX:MetaspaceSize=128M
-XX:MaxMetaspaceSize=128M
-XX:SurvivorRatio=8
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log

启动程序之后打印出了如下的一些日志:

 1 Java HotSpot(TM) 64-Bit Server VM (25.191-b12) for windows-amd64 JRE (1.8.0_191-b12), built on Oct  6 2018 09:29:03 by "java_re" with MS VC++ 10.0 (VS2010)
2 Memory: 4k page, physical 33408872k(22219844k free), swap 35506024k(21336808k free)
3 CommandLine flags: -XX:-BytecodeVerificationLocal -XX:-BytecodeVerificationRemote -XX:CompressedClassSpaceSize=125829120 -XX:InitialHeapSize=1073741824 -XX:+ManagementServer -XX:MaxHeapSize=1073741824 -XX:MaxMetaspaceSize=134217728 -XX:MaxNewSize=536870912 -XX:MetaspaceSize=134217728 -XX:NewSize=536870912 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:SurvivorRatio=8 -XX:ThreadStackSize=1024 -XX:TieredStopAtLevel=1 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
4 2020-09-25T13:00:41.631+0800: 4.013: [GC (Allocation Failure) [PSYoungGen: 419840K->20541K(472064K)] 419840K->20573K(996352K), 0.0118345 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
5 2020-09-25T13:00:44.252+0800: 6.633: [GC (Allocation Failure) [PSYoungGen: 440381K->39872K(472064K)] 440413K->39928K(996352K), 0.0180292 secs] [Times: user=0.08 sys=0.08, real=0.02 secs]
6 2020-09-25T13:00:45.509+0800: 7.891: [GC (Allocation Failure) [PSYoungGen: 459712K->45102K(472064K)] 459768K->45174K(996352K), 0.0181544 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
7 2020-09-25T13:00:46.809+0800: 9.191: [GC (Allocation Failure) [PSYoungGen: 464942K->48670K(472064K)] 465014K->48785K(996352K), 0.0214228 secs] [Times: user=0.16 sys=0.00, real=0.02 secs]
8 2020-09-25T13:00:48.425+0800: 10.807: [GC (Allocation Failure) [PSYoungGen: 468510K->52207K(472064K)] 468625K->57076K(996352K), 0.0218655 secs] [Times: user=0.17 sys=0.00, real=0.02 secs]
9 ......
10 ......
11 2020-09-25T13:06:58.361+0800: 380.743: [GC (Allocation Failure) [PSYoungGen: 422656K->14159K(472064K)] 610503K->204082K(996352K), 0.0111278 secs] [Times: user=0.16 sys=0.00, real=0.01 secs]
12 Heap
13 PSYoungGen total 472064K, used 406352K [0x00000000e0000000, 0x0000000100000000, 0x0000000100000000)
14 eden space 419840K, 93% used [0x00000000e0000000,0x00000000f7f00528,0x00000000f9a00000)
15 from space 52224K, 27% used [0x00000000f9a00000,0x00000000fa7d3d70,0x00000000fcd00000)
16 to space 52224K, 0% used [0x00000000fcd00000,0x00000000fcd00000,0x0000000100000000)
17 ParOldGen total 524288K, used 189923K [0x00000000c0000000, 0x00000000e0000000, 0x00000000e0000000)
18 object space 524288K, 36% used [0x00000000c0000000,0x00000000cb978d08,0x00000000e0000000)
19 Metaspace used 111852K, capacity 117676K, committed 117888K, reserved 1153024K
20 class space used 13876K, capacity 14914K, committed 14976K, reserved 1048576K

从第三行 CommandLine flags 可以得到如下的信息:

  • -XX:InitialHeapSize=1073741824:初始堆大小为1G(等于 -Xms 设置的值)
  • -XX:MaxHeapSize=1073741824:最大堆内存 1G(等于 -Xmx 设置的值)
  • -XX:NewSize=536870912:新生代初始大小 512M(等于 -Xmn 设置的值)
  • -XX:MaxNewSize=536870912:最新生代初始大小 512M(等于 -Xmn 设置的值)
  • -XX:MetaspaceSize=134217728:元空间大小 128M
  • -XX:MaxMetaspaceSize=134217728:最大元空间 128M
  • -XX:SurvivorRatio=8:新生代 Eden 和 Survivor 的比例
  • -XX:ThreadStackSize=1024:线程栈的大小 1M
  • -XX:+UseParallelGC:默认使用 年轻代 Parallel Scavenge + 老年代 Parallel Old 的垃圾回收器组合。

2)查看默认参数:

如果要查看JVM的默认参数,就可以通过给JVM加打印GC日志的参数,就可以在GC日志中看到JVM的默认参数了。

还可以在启动参数中添加 -XX:+PrintFlagsFinal 参数,将会打印系统的所有参数,就可以看到自己配置的参数或系统的默认参数了:

JVM性能调优(2) —— 垃圾回收器和回收策略

3)GC日志:

之后的日志就是每次垃圾回收时产生的日志,每行日志说明了这次GC的执行情况,例如第四行GC日志:

2020-09-25T13:00:41.631+0800: 4.013: [GC (Allocation Failure) [PSYoungGen: 419840K->20541K(472064K)] 419840K->20573K(996352K), 0.0118345 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

详细内容如下:

  • 2020-09-25T13:00:41.631+0800:GC发生的时间点。
  • 4.013:系统运行多久之后发生的GC,单位秒,这里就是系统运行 4.013 秒后发生了一次GC。
  • GC (Allocation Failure):说明了触发GC的原因,这里是指对象分配失败导致的GC。
  • PSYoungGen:指触发的是年轻代的垃圾回收,使用的是 Parallel Scavenge 垃圾回收器。
  • 419840K->20541K:对年轻代执行了一次GC,GC之前年轻代使用了 419840K,GC之后有 20541K 的对象活下来了。
  • (472064K):年轻代可用空间是 472064K,即 461 M,为什么是461M呢?因为新生代大小为 512M,Eden 区占 409.6M,两块 Survivor 区各占 51.2M,所以年轻代的可用空间为 Eden+1个Survivor的大小,即460.8M,约为461M。
  • 419840K->20573K:GC前整个堆内存使用了 419840K,GC之后堆内存使用了 20573K。
  • (996352K):整个堆的大小是 996352K,即 973M,其实就是年轻代的 461M + 老年代的 512 M
  • 0.0118345 secs:本次GC耗费的时间
  • Times: user=0.00 sys=0.00, real=0.01 secs:本次GC耗费的时间

4)JVM退出时的GC情况:

程序结束运行后,还会打印一些日志,就是第12行之后的日志,这部分展示的是当前堆内存的使用情况:

1 Heap
2 PSYoungGen total 472064K, used 406352K [0x00000000e0000000, 0x0000000100000000, 0x0000000100000000)
3 eden space 419840K, 93% used [0x00000000e0000000,0x00000000f7f00528,0x00000000f9a00000)
4 from space 52224K, 27% used [0x00000000f9a00000,0x00000000fa7d3d70,0x00000000fcd00000)
5 to space 52224K, 0% used [0x00000000fcd00000,0x00000000fcd00000,0x0000000100000000)
6 ParOldGen total 524288K, used 189923K [0x00000000c0000000, 0x00000000e0000000, 0x00000000e0000000)
7 object space 524288K, 36% used [0x00000000c0000000,0x00000000cb978d08,0x00000000e0000000)
8 Metaspace used 111852K, capacity 117676K, committed 117888K, reserved 1153024K
9 class space used 13876K, capacity 14914K, committed 14976K, reserved 1048576K

详细内容如下:

  • PSYoungGen total 472064K, used 406352K:指 Parallel Scavenge 回收器负责的年轻代总共有 472064K(461M)内存,目前使用了 406352K (396.8M)。
  • eden space 419840K, 93% used:Eden 区的空间为 419840K(410M),已经使用了 93%。
  • from space 52224K, 27% used:From Survivor 区的空间为 52224K(51M),已经使用了 27%。
  • to space 52224K, 0% used:To Survivor 区的空间为 52224K(51M),使用了 0%,就是完全空闲的。
  • ParOldGen total 524288K, used 189923K:指 Parallel Old 回收器负责的老年代总共有 524288K(512M),目前使用了 189923K(185.4M)。
  • object space 524288K, 36% used:老年代空间总大小 524288K(512M),使用了 36%。
  • Metaspace & class space:Metaspace 元数据空间和Class空间,总容量、使用的内存等。

五、内存分配与回收策略

接下来我们就通过一些demo结合着GC日志分析下什么时候会触发GC,以及对象在堆中如何分配流转的。

1、对象首先分配到Eden区

我们通过如下这段程序来验证下对象首先是分配到 Eden 区的:

1 public class GCMain {
2 static final int _1M = 1024 * 1024;
3
4 public static void main(String[] args) {
5 byte[] b1 = new byte[_1M * 30];
6 byte[] b2 = new byte[_1M * 30];
7 }
8 }

jvm参数设置为如下:堆200M,年轻代 100M,Eden区占 80M,Survivor 各占 10M,老年代100M。使用默认的 Parallel Scavenge + Parallel Old 回收器。

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:+UseParallelGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

程序运行结束后,查看运行的GC日志:

 1 Java HotSpot(TM) 64-Bit Server VM (25.191-b12) for windows-amd64 JRE (1.8.0_191-b12), built on Oct  6 2018 09:29:03 by "java_re" with MS VC++ 10.0 (VS2010)
2 Memory: 4k page, physical 33408872k(23013048k free), swap 35506024k(22095152k free)
3 CommandLine flags: -XX:InitialHeapSize=209715200 -XX:MaxHeapSize=209715200 -XX:MaxNewSize=104857600 -XX:NewSize=104857600 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:SurvivorRatio=8 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
4 Heap
5 PSYoungGen total 92160K, used 68062K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
6 eden space 81920K, 83% used [0x00000000f9c00000,0x00000000fde77ba0,0x00000000fec00000)
7 from space 10240K, 0% used [0x00000000ff600000,0x00000000ff600000,0x0000000100000000)
8 to space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
9 ParOldGen total 102400K, used 0K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
10 object space 102400K, 0% used [0x00000000f3800000,0x00000000f3800000,0x00000000f9c00000)
11 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
12 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

从第5行可以看出,年轻代总共可用空间为 92160K(90M),已经使用了 68062K(66.4M)。代码中创建了两个30M的byte数组,为何会占用66.4M呢?多出来的这部分对象可以认为是对象数组本身额外需要占用的内存空间以及程序运行时所创建的一些额外的对象,就称为未知对象吧。

从第6行之后可以看出,Eden 使用了 83%,From Survivor、To Survivor、老年代使用率均为 0%。可以确认对象首先是分配到 Eden 区的。

2、Eden 区满了触发 Minior GC

使用如下jvm参数:

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:+UseParallelGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下代码,第2、3、4行将产生60M的垃圾对象,第6行再分配时,eden 区空间就不够分配了,此时就会触发一次 YoungGC:

1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 30];
3 b1 = new byte[_1M * 30];
4 b1 = null;
5
6 byte[] b2 = new byte[_1M * 30];
7 }

看GC日志可以发现触发了一次 Young GC:

 1 2020-09-26T00:14:16.832+0800: 0.194: [GC (Allocation Failure) [PSYoungGen: 66424K->815K(92160K)] 66424K->823K(194560K), 0.0010813 secs] [Times: user=0.08 sys=0.08, real=0.00 secs]
2 Heap
3 PSYoungGen total 92160K, used 33993K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
4 eden space 81920K, 40% used [0x00000000f9c00000,0x00000000fbc66800,0x00000000fec00000)
5 from space 10240K, 7% used [0x00000000fec00000,0x00000000feccbca0,0x00000000ff600000)
6 to space 10240K, 0% used [0x00000000ff600000,0x00000000ff600000,0x0000000100000000)
7 ParOldGen total 102400K, used 8K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
8 object space 102400K, 0% used [0x00000000f3800000,0x00000000f3802000,0x00000000f9c00000)
9 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
10 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

第1行可以看出,由于内存分配失败触发了一次YoungGC,回收前内存占用 66424K,回收后只有 815K 了。整个堆回收前占用 66424K,回收后存活 823K。

第3行可以看出,程序结束后,新生代使用了 33993K,其中就包括最后一个 b2 对象。

第5行可以看出,Young GC后,存活的  815K 对象进入了 from survivor 区,占用 7% 的空间。

从上面的分析可以确认 eden 区快满了,无法给新生对象分配内存时,将触发一次 Young GC,并把存活的对象复制到一个 survivor 区中。

3、大对象将直接进入老年代

要控制大对象的阀值可以通过 -XX:PretenureSizeThreshold 参数设置,但是它只对 Serial 和 ParNew 回收器生效,对 Parallel Scavenge 不生效,所以这里我们使用 ParNew + CMS 的回收器组合,并设置大对象阀值为4M:

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=40M -XX:+UseConcMarkSweepGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下的代码,直接创建一个 40M 的对象

1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 40];
3 }

查看GC日志:

 1 Java HotSpot(TM) 64-Bit Server VM (25.191-b12) for windows-amd64 JRE (1.8.0_191-b12), built on Oct  6 2018 09:29:03 by "java_re" with MS VC++ 10.0 (VS2010)
2 Memory: 4k page, physical 33408872k(22977952k free), swap 35506024k(21515696k free)
3 CommandLine flags: -XX:InitialHeapSize=209715200 -XX:MaxHeapSize=209715200 -XX:MaxNewSize=104857600 -XX:NewSize=104857600 -XX:OldPLABSize=16 -XX:PretenureSizeThreshold=41943040 -XX:+PrintGC -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:SurvivorRatio=8 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseConcMarkSweepGC -XX:-UseLargePagesIndividualAllocation -XX:+UseParNewGC
4 Heap
5 par new generation total 92160K, used 6622K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
6 eden space 81920K, 8% used [0x00000000f3800000, 0x00000000f3e77b80, 0x00000000f8800000)
7 from space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
8 to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
9 concurrent mark-sweep generation total 102400K, used 40960K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
10 Metaspace used 3046K, capacity 4556K, committed 4864K, reserved 1056768K
11 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

第3、5、9行可以看出, -XX:+UseConcMarkSweepGC 参数默认启用的是 ParNew + CMS 的回收器。

第5、6行可以看出,eden 区还是会有6M左右的未知对象。

第9行可以看出,CMS 负责的老年代内存大小为 102400K(100M),使用了 40960K(40M),就是代码中创建的 b1 对象。

因此可以确认,超过 -XX:PretenureSizeThreshold 参数设置的大对象将直接进入老年代。

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

对象诞生在eden区中,eden区满了之后,就会触发YoungGC,将eden区存活的对象复制到survivor中,此时对象的GC年龄设为1岁。对象每熬过一次GC,GC年龄就增加1岁,当它超过一定阀值的时候就会被晋升到老年代。GC年龄的阀值可以通过参数 -XX:MaxTenuringThreshold 设置,默认为 15。

设置如下JVM参数:eden 区80M,survivor 各占10M,GC年龄阀值为2。

-Xms200M -Xmx200M -Xmn100M -XX:MaxTenuringThreshold=2 -XX:PretenureSizeThreshold=40M -XX:+UseConcMarkSweepGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

首先运行如下代码,第4、5、6行将在eden区产生70M的垃圾对象,第8行再创建一个35M的对象时,eden区空间不足,将触发第一次YoungGC:

 1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 2]; // b1 为长期存活对象 占 2M
3
4 byte[] b2 = new byte[_1M * 35];
5 b2 = new byte[_1M * 35];
6 b2 = null;
7
8 byte[] b3 = new byte[_1M * 35];
9 //b3 = new byte[_1M * 35];
10 //b3 = null;
11 //
12 //byte[] b4 = new byte[_1M * 35];
13 //b4 = new byte[_1M * 35];
14 //b4 = null;
15 //
16 //byte[] bx = new byte[_1M * 2];
17 //byte[] b5 = new byte[_1M * 35];
18 }

查看GC日志:

1 2020-09-25T23:47:20.648+0800: 0.198: [GC (Allocation Failure) 2020-09-25T23:47:20.648+0800: 0.198: [ParNew: 78712K->2769K(92160K), 0.0013440 secs] 78712K->2769K(194560K), 0.0014923 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 Heap
3 par new generation total 92160K, used 41067K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
4 eden space 81920K, 46% used [0x00000000f3800000, 0x00000000f5d66800, 0x00000000f8800000)
5 from space 10240K, 27% used [0x00000000f9200000, 0x00000000f94b4600, 0x00000000f9c00000)
6 to space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
7 concurrent mark-sweep generation total 102400K, used 0K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
8 Metaspace used 3047K, capacity 4556K, committed 4864K, reserved 1056768K
9 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

可以看出,第一次YoungGC后还存活2769K的对象,然后复制到 from survivor 区,占 27% 的空间大小,包含2M的b1对象+700K左右的未知对象。此时 b1 对象GC年龄为1。

再运行如下代码,同理,运行到第12行时,将触发第二次 YoungGC:

 1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 2]; // b1 为长期存活对象 占 2M
3
4 byte[] b2 = new byte[_1M * 35];
5 b2 = new byte[_1M * 35];
6 b2 = null;
7
8 byte[] b3 = new byte[_1M * 35];
9 b3 = new byte[_1M * 35];
10 b3 = null;
11
12 byte[] b4 = new byte[_1M * 35];
13 //b4 = new byte[_1M * 35];
14 //b4 = null;
15 //
16 //byte[] bx = new byte[_1M * 2];
17 //byte[] b5 = new byte[_1M * 35];
18 }

查看GC日志:

 1 2020-09-25T23:53:57.325+0800: 0.196: [GC (Allocation Failure) 2020-09-25T23:53:57.325+0800: 0.196: [ParNew: 78712K->2770K(92160K), 0.0014935 secs] 78712K->2770K(194560K), 0.0016180 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 2020-09-25T23:53:57.331+0800: 0.201: [GC (Allocation Failure) 2020-09-25T23:53:57.331+0800: 0.201: [ParNew: 77693K->2888K(92160K), 0.0013393 secs] 77693K->2888K(194560K), 0.0013890 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
3 Heap
4 par new generation total 92160K, used 40367K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
5 eden space 81920K, 45% used [0x00000000f3800000, 0x00000000f5c99b30, 0x00000000f8800000)
6 from space 10240K, 28% used [0x00000000f8800000, 0x00000000f8ad2130, 0x00000000f9200000)
7 to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
8 concurrent mark-sweep generation total 102400K, used 0K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
9 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
10 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

可以看出,第二次YoungGC后,还存活2888K对象,此时复制到另一块 survivor 区,占 28% 的内存,包含2M的b1对象+700K左右的未知对象。此时 b2 对象GC年龄加1,为2。

再运行如下代码,第17行将触发第三次YoungGC:

 1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 2]; // b1 为长期存活对象 占 2M
3
4 byte[] b2 = new byte[_1M * 35];
5 b2 = new byte[_1M * 35];
6 b2 = null;
7
8 byte[] b3 = new byte[_1M * 35];
9 b3 = new byte[_1M * 35];
10 b3 = null;
11
12 byte[] b4 = new byte[_1M * 35];
13 b4 = new byte[_1M * 35];
14 b4 = null;
15
16 byte[] bx = new byte[_1M * 2];
17 byte[] b5 = new byte[_1M * 35];
18 }

查看GC日志:

 1 2020-09-26T00:00:39.242+0800: 0.188: [GC (Allocation Failure) 2020-09-26T00:00:39.243+0800: 0.188: [ParNew: 78712K->2749K(92160K), 0.0012472 secs] 78712K->2749K(194560K), 0.0013625 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 2020-09-26T00:00:39.247+0800: 0.193: [GC (Allocation Failure) 2020-09-26T00:00:39.247+0800: 0.193: [ParNew: 77672K->2867K(92160K), 0.0013000 secs] 77672K->2867K(194560K), 0.0013396 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
3 2020-09-26T00:00:39.252+0800: 0.197: [GC (Allocation Failure) 2020-09-26T00:00:39.252+0800: 0.197: [ParNew: 78732K->2048K(92160K), 0.0031018 secs] 78732K->4716K(194560K), 0.0031488 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
4 Heap
5 par new generation total 92160K, used 38707K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
6 eden space 81920K, 44% used [0x00000000f3800000, 0x00000000f5bcce50, 0x00000000f8800000)
7 from space 10240K, 20% used [0x00000000f9200000, 0x00000000f9400010, 0x00000000f9c00000)
8 to space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
9 concurrent mark-sweep generation total 102400K, used 2668K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
10 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
11 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

可以看出,第三次YoungGC后,年轻代还存活 2048K(2M),其实就是 bx 这个对象,bx 被复制到 from survivor 区,from survivor 占用刚好20%(2M)。而此时老年代使用了 2668K,就是2M的 b1 对象+600K左右的未知对象。

所以可以判断出,第三次YoungGC时,在要复制 eden区+from survivor 区的存活对象时,发现 survivor 区存活对象的GC年龄已经超过设置的阀值了,这时就会将超过阀值的对象复制到老年代。

5、动态对象年龄判断

动态对象年龄判断是指,在复制前,如果 survivior 区域内年龄1+年龄2+年龄3+...+年龄n的对象总和大于survivor区的50%时,年龄n及以上的对象就会进入老年代,不一定要达到15岁。

设置如下JVM参数:

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:PretenureSizeThreshold=40M -XX:+UseConcMarkSweepGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下代码:

 1 public static void main(String[] args) {
2 byte[] x1 = new byte[_1M * 3];
3
4 byte[] b1 = new byte[_1M * 30];
5 b1 = new byte[_1M * 30];
6 b1 = null;
7
8 byte[] b2 = new byte[_1M * 30]; // 触发一次GC
9
10 byte[] x2 = new byte[_1M * 2];
11
12 b2 = new byte[_1M * 30];
13 b2 = null;
14
15 byte[] b3 = new byte[_1M * 30]; // 触发一次GC
16
17 //byte[] x3 = new byte[_1M];
18 //
19 //b3 = new byte[_1M * 30];
20 //b3 = null;
21 //
22 //byte[] b4 = new byte[_1M * 30]; // 触发一次GC
23 }

查看GC日志:

 1 2020-09-26T00:50:51.099+0800: 0.211: [GC (Allocation Failure) 2020-09-26T00:50:51.099+0800: 0.211: [ParNew: 69496K->3787K(92160K), 0.0020708 secs] 69496K->3787K(194560K), 0.0021864 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 2020-09-26T00:50:51.104+0800: 0.217: [GC (Allocation Failure) 2020-09-26T00:50:51.104+0800: 0.217: [ParNew: 70513K->6007K(92160K), 0.0030657 secs] 70513K->6007K(194560K), 0.0031105 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
3 Heap
4 par new generation total 92160K, used 38366K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
5 eden space 81920K, 39% used [0x00000000f3800000, 0x00000000f5799b30, 0x00000000f8800000)
6 from space 10240K, 58% used [0x00000000f8800000, 0x00000000f8dddf18, 0x00000000f9200000)
7 to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
8 concurrent mark-sweep generation total 102400K, used 0K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
9 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
10 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

结合代码和GC日志可以分析出,代码运行到第15行之后,触发了两次GC,这时 x1 的GC年龄为2,x2的GC年龄为1。from survivor 占了 58%了,从这里也可以看出是在复制前来判断动态年龄规则的。

再运行如下代码:

 1 public static void main(String[] args) {
2 byte[] x1 = new byte[_1M * 3];
3
4 byte[] b1 = new byte[_1M * 30];
5 b1 = new byte[_1M * 30];
6 b1 = null;
7
8 byte[] b2 = new byte[_1M * 30]; // 触发一次GC
9
10 byte[] x2 = new byte[_1M * 2];
11
12 b2 = new byte[_1M * 30];
13 b2 = null;
14
15 byte[] b3 = new byte[_1M * 30]; // 触发一次GC
16
17 byte[] x3 = new byte[_1M];
18
19 b3 = new byte[_1M * 30];
20 b3 = null;
21
22 byte[] b4 = new byte[_1M * 30]; // 触发一次GC
23 }

查看GC日志:

 1 2020-09-26T00:57:03.279+0800: 0.197: [GC (Allocation Failure) 2020-09-26T00:57:03.279+0800: 0.197: [ParNew: 69496K->3785K(92160K), 0.0020626 secs] 69496K->3785K(194560K), 0.0021906 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 2020-09-26T00:57:03.285+0800: 0.203: [GC (Allocation Failure) 2020-09-26T00:57:03.285+0800: 0.203: [ParNew: 70511K->5980K(92160K), 0.0028174 secs] 70511K->5980K(194560K), 0.0028673 secs] [Times: user=0.16 sys=0.00, real=0.00 secs]
3 2020-09-26T00:57:03.290+0800: 0.208: [GC (Allocation Failure) 2020-09-26T00:57:03.290+0800: 0.208: [ParNew: 70832K->3072K(92160K), 0.0031929 secs] 70832K->6764K(194560K), 0.0032401 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
4 Heap
5 par new generation total 92160K, used 34611K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
6 eden space 81920K, 38% used [0x00000000f3800000, 0x00000000f56cce50, 0x00000000f8800000)
7 from space 10240K, 30% used [0x00000000f9200000, 0x00000000f9500020, 0x00000000f9c00000)
8 to space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
9 concurrent mark-sweep generation total 102400K, used 3692K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
10 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
11 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

结合代码和GC日志可以分析出,代码运行到第22行时,触发了第三次GC,第三次GC复制时,survivor 中年龄为1的 x2 对象(2M)+年龄为2的 x1 对象(3M)+ 年龄为2的未知对象 已经超过 survivor 的50%了,此时就触发动态年龄判定规则,将年龄为2以上的对象晋升到老年代。

从第7行看出,survivor 区还有30%(3M)的对象,就是回收后还存活的 x2(2M)+ x3(1M)。

从第9行看出,老年代使用了 3692K(3.6M),说明 x1(3M)+ 未知对象(500K左右)通过动态年龄判断晋升到老年代了。

6、无法放入Survivor区直接进入老年代

YoungGC时,如果eden区+ from survivor 区存活的对象无法放到 to survivor 区了,这个时候会直接将部分对象放入到老年代。

使用如下jvm参数:

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=40M -XX:+UseConcMarkSweepGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下代码:

 1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 3];
3 byte[] b2 = new byte[_1M * 8];
4
5 byte[] b3 = new byte[_1M * 30];
6 b3 = new byte[_1M * 30];
7 b3 = null;
8
9 byte[] b4 = new byte[_1M * 30]; // 触发GC
10 }

查看GC日志:

1 2020-09-26T01:20:03.727+0800: 0.186: [GC (Allocation Failure) 2020-09-26T01:20:03.727+0800: 0.186: [ParNew: 77688K->3799K(92160K), 0.0059624 secs] 77688K->11993K(194560K), 0.0060861 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
2 Heap
3 par new generation total 92160K, used 36977K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
4 eden space 81920K, 40% used [0x00000000f3800000, 0x00000000f5866800, 0x00000000f8800000)
5 from space 10240K, 37% used [0x00000000f9200000, 0x00000000f95b5e00, 0x00000000f9c00000)
6 to space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
7 concurrent mark-sweep generation total 102400K, used 8194K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
8 Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
9 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

结合代码和GC日志可以看出,YoungGC后,存活的对象无法复制到一个 survivor 区中了,因此有部分对象直接晋升到老年代了。from survivor 区占了 37%(3.7M),可以认为是 b1对象(3M)+ 700K左右未知对象;老年代使用了 8194K(8M),就是b2对象(8M)。

需要注意的是,并不是把全部存活的对象晋升到老年代,而是把部分对象晋升到老年代,部分复制到 survivor 区中。

7、老年代空间分配担保原则

如果YougGC时新生代有大量对象存活下来,而 survivor 区放不下了,这时必须转移到老年代中,但这时发现老年代也放不下这些对象了,那怎么处理呢?其实JVM有一个老年代空间分配担保机制来保证对象能够进入老年代。

在执行每次 YoungGC 之前,JVM会先检查老年代最大可用连续空间是否大于新生代所有对象的总大小。因为在极端情况下,可能新生代 YoungGC 后,所有对象都存活下来了,而 survivor 区又放不下,那可能所有对象都要进入老年代了。这个时候如果老年代的可用连续空间是大于新生代所有对象的总大小的,那就可以放心进行 YoungGC。但如果老年代的内存大小是小于新生代对象总大小的,那就有可能老年代空间不够放入新生代所有存活对象,这个时候JVM就会先检查 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许,就会判断老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次YoungGC,尽快这次YoungGC是有风险的。如果小于,或者 -XX:HandlePromotionFailure 参数不允许担保失败,这时就会进行一次 Full GC。

在允许担保失败并尝试进行YoungGC后,可能会出现三种情况:

  • ① YoungGC后,存活对象小于survivor大小,此时存活对象进入survivor区中
  • ② YoungGC后,存活对象大于survivor大小,但是小于老年大可用空间大小,此时直接进入老年代。
  • ③ YoungGC后,存活对象大于survivor大小,也大于老年大可用空间大小,老年代也放不下这些对象了,此时就会发生“Handle Promotion Failure”,就触发了 Full GC。如果 Full GC后,老年代还是没有足够的空间,此时就会发生OOM内存溢出了。

通过下图来了解空间分配担保原则:

JVM性能调优(2) —— 垃圾回收器和回收策略

分配担保规则在JDK7之后有些变化,不再判断 -XX:HandlePromotionFailure 参数。YoungGC发生时,只要老年代的连续空间大于新生代对象总大小,或者大于历次晋升的平均大小,就可以进行 YoungGC,否则就进行 FullGC。

下面来结合GC日志实际观察下,设置如下jvm参数:老年代100M,eden区80M,survivor区10M,大对象阀值为35M。

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=35M -XX:+UseConcMarkSweepGC -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下代码:

 1 public static void main(String[] args) {
2 byte[] b1 = new byte[_1M * 35];
3 byte[] b2 = new byte[_1M * 35];
4
5 byte[] b3 = new byte[_1M * 30];
6 b3 = new byte[_1M * 30];
7 b3 = null;
8
9 byte[] b4 = new byte[_1M * 30]; // 触发GC
10 }

查看GC日志:

 1 2020-09-26T02:53:17.908+0800: 0.210: [GC (Allocation Failure) 2020-09-26T02:53:17.909+0800: 0.210: [ParNew: 66424K->707K(92160K), 0.0008820 secs] 138104K->72387K(194560K), 0.0010026 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2 2020-09-26T02:53:17.911+0800: 0.213: [GC (CMS Initial Mark) [1 CMS-initial-mark: 71680K(102400K)] 103107K(194560K), 0.0002821 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
3 2020-09-26T02:53:17.912+0800: 0.213: [CMS-concurrent-mark-start]
4 2020-09-26T02:53:17.912+0800: 0.213: [CMS-concurrent-mark: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
5 2020-09-26T02:53:17.912+0800: 0.213: [CMS-concurrent-preclean-start]
6 2020-09-26T02:53:17.912+0800: 0.213: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
7 2020-09-26T02:53:17.912+0800: 0.213: [CMS-concurrent-abortable-preclean-start]
8 Heap
9 par new generation total 92160K, used 33885K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
10 eden space 81920K, 40% used [0x00000000f3800000, 0x00000000f5866800, 0x00000000f8800000)
11 from space 10240K, 6% used [0x00000000f9200000, 0x00000000f92b0f48, 0x00000000f9c00000)
12 to space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
13 concurrent mark-sweep generation total 102400K, used 71680K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
14 Metaspace used 3047K, capacity 4556K, committed 4864K, reserved 1056768K
15 class space used 322K, capacity 392K, committed 512K, reserved 1048576K

b1、b2 两个对象超过大对象阀值,将直接进入老年代,因此可以认为历次进入老年大对象的平均大小为35M。此时老年代还剩余30M。

代码第5、6、7行将产生60M垃圾对象,到第9行时 eden 区不足,这时判断老年代剩余空间(30M)是否大于新生代所有对象大小(60M),明显是否;再判断老年代大小(30M)是否大于历次晋升对象的平均大小(35M),也是否。

因此这时就触发了 Full GC,GC日志中第1行发生了一次YoungGC,第2~7行是CMS的OldGC。

8、CMS触发OldGC

CMS回收器有个参数 -XX:CMSInitiatingOccupancyFraction 来控制当老年代内存占用超过这个比例后,就触发CMS回收。因为CMS要预留一些空间保证在回收期间,可以让对象进入老年代。

设置如下jvm参数:当老年代超过80%时,触发CMS回收,CMS GC线程每个2秒检查一次是否回收。

-Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=15M -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSWaitDuration=2000 
-XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log

运行如下代码:

 1 public static void main(String[] args) {
2 byte[] b3 = new byte[_1M * 30];
3 b3 = new byte[_1M * 30];
4 b3 = new byte[_1M * 20];
5
6 try {
7 Thread.sleep(3000);
8 } catch (InterruptedException e) {
9 e.printStackTrace();
10 }
11 }

查看GC日志:

2020-09-26T04:13:52.245+0800: 2.083: [GC (CMS Initial Mark) [1 CMS-initial-mark: 81920K(102400K)] 86904K(194560K), 0.0006366 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-09-26T04:13:52.245+0800: 2.084: [CMS-concurrent-mark-start]
2020-09-26T04:13:52.245+0800: 2.084: [CMS-concurrent-mark: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-09-26T04:13:52.245+0800: 2.084: [CMS-concurrent-preclean-start]
2020-09-26T04:13:52.246+0800: 2.084: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2020-09-26T04:13:52.246+0800: 2.084: [CMS-concurrent-abortable-preclean-start]
Heap
par new generation total 92160K, used 6622K [0x00000000f3800000, 0x00000000f9c00000, 0x00000000f9c00000)
eden space 81920K, 8% used [0x00000000f3800000, 0x00000000f3e77b80, 0x00000000f8800000)
from space 10240K, 0% used [0x00000000f8800000, 0x00000000f8800000, 0x00000000f9200000)
to space 10240K, 0% used [0x00000000f9200000, 0x00000000f9200000, 0x00000000f9c00000)
concurrent mark-sweep generation total 102400K, used 81920K [0x00000000f9c00000, 0x0000000100000000, 0x0000000100000000)
Metaspace used 3048K, capacity 4556K, committed 4864K, reserved 1056768K
class space used 322K, capacity 392K, committed 512K, reserved 1048576K

可以看到,老年代超过80%后,触发了一次CMS老年代回收,注意并不是Full GC,只是老年代回收。

还需注意的是,并不是超过80%就立即触发CMS回收,CMS自己有个间隔时间,通过 -XX:CMSWaitDuration 参数设置,其默认值为2000毫秒,从这里也可以看出,程序运行2秒后才触发了CMS的回收。

9、总结

1)内存参数设置

JVM性能调优(2) —— 垃圾回收器和回收策略

2)垃圾回收触发时机

JVM性能调优(2) —— 垃圾回收器和回收策略

参考

本文是学习、参考了如下书籍和课程,再通过自己的总结和实践总结而来。如果想了解更多深入的细节,建议阅读原著。

《深入理解Java虚拟机:JVM高级特性与最佳实践 第三版》

《极客时间:Java性能调优实战》

从 0 开始带你成为JVM实战高手