分析常见的CMS GC问题

1,580 阅读23分钟

本文通过详细分析ParNew + CMS收集器如何按照分代算法进行内存区域的分配,然后由实现的过程中不断发现问题->优化的思路,找到可能触发不必要GC的原因。 因为文章已经过长,所以有一些概念不进行展开叙述,有疑问可以留言哦~

1. GC 基础

1.1 分代收集理论

分代收集理论基于三条假说:

  • 弱分代假说:绝大部分对象都是朝生夕死的 -> 建立新生代;
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡 -> 建立老年代,以及新生代晋升老年代机制;
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数 -> 记忆集。

因此基于分代理论实现的垃圾收集器会对JVM进程所在的内存空间的Heap区域进行新生代与老年代的划分,并且基于新生代对象与老年代对象的特性,使用了不同的内存回收算法,并因此对内存区域进行了进一步的划分;

1.2 基础概念:

1.2.1 GC分类与触发时机

  • Young/Minor GC:发生在新生代内存区域的垃圾回收行为;一般在新生代内存不够继续分配给新生对象时,会触发Minor GC

  • Major/Old GC:发生在老年代内存区域的垃圾回收行为;目前只有在CMS回收器中有单独回收老年代的行为,默认在老年代已用空间达到%92或内存碎片化导致新生代无法正常晋升时触发回收行为;

  • Mixed GC:发生在新生代和部分老年代的垃圾回收行为;目前只有在G1回收器中会出现;

  • Full GC:与前面三种部分收集行为不同,Full GC会收集整个Java堆以及方法区;一般在方法区满、新生代担保机制失败等情况会触发Full GC,这种情况下文会着重分析。

  • 安全点(进入GC的时机):安全点一般选在具有需要长时间执行特征的代码处,如循环调用、异常跳转、方法调用等;因为垃圾收集器并不是在代码指令流的任意位置都可以停顿下来进行垃圾收集,而是在垃圾收集即将发生时让所有的线程跑到最近的安全点,然后停顿下来等待垃圾回收开始;HotSpot通过主动中断的方式,当垃圾收集器将要进行回收时,其会设置一个标记位,每当线程运行到安全点时,会主动轮询这个标志,如果标志位被设置,那么就会在当前安全点处挂起。

1.2.2 对象的分配策略

  • 新生对象优先在新生代中进行分配,因此TLAB区域处于新生代中;
  • 如果新生对象的大小超过-XX:PretenureSizeThreshold规定的大小,则会在老年代上进行分配;
  • 新生代对象存活年龄超过-XX:MaxTenuringThreshold规定的年龄,则会由新生代移动到老年代中;其中对象的年龄信息存储在对象头的MarkWord中。

1.3 GC的一般步骤

目前主流的垃圾收集器采用可达性分析算法,因此垃圾回收的一般步骤为:

  1. 收集GCRoots,一般包括:类静态对象引用、方法区常量引用的对象,虚拟机栈以及Native栈中的对象引用;如果是部分收集行为,还需要加上记忆集中跨代引用的对象
  2. 标记存活对象,从GCRoots出发,遍历对象引用图,标记与GCRoots可连通的节点为存活对象;一般可以通过三色标记(三色抽象) 进行标记;
  3. 垃圾对象回收,通过触发的GC类型,对不同区域的对象进行GC回收;
  4. 重置与收集算法相关的数据结构,准备下一轮垃圾回收。

垃圾收集器都存在上述四个步骤,但是会存在相异的实现;不同的垃圾收集器会采用了诸如用户线程与GC线程并发多个GC线程并行等相异的策略,但共同目标都是减少垃圾回收导致Stop The Wrold的时间;

1.3.1 标记存活对象过程

SerialParNew等垃圾收集器会简单粗暴的等所有线程进行安全点后STW;而CMSG1等垃圾收集器采取了并发标记的策略,但是在并发标记的过程中,对象间的引用关系也一直在发生改变:比如可能原本与GCRoots存在间接引用的节点在并发标记过程中断连了,而这会产生并发的问题,可以对使用的三色标记算法进行分析;

三色标记

三色标记,即通过不同的颜色标记处于不同标记状态的对象;

  • 黑色:表示对象已经被垃圾收集器访问过,并且这个对象的所有引用都被扫描过;
  • 白色:表示对象尚未被垃圾收集器访问过;在开始并发标记时,显然目标区域中所有对象都是白色;
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象的所有引用尚未都被扫描过;

image.png

并且基于三色标记的特性对垃圾收集器的效率进行了优化:如果正在遍历的灰色对象引用的对象为黑色,因为黑色节点的所有引用都已经被扫描过,所以无需对黑色节点进行扫描;也是防止递归标记时进入死循环。

但是这个优化带来了两个问题:

  • 在并发标记的过程中,一个已经被标记为黑色的节点,其被一个新的对象(白色) 引用了,但是因为收集器不会再遍历黑色节点,所以会使得该白色节点被漏标记为存活对象,导致了垃圾收集器回收了本应该存活的对象;
  • 在并发标记的过程中,正在扫描一个已经被标记为灰色的节点,其与一个原本在引用链上、但是还未被扫描的对象(白色) 间的引用被切断了,并且该节点不存在与其他灰色节点的直接或间接引用都被切断,此时该对象节点便不会被遍历到;此时并无影响,但是如果该对象又与一个黑色节点建立了引用,那么仍然会导致错误的垃圾回收。

Wilson证明了,通过破坏上述两个问题产生的条件之一便不会产生回收了本应该存活对象的问题;而解决的方法分为增量更新原始快照

  • 增量更新:破坏第一个条件-当黑色对象插入了新的白色对象的引用关系时,就将这个新的插入引用记录下来,等并发扫描结束后,在最终标记阶段将这些记录过的引用记录关系中的黑色对象为根,重新扫描标记一次;CMS采用了该策略,并且最终标记阶段需要STW

  • 原始快照:破坏第二个条件-当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,等并发扫描结束后,在最终标记阶段将这些引用关系中的灰色对象为根,重新扫描一次;G1Shenandoah则是用原始快照来实现。

1.3.2 垃圾对象回收过程

垃圾收集器如果使用分代回收算法,即标记-清除标记-复制标记-整理,算法共性是都需要预先进行标记,这个在步骤2时已经做好;并且垃圾收集器会按照其使用的回收算法进行堆内存的规划,具体的规划方式会在下面讲新生代与老年代的回收策略时详细介绍;

1.4 新生代收集策略

因为新生代大部分对象朝生夕死的特性,而标记-复制算法的回收成本与新生代GC后存活对象的数量正向关;并且使用标记-复制算法清理内存区域后,不存在空间碎片,因此可以使用指针碰撞的方式来对新对象进行内存分配;因此采用标记-复制算法进行Heap新生代内存回收;

一开始Fenichel提出了半区复制的算法,即将新生代内存划分为大小相同的两块,每次只使用其中一块,当这块内存使用完后,触发Minor GC,将这一块还存活的对象复制到另一块内存中。

1.4.1 Appel式回收

半区复制使得新生代可用内存缩减了一半,造成了比较大的空间浪费;但是因为据IBM研究,新生代98%的对象熬不过第一轮GC回收,因而显然不需要对空间进行折半划分;

所以Andrew Appel半区复制进行了优化:将新生代内存划分为一块较大的Eden和两块较小的Survivor;每次分配内存只使用Eden和一块Survivor,当发生垃圾回收时会将这两块区域存活的对象复制到另一块Survivor中,并将用于分配内存的两块区域清空;如下图Young Generation所示:

image.png

SerialParNew等新生代垃圾收集器便是基于Appel回收算法进一步细分了新生代的内存布局;默认Eden与Survivor的内存比例为8:1,并且可以通过设置VM选项-XX:SurvivorRatio进行自定义的配置。

1.4.2 Appel式回收的代价-分配担保的代价

但是Appel会存在Minor GC后剩余对象的数量>10%,剩下的一个Survivor无法完成复制的情况,因此需要引入-分配担保机制

满足分配担保机制的两种情况:

  • 老年代的空闲连续内存 > Young GC 前新生代已分配内存,此时垃圾收集器可以安全的进行Young GC
  • 上一个条件不满足时,如果老年代空闲连续内存 > 多次Young GC后新生代存活对象占用内存的平均值,垃圾收集器可以尝试的进行Young GC,如果回收过程中发现老年代内存不够,垃圾收集器则只能触发Full GC进行全局垃圾回收,此时的代价就变得很高昂。

因此一般线上如果频繁因为担保机制触发Full GC时,可以尝试通过-XX:SurvivorRatio选项调大Survivor的比例,降低Full GC的频率以提高性能;实例在下面的实践章节可见。

1.4.3 动态年龄判定

垃圾收集器的设计者也注意到了上述可能导致Full GC的问题,因此他们提出了动态(晋升)年龄判定 的算法:新生代晋升到老年代的年龄不再由-XX:MaxTenuringThreshold独自决定;如果在TO Survivor2空间中某个相同年龄所有对象大小的总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象就可以直接进入老年代。

这使得占用Survivor区的大部分相同年龄对象可以提前移动到老年代中,以减缓Survivor区因为-XX:MaxTenuringThreshold值过大而使得大量对象堆积在Survivor区,导致频繁触发分配担保机制的问题

1.4.4 动态年龄判定的代价

应对策略很直观,分配担保失败带来的代价很高昂-Full GC,那么就减少触发这个机制~ 但是使用动态年龄判定又会带来新的问题:当JVM刚刚启动时,程序生成的大部分对象都是在新生代分配,并且年龄都为0,少数为1,因此基于上述机制,虚拟机会应用 >=0则晋升老年代的策略,而这可能会导致大量的新生代对象直接复制进入老年代,而对象移动的成本是比较高的,涉及到新空间的分配,对象的传输,程序对象值引用地址的替换

  • 新空间的分配:因为操作系统一般分配给进程的都是虚拟内存,一直等到进程真正使用时才会实际的进行内存分配;
  • 程序对象值引用地址的替换:Java对象是值引用,因此诸如Object object = new Object()这类语句中,object是一个内存空间地址的值;因此对象移动时,虚拟机需要进行对象引用地址无感知的替换;替换的策略在不同的垃圾收集器中并不相同,ZGC使用染色指针Shenandoah中使用Brooks指针、其他垃圾收集器以使用读屏障为主。

因此大批量的对象进入老年代需要巨大的代价,从而导致项目启动时出现GC长暂停的现象,目前没有很好的可以绕过这个机制的方法,可以通过观察程序启动时对象占用空间的大小来合理设置Survivor区域的大小,或者通过灰度发布等代码上线措施让流量慢慢进入实例。

不过,还需要考虑项目是否可以接收项目初起动后出现一两次GC长暂停,一般修改jvm参数为最后的兜底策略,轻易不要修改,也切记区分过早优化与提前发现问题之间细微的差距~

至此,新生代涉及到的一些机制在宏观上便讲完了。

1.5 老年代的收集策略

因为按照对象分配策略,对象进入到老年代存在两种可能:

  • 大于-XX:PretenureSizeThreshold规定大小的对象会直接分配到老年代;
  • 新生代通过动态年龄判断、分配担保等机制从新生代晋升到老年代。

因此老年代的布局也分为TenuredHumongous,后者则用于存储大对象。

image.png

目前使用分代收集算法的垃圾收集器,只有CMS(Concurrent Mark Sweep)是支持单独对老年代进行回收的;存在两种触发时机

  • 老年代内存空间占用率达到92%,可以通过设置-XX:+UseCMSInitiatingOccupancyOnly使得-XX:CMSInitiatingOccupancyFraction参数生效,并进行阈值的更改;
  • 在每次Young GC完成后,CMS后台轮询线程concurrentMarkSweepThread会判断:新生代之前的Young GC失败过,或者依据每次新生代晋升总对象大小的平均值估计下一次Young GC可能失败,此时后台轮询线程会触发Major GC

CMS GC的阶段为:初始标记(STW)、并发标记、最终标记(STW)、并发清除(与上文探讨的GC一般步骤无异);

1.5.1 最终标记阶段(Final Remark)

最终标记前半段:CMS采用上文提到的增量更新的方式做并发期间改变的对象引用图进行最终遍历,此时与并发标记阶段的流程相同;

但是CMS最终标记的后半段会对卡表(CMS记忆集)进行遍历RefrenceQueue队列中的对象进行清理,队列中的对象是代码中通过不同的Refrence实现类包裹对象时进入RefrenceQueue队列的;

  • RefrenceQueue 对象清理:除了对于WeakRefrence引用的对象直接清理,在内存异常抛出前对SoftRefrence引用的对象进行清理等;与GC相关却又容易被忽视的是FinalRefrence,JVM在类加载时对所有实现了返回为void的非空finalize方法的对象进行标记(注意Object类中的finalize是一个空方法);接着在并发标记阶段中,如果一个被JVM标记的对象被垃圾收集器标记,则会将该对象放入Finalizer类的静态队列中,即该对象被Finalizer引用,此时便不会被垃圾回收,直到FinalizerThread这个优先级不是很高的线程获得时间片依次对对象进行出队列(此时该对象失去引用)、执行对象的finalize方法后,如果没有让该对象重新获得引用,则在下一次垃圾回收时会被回收;
  • RefrenceQueue对象清理可能导致内存泄漏:比较常见的SocksSocketImpl类的父类就实现了finalize方法,为了防止代码中没有手动关闭socket造成内存泄漏; 如果当前socket已经使用完,此时该没有被关闭的socket对象因为不存在引用关系被加入Finalizer类的静态队列,但是并不知道FinalizerThread何时获取到时间片对这些对象进行处理,如果长时间得不到处理便会造成大量遗留socket对象晋升老年代并导致Major GC;目前的解决方法最好是代码中显式关闭~实践章节会讲如何通过虚拟机工具分析出导致内存泄漏的对象。

1.5.2 并发清除阶段

CMS使用并发清除的策略,并发与清除都会带来一定程度的问题:

  • 并发:而不是像Serial OldParallel Old那样在进行垃圾回收的时候STW,即用户线程与GC线程是并发的,所以当CMS按照上一个步骤标记的对象进行垃圾回收时,用户线程还是一直在产生垃圾对象,而这些对象只能等到下一次垃圾回收时再进行处理,而这些垃圾被称为浮动垃圾

  • 并发问题:也是因此,采用并发清除策略的老年代与新生代用满后再Young GC的策略不同,必须留给用户线程一定的内存量,提前触发Major GC;因此如果-XX:CMSInitiatingOccupancyFraction参数对于当前应用设置的过大,会导致老年代无法容纳并发期间进入老年代的所有对象,从而触发Full GC,常导致抛出Concurrent Mode Failure的问题(PS:这种因为内存不够晋升触发Full GC的情况,有可能是因为内存碎片过多,因此下面会讲Full GC前的一些手段);

  • 清除:采用标记-清除算法的缺点在于会产生比较多的内存碎片,并且需要额外的空闲链表数据结构进行可用空间的维护;而内存碎片多会导致即使老年代剩余空间仍然充足,但却无法容纳较大的对象,从而导致Full GC;当前CMS也注意到这个问题,添加了-XX:CMSFullGCsBeforeCompaction参数,这个参数的作用是在CMS执行过若干次垃圾回收后(由该参数决定),下一次进入Full GC前会先进行碎片整理,该参数默认为0,即每次要触发Full GC前,都会先进行垃圾碎片的整理,如果整理后还是不够分配才会触发Full GC

  • 清除问题:上述方法虽然一定程度上缓解了内存碎片引起的对象内存分配问题,但是又会导致CMS无法进行并发清除,因为碎片整理会导致对象移动,而对象的移动需要进行程序对象值引用地址的替换,显然用户线程与GC线程可能同时操作同一个对象,因此并发是不安全的,这个时候只能STW,会导致相对长时间的GC暂停

至此,老年代涉及的一些机制在宏观上便讲完了~

1.6 非堆-方法区的内存回收策略

方法区存储在非堆中,因为Jdk8后将字符串常量池从方法区移到了堆中,因此方法区的存储压力相对之前降低不少;方法区则主要存储已经被虚拟机加载的类信息、静态变量、常量以及编译器编译后的代码等。

依据分代算法实现的垃圾收集器一般会将方法区的内存区域当作永久代perm,以将其纳入垃圾收集器的管辖范围,并通过-XX:MaxPermSize参数决定方法区大小的上限;但是因为类型信息卸载条件比较苛刻,并且方法区的默认大小并不够大,如果此时存在大量的反射、动态代理、CGlib等字节码框架导致不停在内存中加载新的Class信息,使得方法区被逐渐堆满,会导致Full GC甚至OOM

因此jdk8后则移除了永久代的概念,改用进程本地内存中实现的元空间(Meta-Space)进行实现:

image.png

1.6.1 方法区OOM解决方案

如上所述,方法区OOM大概率是由不同动态加载类的机制不停的加载类信息导致的;因此当方法区发生OOM时,可以通过jcmd <PID> GC.class_stats|awk '{print$13}'|sed 's/\(.*\)\.\(.*\)/\1/g'|sort |uniq -c|sort -nrk1方法确认是哪几个包下的Class随时间增加比较多,再跟进包的源代码进行查看。

当然也可以通过dump快照,通过JProfiler或者MAT等工具进行分析;如果在包层面看不出明显的问题,可以设置-XX:+TraceClassLoading-XX:+TraceClassUnLoading参数对类加载和卸载信息进行详细观察。

1.7 堆外内存回收策略

堆外内存最直观的解释为:属于JVM进程的内存空间,但是不属于虚拟机运行时数据区的一部分,不被虚拟机直接管理;主要分为直接内存JNI(Java Native Interface) Memory两个部分:

  • 直接内存(Direct Memory):通过UnSafe.allocateMemoryByteBuffer.allocateDirect等navtie方法申请的堆外内存,并通过存储在JVM堆中的DirectByteBuffer对象作为对这块内存的引用进行操作;如常见的NIO类通过native方法申请的堆外内存,因为避免了在JVM堆和native堆中来回复制数据(零拷贝),所以在一些场景下可以提高效率;可以通过jcmd pid VM.native_memory detail命令查看直接内存的内存分布~

  • JNI Memory:代码中有通过JNI调用malloc、mmap、brkNative Code申请的堆外内存;

image.png

JVM通过-XX:MaxDirectMemorySize参数控制虚拟机可申请的堆外内存的最大值,Java8中如果未配置该参数,默认和-Xmx相等。

对于直接内存: 虚拟机在堆中创建DirectByteBuffer时,会为该对象关联一个PhantomReference对象-Cleaner,通过虚引用的机制,在该DirectByteBuffer对象被回收时,Cleaner会被触发,间接调用unsafe.freeMemory(long address)方法进行堆外内存的释放。

对于JNI内存: 因为在堆中没有直接的引用,所以只能依赖调用线程自行free

1.7.1 内存泄漏导致OOM问题

因为堆外内存不被垃圾收集器处理,而直接被操作系统管理;而操作系统在当进程空间不够使用时,在磁盘上开启swap区,同时会出现GC时间飙升,用户线程一直被阻塞的现象;

堆外内存导致OOM最主要的原因是程序申请了堆外内存却没有告之操作系统要进行释放。

  • 主动申请直接内存未释放:其实JVM设计者已经考虑到这个问题,在每次创建DirectByteBuffer时,会调用Bits.reserveMemory方法进行当前直接内存的使用数量,当判断直接内存不够使用时,会通过System.gc()触发Full GC强制Young区与Old区所有无效的DirectByteBuffer引用得到释放;但是如果JVM参数设置了-XX:+DisableExplicitGC,则会导致System.gc()变为空方法,导致无法手动触发GC回收堆外内存,最终导致异常OutOfMemoryError: Direct buffer memory

  • 解决方案:生产环境设置-XX:+DisableExplicitGC禁止手动GC,防止因为手动前台GC导致STW使得服务不可用;但是JVM还可以设置-XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses来进行后台手动并发GC,这样就可以使得手动GC不会STW导致服务不可用。

  • 通过JNI申请堆外内存未释放解决方案:涉及到JNI堆外内存的情况难以处理,因为没有直观的可以分析的工具;可以尝试使用gperftools工具,当程序运行时,会动态的将malloc调用转变为其包装的libtcmalloc.so,接着再进行内存分配情况的统计;不过因为JNI不仅提供了malloc进行内存申请,还包括了mmap/brk等,因此这种手段只能做一个简单的分析;

1.7.2 JNI方法安全点导致GC延迟

因为JNI方法中可能存在引用JVM堆中对象的情况,因此如果此时发生GC则会导致对象移动,使得JNI方法GC后可能拿到的是对象过期的内存地址导致运行错误;并且JNI Memory不受垃圾收集器管理,因此写屏障Brook指针、染色指针等措施对其不生效;

因此JNI方法进入安全点的时机即为运行完当前方法,并且此时所有线程无法运行新的JNI方法;

带来的影响

  • 如果此时是Young区不够分配,并且此时存在JNI方法未退出,则因为无法进行Young GC,会直接进入老年代;
  • 如果老年代仍然不够分配,则只能等待JNI方法退出,此时线程阻塞。

2. 实践

2.1 堆外内存OOM

2.1.1 不设置-XX:+DisableExplicitGC

public class NonHeapOOM {
    // -Xmx64m -Xms64m -Xmn32m -XX:+UseConcMarkSweepGC  
    // -XX:+PrintGCDetails -XX:MaxDirectMemorySize=10m
    public static void main(String[] args) throws InterruptedException {
        int i = 1;
        while (true){
            System.out.println("第"+(i++)+"次");
            Thread.sleep(1000l);
            // 每次分配1m的堆外内存
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024*1024);
        }
    }
}

设置堆外内存为10m,每次循环分配1m,则在第11次分配堆外内存时,会出现System.gc()触发内存回收;体现在GC上则会进行周期性的GC,如下图所示:

image.png

2.1.2 设置-XX:+DisableExplicitGC

-Xmx64m -Xms64m -Xmn32m -XX:+UseConcMarkSweepGC -XX:+PrintGCDetail-XX:MaxDirectMemorySize=10m -XX:+DisableExplicitGC

观察jProfiler监控,可以发现内存大小小于<-Xmn32m,因此垃圾收集器并不会触发Young GC,导致不会释放堆外内存,第11次内存分配时,直接报出OOM:

image.png

3. 参考与总结:

实践篇感觉自己造例子着实没有什么参考价值,因此将我写这篇博客参考到的所有GC问题的实例文章链接贴在下文,大家可以参考阅读~

《深入理解Java虚拟机》

关于GC原理和性能调优实践,看这一篇就够了!

你们要的线上GC问题案例来啦

Java中9种常见的CMS GC问题分析与解决

GC调优实际案例 - 年轻代 GC 长暂停的分析与解决

一次线上JVM调优实践,FullGC40次/天到10天一次的优化过程

记录一次Metaspace OOM的问题

Netty堆外内存泄露排查盛宴

Spring Boot引起的“堆外内存泄漏”排查及经验总结

一次堆外OOM问题排查

Linux内存分配小结--malloc、brk、mmap

JVM堆外内存利用改进: DirectBuffer详解