目前使用最多的是 CMS 和 G1 收集器,二者都有分代的概念,主要内存结构如下:
评判 GC 的两个核心指标:
目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,衡量指标需要结合一下应用服务的 SLA,主要如下两点来判断:
备注:除了这两个指标之外还有 Footprint(资源量大小测量)、反应速度等指标,互联网这种实时系统追求低延迟,而很多嵌入式系统则追求 Footprint。
拿到 GC 日志,我们就可以简单分析 GC 情况了,通过一些工具,我们可以比较直观地看到 Cause 的分布情况,如下图就是使用 gceasy 绘制的图表:
重点需要关注的几个 GC Cause:
交互类型根据对象存活时间比例图来看主要分为两种:对象存活时间、对象分配比例,如下图所示“Survival Time”表示对象存活时间,“Rate”表示对象分配比例:
当然,除了二者之外还有介于两者之间的场景,本篇文章主要讨论第一种情况。对象存活时间分布图,对我们设置 GC 参数有着非常重要的指导意义,如下图就可以简单推算分代的边界。
2. 分代 GC:部分收集操作的 GC,只对某些分代 / 分区进行回收。
3. Full GC:全量收集的 GC,对整个堆进行回收,STW 时间会比较长,一旦发生,影响较大,也可以叫做 Major GC,参见“场景七:内存碎片 & 收集器退化”。
4. MetaSpace:元空间回收引发问题,参见“场景三:MetaSpace 区 OOM”。
5. Direct Memory:直接内存(也可以称作为堆外内存)回收引发问题,参见“场景八:堆外内存 OOM”。
6. JNI:本地 Native 方法引发问题,参见“场景九:JNI 引发的 GC 问题”。
一个问题的解决难度跟它的常见程度成反比,大部分我们都可以通过各种搜索引擎找到类似的问题,然后用同样的手段尝试去解决。当一个问题在各种网站上都找不到相似的问题时,那么可能会有两种情况,一种这不是一个问题,另一种就是遇到一个隐
藏比较深的问题,遇到这种问题可能就要深入到源码级别去调试了。以下 GC 问题场景,排查难度从上到下依次递增。
服务刚刚启动时 GC 次数较多,最大空间剩余很多但是依然发生 GC,这种情况我们可以通过观察 GC 日志或者通过监控工具来观察堆的空间变化情况即可。GC Cause一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间的大小会被调整,如下图所示:
在 JVM 的参数中 -Xms 和 -Xmx 设置的不一致,在初始化时只会初始 -Xms 大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次GC。另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRa
tio 和 -XX:MaxHeapFreeRatio 来控制扩容和缩容的比例,调节这两个值也可以控制伸缩的时机,整个伸缩的模型理解可以看这个图,当 committed 的空间大小超过了低水位 / 高水 位的大小,capacity 也会随之调整:
观察 CMS GC 触发时间点 Old/MetaSpace 区的 committed 占比是不是一个固定的值,或者像上文提到的观察总的内存使用率也可以。
尽量将成对出现的空间大小配置参数设置成固定的,如 -Xms 和 -Xmx,-XX:-MaxNewSize 和 -XX:NewSize,-XX:MetaSpaceSize 和 -XX:MaxMetaSpaceSize 等。
一般来说,我们需要保证 Java 虚拟机的堆是稳定的,确保 -Xms 和 -Xmx 设置的是一个值(即初始值和最大值一致),获得一个稳定的堆,同理在 MetaSpace 区也有类似的问题。不过在不追求停顿时间的情况下震荡的空间也是有利的,可以动态地伸缩
以节省空间,例如作为富客户端的 Java 应用。这个问题虽然初级,但是发生的概率还真不小,尤其是在一些规范不太健全的情
况下。
除了扩容缩容会触发 CMS GC 之外,还有 Old 区达到回收阈值、MetaSpace 空间不足、Young 区晋升失败、大对象担保失败等几种触发条件,如果这些情况都没有发生却触发了 GC ?这种情况有可能是代码中手动调用了 System.gc 方法,此时可以找到 GC 日志中的 GC Cause 确认下。那么这种 GC 到底有没有问题,翻看网上的一些资料,有人说可以添加 -XX:+DisableExplicitGC 参数来避免这种 GC,也有人说不能加这个参数,加了就会影响 Native Memory 的回收。先说结论,建议保留 System.gc,那为什么要保留?我们一起来分析下。
通过 System.gc 在 Hotspot 中 的 源 码, 可 以 发 现 增 加 -XX:+DisableExplicitGC 参数后,这个方法变成了一个空方法,如果没有加的话便会调用 Universe::heap()::collect 方法,继续跟进到这个方法中,发现 System.gc会引发一次 STW 的 Full GC,对整个堆做收集。
此处补充一个知识点,CMS GC 共分为 Background(后台) 和 Foreground (前台)两种模式,前者就是我们常规理解中的并发收集,可以不影响正常的业务线程运行,但 Foreground Collector 却有很大的差异,他会进行一次压缩式 GC。此压缩式 GC使用的是跟 Serial Old GC 一样的 Lisp2 算法,其使用 Mark-Compact 来做 Full GC,一般称之为 MSC(Mark-Sweep-Compact),它收集的范围是 Java 堆的 Young 区和 Old 区以及 MetaSpace。 compact 的代价是巨大的,那么使用 Foreground Collector 时将会带来非常长的 STW。如果在应用程序中 System.gc 被频繁调用,那就非常危险了。
如果禁用掉的话就会带来另外一个内存泄漏问题,此时就需要说一下 DirectByteBuffer,它有着零拷贝等特点,被 Netty 等各种 NIO 框架使用,会使用到堆外内存。堆内存由 JVM 自己管理,堆外内存必须要手动释放,DirectByteBuffer 没有Finalizer,它的 Native Memory 的清理工作是通过 sun.misc.Cleaner 自动完成的,是一种基于 PhantomReference 的清理工具,比普通的 Finalizer 轻量些。 为 DirectByteBuffer 分配空间过程中会显式调用 System.gc ,希望通过 Full GC来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 Native Memory。
HotSpot VM 只会在 Old GC 的时候才会对 Old 中的对象做 Reference Processing,而在 Young GC 时只会对 Young 里的对象做 Reference Processing。Young 中的 DirectByteBuffer 对象会在 Young GC 时被处理,也就是说,做 CMS GC 的话会对 Old 做 Reference Processing,进而能触发 Cleaner 对已死的DirectByteBuffer 对象做清理工作。但如果很长一段时间里没做过 GC 或者只做了Young GC 的话则不会在 Old 触发 Cleaner 的工作,那么就可能让本来已经死亡,但已经晋升到 Old 的 DirectByteBuffer 关联的 Native Memory 得不到及时释放。这几个实现特征使得依赖于 System.gc 触发 GC 来保证 DirectByteMemory 的清理工作能及时完成。如果打开了 -XX:+DisableExplicitGC,清理工作就可能得不到及时完成,于是就有发生 Direct Memory 的 OOM。
通过上面的分析看到,无论是保留还是去掉都会有一定的风险点,不过目前互联网中的 RPC 通信会大量使用 NIO,所以笔者在这里建议保留。此外 JVM 还提供了-XX:+ExplicitGCInvokesConcurrent 和 -XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 参数来将 System.gc 的触发类型从 Foreground改为 Background,同时 Background 也会做 Reference Processing,这样的话就能大幅降低了 STW 开销,同时也不会发生 NIO Direct Memory OOM。
不止 CMS,在 G1 或 ZGC 中开启 ExplicitGCInvokesConcurrent 模式,都会采用高性能的并发收集方式进行收集,不过还是建议在代码规范方面也要做好约束,规范好 System.gc 的使用。 P.S. HotSpot 对 System.gc 有特别处理,最主要的地方体现在一次 System.gc是否与普通 GC 一样会触发 GC 的统计 / 阈值数据的更新,HotSpot 里的许多 GC 算法都带有自适应的功能,会根据先前收集的效率来决定接下来的 GC 中使用的参数,但 System.gc 默认不更新这些统计数据,避免用户强行 GC 对这些自适应功能 的干扰(可以参考 -XX:+UseAdaptiveSizePolicyWithSystemGC 参数,默认是false)。
JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决。
在讨论为什么会 OOM 之前,我们先来看一下这个区里面会存什么数据,Java7 之前字符串常量池被放到了 Perm 区,所有被 intern 的 String 都会被存在这里,由于 String.intern 是不受控的,所以 -XX:MaxPermSize 的值也不太好设置,经常会出现 java.lang.OutOfMemoryError: PermGen space 异常,所以在 Java7之后常量池等字面量(Literal)、类静态变量(Class Static)、符号引用(Symbols Reference)等几项被移到 Heap 中。而 Java8 之后 PermGen 也被移除,取而代之的是 MetaSpace。
在最底层,JVM 通过 mmap 接口向操作系统申请内存映射,每次申请 2MB 空间,这里是虚拟内存映射,不是真的就消耗了主存的 2MB,只有之后在使用的时候才会真的消耗内存。申请的这些内存放到一个链表中 VirtualSpaceList,作为其中的一个
Node。在上层,MetaSpace 主要由 Klass Metaspace 和 NoKlass Metaspace 两大部分组成。
MetaSpace 的对象为什么无法释放,我们看下面两点:
由 场 景 一 可 知, 为 了 避 免 弹 性 伸 缩 带 来 的 额 外 GC 消 耗, 我 们 会 将 -XX-:MetaSpaceSize 和 -XX:MaxMetaSpaceSize 两个值设置为固定的,但是这样也会导致在空间不够的时候无法扩容,然后频繁地触发 GC,最终 OOM。所以关键原因就是 ClassLoader 不停地在内存中 load 了新的 Class ,一般这种问题都发生在动态类加载等情况上。
了解大概什么原因后,如何定位和解决就很简单了,可以 dump 快照之后通过JProfiler 或 MAT 观察 Classes 的 Histogram(直方图)即可,或者直接通过命令即可定位,jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以 定位了。 如果无法从整体的角度定位,可以添加 -XX:+TraceClassLoading 和 -XX-:+TraceClassUnLoading 参数观察详细的类加载和卸载信息。
原理理解比较复杂,但定位和解决问题会比较简单,经常会出问题的几个点有 Orika的 classMap、JSON 的 ASMSerializer、Groovy 动态加载类等,基本都集中在反射、Javasisit 字节码增强、CGLIB 动态代理、OSGi 自定义类加载器等的技术点上。另外就是及时给 MetaSpace 区的使用率加一个监控,如果指标有波动提前发现并解决问题。
这种场景主要发生在分代的收集器上面,专业的术语称为“Premature Promotion”。90% 的对象朝生夕死,只有在 Young 区经历过几次 GC 的洗礼后才会晋升到 Old区,每经历一次 GC 对象的 GC Age 就会增长 1,最大通过 -XX:MaxTenuringThreshold 来控制。 过早晋升一般不会直接影响 GC,总会伴随着浮动垃圾、大对象担保失败等问题,但
这些问题不是立刻发生的,我们可以观察以下几种现象来判断是否发生了过早晋升。
GC 日 志 中 出 现“Desired survivor size 107347968 bytes, new threshold 1(max 6)”等信息,说明此时经历过一次 GC 就会放到 Old 区。
比如说 Old 区触发的回收阈值是 80%,经历过一次 GC 之后下降到了 10%,这就说明 Old 区的 70% 的对象存活时间其实很短,如下图所示,Old 区大小每次 GC 后从 2.1G 回收到 300M,也就是说回收掉了 1.8G 的垃圾,只有 300M 的活跃对象。整个 Heap 目前是 4G,活跃对象只占了不到十分之一。
主要的原因有以下两点:
同时无法 GC 掉对象还会带来另外一个问题,引发动态年龄计算:JVM 通过 -XX:-MaxTenuringThreshold 参数来控制晋升年龄,每经过一次 GC,年龄就会加一,达到最大年龄就可以进入 Old 区,最大值为 15(因为 JVM 中使用 4 个比特来表示对象的年龄)。
设定固定的 MaxTenuringThreshold 值作为晋升条件:
相同应用在不同时间的表现不同,特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面问题,所以 Hotspot 会使用动态计算的方式来调整晋升的阈值。动态年龄触发后导致更多的对象进入了 Old 区,造成资源浪费。
知道问题原因后我们就有解决的方向,如果是 Young/Eden 区过小,我们可以在总的 Heap 内存不变的情况下适当增大 Young 区,具体怎么增加?一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。
如果是分配速率过大:
过早晋升问题一般不会特别明显,但日积月累之后可能会爆发一波收集器退化之类的问题,所以我们还是要提前避免掉的,可以看看自己系统里面是否有这些现象,如果比较匹配的话,可以尝试优化一下。一行代码优化的 ROI 还是很高的。
如果在观察 Old 区前后比例变化的过程中,发现可以回收的比例非常小,如从 80% 只回收到了 60%,说明我们大部分对象都是存活的,Old 区的空间可以适当调大些。
Old 区频繁的做 CMS GC,但是每次耗时不是特别长,整体最大 STW 也在可接受范围内,但由于 GC 太频繁导致吞吐下降比较多。
这种情况比较常见,基本都是一次 Young GC 完成后,负责处理 CMS GC 的一个后台线程 concurrentMarkSweepThread 会不断地轮询,使用 shouldConcurrentCollect() 方法做一次检测,判断是否达到了回收条件。如果达到条件,使用 collect_in_background() 启动一次 Background 模式 GC。轮询的判断是使用 sleepBeforeNextCycle() 方法,间隔周期为 -XX:CMSWaitDuration 决定,默认为 2s。
触发 GC,分为以下几种情况:
我们这里还是拿最常见的达到回收比例这个场景来说,与过早晋升不同的是这些对象确实存活了一段时间,Survival Time 超过了 TP9999 时间,但是又达不到长期存活,如各种数据库、网络链接,带有失效时间的缓存等。处理这种常规内存泄漏问题基本是一个思路,主要步骤如下:
经过整个流程下来基本就能定位问题了,不过在优化的过程中记得使用控制变量的方法来优化,防止一些会加剧问题的改动被掩盖。
CMS GC 单次 STW 最大超过 1000ms,不会频繁发生,某些场景下会引起“雪崩效应”,这种场景非常危险,我们应该尽量避免
出现。
CMS 在回收的过程中,STW 的阶段主要是 初始标记和重新标记这两个阶段,也是导致 CMS Old GC 最多的原因,另外有些情况就是在 STW 前等待 线程到达 SafePoint 也会导致时间过长,但这种情况较少。
初始标记:从 GC Root 出发标记 Old 中的对象,处理完成后借助 BitMap处理下 Young 区对 Old 区的引用,整个过程基本都比较快,很少会有较大的停顿。
重新标记的开始阶段与初始标记的流程相同,但是后续多了 Card Table 遍历、Reference 实例的清理并将其加入到 Reference 维护的 pend_list 中,如果要收集元数据信息,还要清理 SystemDictionary、CodeCache、SymbolTable、StringTable 等组件中不再使用的资源。
知道了两个 STW 过程执行流程,我们分析解决就比较简单了,由于大部分问题都出在 Final Remark 过程,这里我们也拿这个场景来举例,主要步骤:
知道 GC 耗时的根因就比较好处理了,这种问题不会大面积同时爆发,不过有很多时候单台 STW 的时间会比较长,如果业务影响比较大,及时摘掉流量,具体后续优化策略如下:
正常情况进行的 Background CMS GC,出现问题基本都集中在 Reference 和 Class 等元数据处理上,在 Reference 类的问题处理方面,不管是 FinalReference,还是 SoftReference、WeakReference 核心的手段就是找准时机 dump 快照,然后用内存分析工具来分析。Class 处理方面目前除了关闭类卸载开关,没有太好的方法。在 G1 中同样有 Reference 的问题,可以观察日志中的 Ref Proc,处理方法与CMS 类似。
并发的 CMS GC 算法,退化为 Foreground 单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。其中 CMS 收集器退化后单线程串行 GC 算法有两种:
CMS 发生收集器退化主要有以下几种情况:
为什么 CMS GC 正在执行还会导致收集器退化呢?主要是由于 CMS 无法处理浮动垃圾(Floating Garbage)引起的。CMS 的并发清理阶段,Mutator 还在运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里,无法在本次 GC 被清除掉,这些就是浮动垃圾,除此之外在 Remark 之前那些断开引用脱离了读写屏障控制的对象也算浮动垃圾。所以 Old 区回收的阈值不能太高,否则预留的内存空间很可能不够,从而导致 Concurrent Mode Failure 发生。
正常情况下触发并发模式的 CMS GC,停顿非常短,对业务影响很小,但 CMS GC 退化后,影响会非常大,建议发现一次后就彻底根治。只要能定位到内存碎片、浮动垃圾、增量收集相关等具体产生原因,还是比较好解决的,关于内存碎片这块,如果 -XX:CMSFullGCsBeforeCompaction 的值不好选取的话,可以使用 -XX:PrintFLSStatistics 来观察内存碎片率情况,然后再设置具体的值。 最后就是在编码的时候也要避免需要连续地址空间的大对象的产生,如过长的字符串,用于存放附件、序列化或反序列化的 byte 数组等,还有就是过早晋升问题尽量在爆发问题前就避免掉。
内存使用率不断上升,甚至开始使用 SWAP 内存,同时可能出现 GC 时间飙升,线程被 Block 等现象,通过 top 命令发现 Java 进程的 RES 甚至超过了 -Xmx 的大小。出现这些现象时,基本可以确定是出现了堆外内存泄漏。
JVM 的堆外内存泄漏,主要有两种的原因:
哪种原因造成的堆外内存泄漏?
首先,我们需要确定是哪种原因导致的堆外内存泄漏。这里可以使用 NMT(Native MemoryTracking)进行分析。在项目中添加 -XX:NativeMemoryTracking=detail JVM 参数后重启项目(需要注意的是,打开 NMT 会带来 5%~10% 的性能损耗)。使用命令 jcmd pid VM.native_memory detail 查看内存分布。重点观察 total 中的 committed,因为 jcmd 命令显示的内存包含堆内内存、Code 区域、通过 Unsafe.allocateMemory 和 DirectByteBuffer 申请的内存,但是不包含其他 Native Code(C 代码)申请的堆外内存。
如果 total 中的 committed 和 top 中的 RES 相差不大,则应为主动申请的堆外内存未释放造成的,如果相差较大,则基本可以确定是 JNI 调用造成的。
JVM 使用 -XX:MaxDirectMemorySize=size 参数来控制可申请的堆外内存的最大值。在 Java8 中,如果未配置该参数,默认和 -Xmx 相等。
NIO 和 Netty 都会取 -XX:MaxDirectMemorySize 配置的值,来限制申请的堆外内存的大小。NIO 和 Netty 中还有一个计数器字段,用来计算当前已申请的堆外内存大小,NIO 中是 java.nio.Bits#totalCapacity、Netty 中 io.netty.util. internal.PlatformDependent#DIRECT_MEMORY_COUNTER。当申请堆外内存时,NIO 和 Netty 会比较计数器字段和最大值的大小,如果计数器的值超过了最大值的限制,会抛出 OOM 的异常。
我们可以检查代码中是如何使用堆外内存的,NIO 或者是 Netty,通过反射,获取到对应组件中的计数器字段,并在项目中对该字段的数值进行打点,即可准确地监控到这部分堆外内存的使用情况。
此时,可以通过 Debug 的方式确定使用堆外内存的地方是否正确执行了释放内存的代码。另外,需要检查 JVM 的参数是否有 -XX:+DisableExplicitGC 选项,如果有就去掉,因为该参数会使 System.gc 失效。
这种情况排查起来比较困难,我们可以通过 Google perftools + Btrace 等工具,帮助我们分析出问题的代码在哪里
首先可以使用 NMT + jcmd 分析泄漏的堆外内存是哪里申请,确定原因后,使用不同的手段,进行原因定位。
在 GC 日志中,出现 GC Cause 为 GCLocker Initiated GC。
2020-09-23T16:49:09.727+0800: 504426.742: [GC (GCLocker Initiated GC)
504426.742: [ParNew (promotion failed): 209716K->6042K(1887488K),
0.0843330 secs] 1449487K->1347626K(3984640K), 0.0848963 secs] [Times:
user=0.19 sys=0.00, real=0.09 secs]
2020-09-23T16:49:09.812+0800: 504426.827: [Full GC (GCLocker Initiated
GC) 504426.827: [CMS: 1341583K->419699K(2097152K), 1.8482275 secs]
1347626K->419699K(3984640K), [Metaspace: 297780K->297780K(1329152K)],
1.8490564 secs] [Times: user=1.62 sys=0.20, real=1.85 secs]
JNI(Java Native Interface)意为 Java 本地调用,它允许 Java 代码和其他语言写的 Native 代码进行交互。
JNI 如果需要获取 JVM 中的 String 或者数组,有两种方式:
由于 Native 代码直接使用了 JVM 堆区的指针,如果这时发生 GC,就会导致数据错误。因此,在发生此类 JNI 调用时,禁止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。
JNI 产生的 GC 问题较难排查,需要谨慎使用。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。