一、Java内存区域
Java虚拟机在运行程序时会把其自动管理的内存划分为以上几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。
方法区(Method Area):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据。在jdk1.7及其之前,方法区是堆的一个“逻辑部分”(一片连续的堆空间),但为了与堆做区分,方法区还有个名字叫“非堆”,也有人用“永久代”(HotSpot对方法区的实现方法)来表示方法区。
从jdk1.7已经开始准备“去永久代”的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中,(常量池除字符串常量池还有class常量池等),这里只是把字符串常量池移到堆内存中;在jdk1.8中,方法区已经不存在,原方法区中存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。根据网上的资料结合自己的理解对jdk1.3~1.6、jdk1.7、jdk1.8中方法区的变迁画了张图如下(如有不合理的地方希望读者指出):
去永久代的原因有:
(1)字符串存在永久代中,容易出现性能问题和内存溢出。
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
(3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程,如下(图有误,应该为栈桢):
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。
元空间
上面说到,jdk1.8 中,已经不存在永久代(方法区),替代它的一块空间叫做 “ 元空间 ”,和永久代类似,都是 JVM 规范对方法区的实现,但是元空间并不在虚拟机中,而是使用本地内存,元空间的大小仅受本地内存限制,但可以通过 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 来指定元空间的大小。
二、垃圾回收
判断对象是否可回收的方法
引用计数法
引用计数法的实现很简单,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。大部分情况下这个方法是可以发挥作用的,但是在存在循环引用的情况下,引用计数法就无能为力了。在 Java 程序中,a 和 b 是可以被回收的,因为 JVM 并没有使用引用计数法判定对象是否可回收,而是采用了可达性分析法。
可达性分析法
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集 (GC Root Set),从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链” (Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,则说明此对象不再被使用,也就可以被回收了。要进行可达性分析就需要先枚举根节点 (GC Roots),在枚举根节点过程中,为防止对象的引用关系发生变化,需要暂停所有用户线程 (垃圾收集之外的线程),这种暂停全部用户线程的行为被称为 (Stop The World)。
即使是不可达对象,也并非一定会被回收,如果该对象同时满足以下几个条件,那么它仍有“逃生”的可能:
- 该对象有重写的 finalize()方法 (Object 类中的方法);
- finalize()方法中将其自身链接到了引用链上;
- JVM 此前没有调用过该对象的finalize()方法 (因为 JVM 在收集可回收对象时会调用且仅调用一次该对象的finalize()方法)。
在 Java 语言中,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈 (栈帧中的本地变量表) 中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,比如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI (即通常所说的Native方法) 引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常见的异常对象 (比如
NullPointExcepiton、OutOfMemoryError) 等,还有系统类加载器。 - 所有被同步锁 (synchronized关键字) 持有的对象。
- 反映Java虚拟机内部情况的 JM XBean、JVM TI 中注册的回调、本地代码缓存等。
三、垃圾收集算法介绍
标记-清除算法
标记-清除算法的思想很简单,顾名思义,该算法的过程分为标记和清除两个阶段:首先标记出所有需要回收的对象,其中标记过程就是使用可达性分析法判断对象是否属于垃圾的过程。在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。示意图如下:
标记清除算法
这个算法虽然很简单,但是有两个明显的缺点:
- 执行效率不稳定。如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
- 导致内存空间碎片化。标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作,非常影响程序运行效率。
标记-复制算法
标记-复制算法常简称复制算法,这一算法正好解决了标记-清除算法在面对大量可回收对象时执行效率低下的问题。其实现方法也很易懂:在可用内存中划分出两块大小相同的区域,每次只使用其中一块,另一块保持空闲状态,第一块用完的时候,就把存活的对象全部复制到第二块区域,然后把第一块全部清空。如下图所示:
这个算法很适合用于对象存活率低的情况,因为它只关注存活对象而无需理会可回收对象,所以 JVM 中新生代的垃圾收集正是采用的这一算法。但是其缺点也很明显,每次都要浪费一半的内存,未免太过奢侈,不过 JVM 中的新生代有更精细的内存划分,比较好地解决了这个问题,见下文。
标记-整理算法
这个算法完美解决了标记-清除算法的空间碎片化问题,其标记过程与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
这个算法虽然可以很好地解决空间碎片化问题,但是每次垃圾回收都要移动存活的对象,还要对引用这些对象的地方进行更新,对象移动的操作也需要全程暂停用户线程 (Stop The World)。
分代收集算法
与其说是算法,不如说是理论。如今大多数虚拟机的实现版本都遵循了“分代收集”的理论进行设计,这个理论可以看作是经验之谈,因为开发人员在开发过程中发现了 JVM 中存活对象的数量和它们的年龄之间有着某种规律,如下图:
JVM 中存活对象数量与年龄之间的关系
在此基础上,人们提出了以下假说:
- 绝大多数对象都是朝生夕灭的。
- 熬过越多次垃圾收集过程的对象就越难以消亡。
根据这两个假说,可以把 JVM 的堆内存大致分为新生代和老年代,新生代对象大多存活时间短,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间,所以这一区域一般采用标记-复制算法进行垃圾收集,频率比较高。而老年代则是一些难以消亡的对象,可以采用标记-清除和标记整理算法进行垃圾收集,频率可以低一些。
按照 Hotspot 虚拟机的实现,针对新生代和老年代的垃圾收集又分为不同的类型,也有不同的名词,如下:
- 部分收集 (Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:新生代收集 (Minor GC / Young GC):指目标只是新生代的垃圾收集。老年代收集 (Major GC / Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器的并发收集阶段是单独收集老年代的行为。混合收集 (Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为。
- 整堆收集 (Full GC):收集整个Java堆和方法区的垃圾收集。
人们经常会混淆 Major GC 和 Full GC,不过这也有情可原,因为这两种 GC 行为都包含了老年代的垃圾收集,而单独的老年代收集 (Major GC) 又比较少见,大多数情况下只要包含老年代收集,就会是整堆收集 (Full GC),不过还是分得清楚一点比较好哈。
四、JVM 的内存分配和垃圾收集机制
经过前面的铺垫,现在终于可以一窥 JVM 的内存分配和垃圾收集机制的真面目了。
JVM 堆内存的划分
JVM 堆内存划分,从Java 8开始不再有永久代
Java 堆是 JVM 所管理的内存中最大的一块,也是垃圾收集器的管理区域。大多数垃圾收集器都会将堆内存划分为上图所示的几个区域,整体分为新生代和老年代,比例为 1 : 2,新生代又进一步分为 Eden、From Survivor 和 To Survivor,默认比例为 8 : 1 : 1,请注意,可通过 SurvivorRatio 参数进行设置。请注意,从 JDK 8 开始,JVM 中已经不再有永久代的概念了。Java 堆上的无论哪个区域,存储的都只能是对象的实例,将Java 堆细分的目的只是为了更好地回收内存,或者更快地分配内存。
分代收集原理
(1)新生代中对象的分配与回收
大多数情况下,对象优先在新生代 Eden 区中分配,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。Eden、From Survivor 和 To Survivor 的比例为 8 : 1 : 1,之所以按这个比例是因为绝大多数对象都是朝生夕灭的,垃圾收集时 Eden 存活的对象数量不会太多,Survivor 空间小一点也足以容纳,每次新生代中可用内存空间为整个新生代容量的90% (Eden 的 80% 加上 To Survivor 的 10%),只有From Survivor 空间,即 10% 的新生代是会被“浪费”的。不会像原始的标记-复制算法那样浪费一半的内存空间。From Survivor 和 To Survivor 的空间并不是固定的,而是在 S0 和 S1 之间动态转换的,第一次 Minor GC 时会选择 S1 作为 To Survivor,并将 Eden 中存活的对象复制到其中,并将对象的年龄加1,注意新生代使用的垃圾收集算法是标记-复制算法的改良版。下面是示意图,请注意其中第一步的变色是为了醒目,虚拟机只做了标记存活对象的操作。
第一次 Minor GC 示意图
在后续的 Minor GC 中,S0 和 S1会交替转化为 From Survivor 和 To Survivor,Eden 和 From Survivor 中的存活对象会复制到 To Survivor 中,并将年龄加大 1。如下图所示:
(2)对象晋升老年代
在以下这些情况下,对象会晋升到老年代。
- 长期存活对象将进入老年代
- 对象在 Survivor 区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置,这个参数的最大值是15,因为对象年龄信息储存在对象头中,占4个比特 (bit)的内存,所能表示最大数字就是15。
3.大对象可以直接进入老年代
对于大对象,尤其是很长的字符串,或者元素数量很多的数组,如果分配在 Eden 中,会很容易过早占满 Eden 空间导致 Minor GC,而且大对象在 Eden 和两个 Survivor 之间的来回复制也还会有很大的内存复制开销。所以我们可以通过设置 -XX:PretenureSizeThreshold 的虚拟机参数让大对象直接进入老年代。
4.动态对象年龄判断
为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 -XX:MaxTenuringThreshold 中要求的年龄。
5.空间分配担保 (Handle Promotion)
当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域 (实际上大多数情况下就是老年代) 进行分配担保。在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 - XX:HandlePromotionFailure 参数的设置值是否允许担保失败 (Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者-XX: HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次 Full GC。
五、JVM 内存溢出
1、堆内存溢出
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生 OutOfMemoryError 的异常。堆内存异常示例如下:
/*** 设置最大堆最小堆:-Xms20m -Xmx20m* 运行时,不断在堆中创建OOMObject类的实例对象,且while执行结束之前,GC Roots(代码中的oomObjectList)到对象(每一个OOMObject对象)之间有可达路径,垃圾收集器就无法回收它们,最终导致内存溢出。*/public class HeapOOM {static class OOMObject {}public static void main(String[] args) {List<OOMObject> oomObjectList = new ArrayList<>();while (true) {oomObjec(new OOMObject());}}}
运行后会报异常,在堆栈信息中可以看到:
java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。
新产生的对象最初分配在新生代,新生代满后会进行一次 Minor GC,如果 Minor GC 后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行 Full GC,之后如果空间还不足以存放新对象则抛出 OutOfMemoryError 异常。
常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。
2、虚拟机栈/本地方法栈溢出
(1)StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出StackOverflowError,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常。
最常见的场景就是方法无限递归调用,如下:
/*** 设置每个线程的栈大小:-Xss256k* 运行时,不断调用doSomething()方法,main线程不断创建栈帧并入栈,导致栈的深度越来越大,最终导致栈溢出。*/public class StackSOF {private int stackLength=1;public void doSomething(){stackLength++;doSomething();}public static void main(String[] args) {StackSOF stackSOF=new StackSOF();try {();}catch (Throwable e){//注意捕获的是T("栈深度:"+);throw e;}}}
上述代码执行后抛出:
Exception in thread "Thread-0" java.lang.StackOverflowError 的异常。
(2)OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError。
我们可以这样理解,虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为 4G,系统和其他应用占用 2G,虚拟机可用的物理内存为 2G,最大堆内存为 1G,最大方法区内存为 512M,那可供栈占有的内存大约就是 512M,假如我们设置每个线程栈的大小为 1M,那虚拟机中最多可以创建 512个线程,超过 512个线程再创建就没有空间可以给栈了,就报 OutOfMemoryError 异常了。
栈上能够产生 OutOfMemoryError 的示例如下:
/*** 设置每个线程的栈大小:-Xss2m* 运行时,不断创建新的线程(且每个线程持续执行),每个线程对一个一个栈,最终没有多余的空间来为新的线程分配,导致OutOfMemoryError*/public class StackOOM {private static int threadNum = 0;public void doSomething() {try {T(100000000);} catch (InterruptedException e) {e.printStackTrace();}}public static void main(String[] args) {final StackOOM stackOOM = new StackOOM();try {while (true) {threadNum++;Thread thread = new Thread(new Runnable() {@Overridepublic void run() {();}});();}} catch (Throwable e) {Sy("目前活动线程数量:" + threadNum);throw e;}}}
上述代码运行后会报异常
在堆栈信息中可以看到java.lang.OutOfMemoryError: unable to create new native thread的信息,无法创建新的线程,说明是在扩展栈的时候产生的内存溢出异常。
总结:在线程较少的时候,某个线程请求深度过大,会报 StackOverflow 异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把 -Xss 的值设置大一些,但一般情况下是代码问题的可能性较大;
在虚拟机产生线程时,无法为该线程申请栈空间了,会报 OutOfMemoryError 异常,解决这种问题可以适当减小栈的深度,也就是把 -Xss 的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在 3000~5000 左右。
在 jdk1.5 之前 -Xss 默认是 256k,jdk1.5 之后默认是 1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作。
3、方法区溢出
前面说到,方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。
由于在 jdk1.6 之前字符串常量池是存在于方法区中的,所以基于 jdk1.6 之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和 GC Roots 之间保证有可达路径)来模拟方法区的 OutOfMemoryError 异常;但方法区还存储加载的类信息,所以基于 jdk1.7 的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出。
/*** 设置方法区最大、最小空间:-XX:PermSize=10m -XX:MaxPermSize=10m* 运行时,通过cglib不断创建JavaMethodAreaOOM的子类,方法区中类信息越来越多,最终没有可以为新的类分配的内存导致内存溢出*/public class JavaMethodAreaOOM {public static void main(final String[] args){try {while (true){Enhancer enhancer=new Enhancer();en);en(false);en(new MethodInterceptor() {@Overridepublic Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {return me(o,objects);}});en();}}catch (Throwable t){t.printStackTrace();}}}
上述代码运行后会报:
java.lang.OutOfMemoryError: PermGen space 的异常,说明是在方法区出现了内存溢出的错误。
4、本机直接内存溢出
本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域,但 Java 中用到 NIO 相关操作时(比如 ByteBuffer 的 allocteDirect 方法申请的是本机直接内存),也可能会出现内存溢出的异常。
六、常见的垃圾回收器
HotSpot虚拟机所包含的垃圾回收器
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用,虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:CMS、Serial Old、Parallel Old
- 整理收集器:G1,是JDK1.7 Update14这个版本中正式提供的商业收集器,它可以同时适用于新生代和年老代
几个概念
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。
例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
(1)Serial 收集器
Serial和Serial Old是JDK诞生之后的第一个垃圾回收器,发展历史最为悠久。
Serial的特点:单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
Stop The World(STW):JVM在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即GC停顿,会带给用户不良的体验;
Serial的适用范围:适用于客户的模式下的虚拟机。在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的。
Serial和Serial Old收集器的运行图示:
可以通过 "-XX:+UseSerialGC" 参数来显式地使用串行垃圾收集器。
(2)ParNew收集器
ParNew收集器其实是Serial收集器的多线程版本。
除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)。
ParNew的特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题
ParNew的应该场景:在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。
ParNew/Serial Old组合收集器运行示意图:
参数设置:
- "-XX:+UseConcMarkSweepGC":指定使用CMS后,会默认使用ParNew作为新生代收集器;
- "-XX:+UseParNewGC":强制指定使用ParNew;
- "-XX:ParallelGCThreads":指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同;
(3)Parallel Scavenge收集器
如果JVM没有做任何调优的情况下,默认使用的就是Parallel Scavenge和Parallel Old,简称PS+PO。
Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。
它的特点:Parallel Scavenge属于新生代收集器也是采用复制算法的收集器,又是并行多线程收集器(与ParNew收集器类似)。
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)
GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(
-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。
应用场景:
- 高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;
- 当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;
例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;
参数设置:
Parallel Scavenge收集器提供两个参数用于精确控制吞吐量:
(A)、"-XX:MaxGCPauseMillis"
控制最大垃圾收集停顿时间,大于0的毫秒数;
MaxGCPauseMillis设置得稍小,停顿时间可能会缩短,但也可能会使得吞吐量下降;
因为可能导致垃圾收集发生得更频繁;
(B)、"-XX:GCTimeRatio"
设置垃圾收集时间占总时间的比率,0<n<100的整数;
GCTimeRatio相当于设置吞吐量大小;
垃圾收集执行时间占应用程序执行时间的比例的计算方法是:
1 / (1 + n)
例如,选项-XX:GCTimeRatio=19,设置了垃圾收集时间占总时间的5%--1/(1+19);
默认值是1%--1/(1+99),即n=99;
垃圾收集所花费的时间是年轻一代和老年代收集的总时间;
如果没有满足吞吐量目标,则增加年轻代的内存大小以尽量增加用户程序运行的时间;
此外,还有一个值得关注的参数:
(C)、"-XX:+UseAdptiveSizePolicy"
开启这个参数后,就不用手工指定一些细节参数,如:
新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等;
JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs);
这是一种值得推荐的方式:
(1)、只需设置好内存数据大小(如"-Xmx"设置最大堆);
(2)、然后使用"-XX:MaxGCPauseMillis"或"-XX:GCTimeRatio"给JVM设置一个优化目标;
(3)、那些具体细节参数的调节就由JVM自适应完成;
这也是Parallel Scavenge收集器与ParNew收集器一个重要区别;
上面介绍的都是新生代收集器,接下来开始介绍老年代收集器。
(4)Serial Old 收集器
Serial Old是Serial收集器的老年代版本,它用的是标记-整理算法,用的也是单线程。
应用场景:主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
Server模式下主要的两大用途:
- 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器的后备方案,在并发收集Concurent Mode Failure时使用。
运行示意图:
(5)Parallel Old 收集器
Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本,在JDK1.6中才开始提供。
特点:针对老年代;采用"标记-整理"算法;多线程收集。
应用场景: JDK1.6及之后用来代替老年代的Serial Old收集器;特别是在Server模式,多CPU的情况下;这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;
参数设置: "-XX:+UseParallelOldGC":指定使用Parallel Old收集器;
运行示意图:
(6)CMS收集器
并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器。
特点:
- 针对老年代;
- 基于"标记-清除"算法(不进行压缩操作,产生内存碎片);
- 以获取最短回收停顿时间为目标;
- 并发收集、低停顿;
- 需要更多的内存(看后面的缺点);
CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
应用场景:
- 与用户交互较多的场景;
- 希望系统停顿时间最短,注重服务的响应速度;
- 以给用户带来较好的体验;
- 如常见WEB、B/S系统的服务器上的应用;
参数设置: "-XX:+UseConcMarkSweepGC":指定使用CMS收集器;
CMS收集器运作过程: 比前面几种收集器更复杂,可以分为4个步骤。
(A)初始标记(CMS initial mark)
仅标记一下GC Roots能直接关联到的对象(根对象)速度很快;但需要"Stop The World";
(B)并发标记(CMS concurrent Mark)
进行GC Roots Tracing的过程;刚才产生的集合中标记出存活对象;应用程序也在运行;并不能保证可以标记出所有的存活对象;
(C)重新标记(CMS remark)
为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率;
(D)并发清除(CMS concurrent sweep)
回收所有的垃圾对象;
整个过程中耗时最长的并发标记和并发清除都可以与用户线程一起工作,所以总体上说,CMS收集器的内存回收过程与用户线程一起并发执行。
CMS收集器3个明显的缺点:
1.对CPU资源非常敏感
并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。
CMS的默认收集线程数量是=(CPU数量+3) / 4;
当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
增量式并发收集器:
针对这种情况,曾出现了"增量式并发收集器"(Incremental Concurrent Mark Sweep/i-CMS);
类似使用抢占式来模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间;
但效果并不理想,JDK1.6后就官方不再提倡用户使用。
2.无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败。
(1)浮动垃圾(Floating Garbage)
在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;
这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;
也可以认为CMS所需要的空间比其他垃圾收集器大;
"
-XX:CMSInitiatingOccupancyFraction":设置CMS预留内存空间;
JDK1.5默认值为68%;
JDK1.6变为大约92%;
(2)"Concurrent Mode Failure"失败
如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;这样的代价是很大的,所以
CMSInitiatingOccupancyFraction不能设置得太大。
3.产生大量内存碎片
由于CMS基于"标记-清除"算法,清除后不进行压缩操作,产生大量不连续的内存碎片会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。
解决方法:
(1)、"-XX:+UseCMSCompactAtFullCollection"
使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程;
但合并整理过程无法并发,停顿时间会变长;
默认开启(但不会进行,结合下面的CMSFullGCsBeforeCompaction);
(2)、"-XX:+CMSFullGCsBeforeCompaction"
设置执行多少次不压缩的Full GC后,来一次压缩整理;
为减少合并整理过程的停顿时间;
默认为0,也就是说每次都执行Full GC,不会进行压缩整理;
由于空间不再连续,CMS需要使用可用"空闲列表"内存分配方式,这比简单实用"碰撞指针"分配内存消耗大;
(7)G1收集器
G1垃圾收集器全称是Garbage-First,意义为进行最有价值的垃圾回收(汉语言的优美此刻完美体现)。G1是面向服务器级的垃圾收集器,主要针对多CPU以及大容量内存的机器,具有高吞吐量以及低GC停顿时间。
G1垃圾收集器的堆划分和其他垃圾收集器不大一样,G1将java 堆划分为若干个大小相等的堆内存区域,后面称为region。JVM堆中最多可以存在2048个region,每个region的大小就是堆的内存空间除以region个数,当然可以通过配置 -XX:G1HeapRegionSize 参数对region大小进行手动指定,不过因为G1内部有非常多的堆空间优化机制,所以推荐使用默认配置,不建议修改。
G1堆内存空间示例图
G1收集器堆空间分区情况
G1除了有其他垃圾收集器包含的eden、survivor、old外,还有一个特殊的humongous区,专门用于存储大对象,以下是每个分区功能以及特点:
- Eden、Survivor:新生代空间,G1中新生代默认初始空间占比为5%,可以通过配置修改此初始值,不过因为G1内部会在新生代空间不足时,根据内部算法自动增加新生代内存空间,所以不建议修改初始值。JVM在运行过程中,会不断给新生代空间分配内存,最高不超过60%,可以通过 -XX:G1MaxNewSizePercent 参数进行调整。新生代中eden和survior的内存分配为8:1:1
- Humongous:G1大对象分配区,在G1中,大对象的判定规则为超过单个region区域内存的50%即为大对象,大对象不需要心如新生代,直接进入humongous区,避免了大对象经历多次gc,影响jvm整体gc性能,也避免了这种短期生存的大对象挤压老年代内存空间,避免full gc的提前发生。如果单个对象大小超过一个region区域内存限制,G1则会给此对象分配多个连续的region空间进行存储。full gc运行时,也会对humongous区域的对象进行gc回收
- Old:除了其分布在多个region空间的特点之外,和其他收集器老年代功能以及用法一致
G1垃圾收集过程
G1垃圾收集整体过程和CMS类似核心思想也是降低业务线程停顿时间,让业务线程和gc线程并行工作,提高用户体验,不过G1的具体实现上更为优秀(当然大内存和CPU资源是必不可少)
- 初始标记:此阶段和CMS初始标记一致,会标记GC Root根直接关联的对象,此时暂停除GC线程外的所有
- 并发标记:此阶段和CMS并发标记一致,会并发标记初始标记对象的关联对象,业务线程同时运行,并记录此时业务线程导致的对象引用更新
- 最终标记:此阶段和CMS并发标记一致,会修正并发标记阶段业务线程导致的对象引用更新,此时暂停除GC线程以外的线程
- 筛选回收:G1的回收算法使用的是复制算法,筛选回收的意思是G1会根据 其参数设置对region区域进行选择性回收,G1内部会分析每个region的回收价值以及回收所需时长,如果单个region的可回收对象大小不足15%(可配置),G1则不会回收此region区域。同时G1会根据region区域需要的回收时长以及JVM设置的GC停顿时间来合理地进行内存回收。例如用户设置的GC停顿时长为100ms,G1会根据后台维护的优先列表来计算在100ms内能回收的最大region数,回收时间过程的不会进行回收,如果region合计回收时间远小于100ms,G1也不会进行回收,而是会开辟新的region空间存放对象
G1收集器运行示意图
G1优势
- 高性能且与支持并行:在多核CPU前提下,充分使用CPU资源,做到并发且并行地进行垃圾收集,极大地减少了 stop the world的时长
- 支持分代收集:G1收集器可以直接对整个JVM 堆进行管理,不像CMS、ParNew等收集器只能对老年代或者新生代进行垃圾清理
- 空间整合:G1收集器采用的是复制收集算法,效率高于CMS的标记回收算法
- 停顿时长可设置:G1根据自带的停顿预测算法,让用户线程的停顿时间尽可能借用JVM设置的停顿时间,极大的提升了用户体验
G1垃圾收集方式
- YoungGC: 对G1 Eden区内存回收的回收方式,不过G1并不是在Eden区一满就会触发Young GC,而是会计算当前Eden区如果执行young gc需要执行的时间是否接近 预测停顿时长(通过参数 -XX:MaxGCPauseMills 进行设置),如果接近则会执行young gc,如果远小于预测停顿时长,则会继续分配region给eden空间给新对象存放
- MixedGC:此回收方式会对新生代以及部分老年代以及大对象区进行回收,老年区的回收空间大小是根据G1的内部算法,结合jvm设置的预测停顿时间,将老年区价值高的垃圾对象优先进行回收,使用的是复制算法,会将标记的有用对象复制到其他region中,再对需要清除的region进行全量清除,当无剩余region空间作为复制算法的目标空间时,就会触发full gc
- FullGC: 此回收方式非常简单粗暴,即使用单线程对内存空间进行标记清理以及压缩处理
G1收集器参数设置
- -XX:+UseG1GC:G1开启参数
- -XX:ParallelGCThreads:并发标记GC线程数
- -XX:G1HeapRegionSize:指定region分区大小,必须是2的整数次幂,最大32m,默认算法会将jvm堆空间划分为2048个region
- -XX:MaxGCPauseMillis:预测GC最大暂停时长,此参数对于G1收集器非常关键,默认200毫秒,需要根据具体业务进行设置
- -XX:G1NewSizePercent:新生代初始内存占比(默认5%,不建议修改)
- -XX:G1MaxNewSizePercent:新生代最大空间占比(默认60%,根据实际业务调整)
- -XX:TargetSurvivorRatio:survivor触发转移对象至老年代的阈值(默认50%),当survivor分区内存空间超过此阈值,会将年龄相比最大的对象移入老年区,直至survivor空间所用内存低于阈值
- -XX:MaxTenuringThreshold:新生代区最大年龄阈值,对象年龄达到此阈值后对象移入老年代
- -XX:InitiatingHeapOccupancyPercent:老年代空间占整体堆空间比例超过此阈值时,会触发mixgc,默认为45%
- -XX:G1HeapWastePercent:mixgc释放内存空间阈值,当jvm执行mixed gc后,会不断地有未空闲的region块被释放,当释放的region总内存量占堆空间比例达到此阈值后,停止mixed gc,默认5%
- -XX:G1MixedGCLiveThresholdPercent:当进行gc时,会计算当前region不可回收对象占region空间比例,如果该比例高于此阈值,则不会回收,默认85%
- -XX:G1MixedGCCountTarget:因为G1为了提高用户体验,回收过程中的筛选回收会分多次进行,此参数用于这是筛选回收拆分执行次数,默认8次
G1收集器使用建议
其实所有的垃圾回收器的优化都是大同小异,都是需要防止minor gc的频繁触发以及防止新生代的短期存活对象进入老年代,而G1收集器优化的最重要的点就是 -XX:MaxGCPauseMillis 参数,通过此参数可以设置新生代垃圾回收频率,同时可以基于对业务的评估,设置此参数,防止survivor区对象超过50%而短期存活对象被迫进入老年代的问题
(9)ZGC垃圾回收器
ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:
- GC之痛:介绍实际业务中遇到的GC痛点,并分析CMS收集器和G1收集器停顿时间瓶颈;
- ZGC原理:分析ZGC停顿时间比G1或CMS更短的本质原因,以及背后的技术原理;
- ZGC调优实践:重点分享对ZGC调优的理解,并分析若干个实际调优案例;
- 升级ZGC效果:展示在生产环境应用ZGC取得的效果。
GC之痛
很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。
部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有( 40ms + 30ms ) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。
可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。
CMS与G1停顿时间瓶颈
在介绍ZGC之前,首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:
G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
标记阶段停顿分析
- 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
清理阶段停顿分析
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
复制阶段停顿分析
- 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。
G1的Young GC和CMS的Young GC,其标记-复制全过程STW,这里不再详细阐述。
ZGC原理
全并发的ZGC
与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。
ZGC垃圾回收周期如下图所示:
ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。
ZGC关键技术
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。
着色指针
| 着色指针是一种将信息存储在指针中的技术。
ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:
其中,[0~4TB) 对应Java堆,[4TB ~ 8TB) 称为M0地址空间,[8TB ~ 12TB) 称为M1地址空间,[12TB ~ 16TB) 预留未使用,[16TB ~ 20TB) 称为Remapped空间。
当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。
与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障
| 读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障 <Load barrier> Object p = o // 无需加入屏障,因为不是从堆中读取引用 o.dosomething() // 无需加入屏障,因为不是从堆中读取引用 int i = obj.FieldB //无需加入屏障,因为不是对象引用
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC并发处理演示
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。即第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
ZGC调优实践
ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性。
调优基础知识
理解ZGC重要配置参数
以我们服务在生产环境中ZGC参数配置为例,说明各个参数的作用:
重要参数配置样例:
-Xms10G -Xmx10G -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m -XX:+UnlockExperimentalVMOptions -XX:+UseZGC -XX:ConcGCThreads=2 -XX:ParallelGCThreads=6 -XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive -Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m
-Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。
-XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize: 设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。我们的服务因为有一定特殊性,所以设置的较大,后面会详细介绍。
-XX:+
UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。
-XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。
-XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。
-XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。
-XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。
-Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
理解ZGC触发时机
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
- 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
- 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
- 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
- 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。日志中关键字是“Proactive”。
- 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
- 外部触发:代码中显式调用Sy()触发。日志中关键字是“Sy()”。
- 元数据分配触发:元数据区不足时导致,一般不需要关注。日志中关键字是“Metadata GC Threshold”。
理解ZGC日志
一次完整的GC过程,需要注意的点已在图中标出。
注意:该日志过滤了进入安全点的信息。正常情况,在一次GC过程中还穿插着进入安全点的操作。
GC日志中每一行都注明了GC过程中的信息,关键信息如下:
- Start:开始GC,并标明的GC触发的原因。上图中触发原因是自适应算法。
- Phase-Pause Mark Start:初始标记,会STW。
- Phase-Pause Mark End:再次标记,会STW。
- Phase-Pause Relocate Start:初始转移,会STW。
- Heap信息:记录了GC过程中Mark、Relocate前后的堆大小变化状况。High和Low记录了其中的最大值和最小值,我们一般关注High中Used的值,如果达到100%,在GC过程中一定存在内存分配不足的情况,需要调整GC的触发时机,更早或者更快地进行GC。
- GC信息统计:可以定时的打印垃圾收集信息,观察10秒内、10分钟内、10个小时内,从启动到现在的所有统计信息。利用这些统计信息,可以排查定位一些异常点。
日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。
理解ZGC停顿原因
我们在实战过程中共发现了6种使程序停顿的场景,分别如下:
- GC时,初始标记:日志中Pause Mark Start。
- GC时,再标记:日志中Pause Mark End。
- GC时,初始转移:日志中Pause Relocate Start。
- 内存分配阻塞:当内存不足时线程会阻塞等待GC完成,关键字是"Allocation Stall"。
- 安全点:所有线程进入到安全点后才能进行GC,ZGC定期进入安全点判断是否需要GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程挂起。
- dump线程、内存:比如jstack、jmap命令。
内存分配阻塞,系统停顿可达到秒级
案例一:秒杀活动中流量突增,出现性能毛刺
日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。
分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。
解决方法:
- 开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
- 增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。
案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺
日志信息:平均1秒GC一次,两次GC之间几乎没有间隔。
分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。
解决方法:增大-XX:ConcGCThreads,加快并发标记和回收速度。ConcGCThreads默认值是核数的1/8,8核机器,默认值是1。该参数影响系统吞吐,如果GC间隔时间大于GC周期,不建议调整该参数。
GC Roots 数量大,单次GC停顿时间长
案例三:单次GC停顿时间30ms,与预期停顿10ms左右有较大差距
日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。
分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道ClassLoader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。
解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。
案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复
日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。
分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+
PrintCodeCacheOnCompilation参数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。
解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了大量Aviator方法进入CodeCache中。
值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。
升级ZGC效果
延迟降低
| TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。
在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:
- TP999:下降12~142ms,下降幅度18%~74%。
- TP99:下降5~28ms,下降幅度10%~47%。
超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。
吞吐下降
对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。
总结
ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。
Zeus在升级JDK 11+ZGC中,通过将风险和问题分类,然后各个击破,最终顺利实现了升级目标,GC停顿也几乎不再影响系统可用性。
最后推荐大家升级ZGC,Zeus系统因为业务特点,遇到了较多问题,而风控其他团队在升级时都非常顺利。
参考文献
- ZGC官网
- 彭成寒.《新一代垃圾回收器ZGC设计与实现》. 机械工业出版社, 2019.
附录
如何使用新技术
在生产环境升级JDK 11,使用ZGC,大家最关心的可能不是效果怎么样,而是这个新版本用的人少,网上实践也少,靠不靠谱,稳不稳定。其次是升级成本会不会很大,万一不成功岂不是白白浪费时间。所以,在使用新技术前,首先要做的是评估收益、成本和风险。
评估收益
对于JDK这种世界关注的程序,大版本升级所引入的新技术一般已经在理论上经过验证。我们要做的事情就是确定当前系统的瓶颈是否是新版本JDK可解决的问题,切忌问题未诊断清楚就采取措施。评估完收益之后再评估成本和风险,收益过大或者过小,其他两项影响权重就会小很多。
以本文开头提到的案例为例,假设GC次数不变(10次/分钟),且单次GC时间从40ms降低10ms。通过计算,一分钟内有100/60000 = 0.17%的时间在进行GC,且期间所有请求仅停顿10ms,GC期间影响的请求数和因GC增加的延迟都有所减少。
评估成本
这里主要指升级所需要的人力成本。此项相对比较成熟,根据新技术的使用手册判断改动点。跟做其他项目区别不大,不再具体细说。
在我们的实践中,两周时间完成线上部署,达到安全稳定运行的状态。后续持续迭代3个月,根据业务场景对ZGC进行了更契合的优化适配。
评估风险
升级JDK的风险可以分为三类:
- 兼容性风险:Java程序JAR包依赖很多,升级JDK版本后程序是否能运行起来。例如我们的服务是从JDK 7升级到JDK 11,需要解决较多JAR包不兼容的问题。
- 功能风险:运行起来后,是否会有一些组件逻辑变更,影响现有功能的逻辑。
- 性能风险:功能如果没有问题,性能是否稳定,能稳定的在线上运行。
经过分类后,每类风险的应对转化成了常见的测试问题,不再属于未知风险。风险是指不确定的事情,如果不确定的事情都能转化成可确定的事情,意味着风险已消除。