Gc实战

2016/01/08 JVM

GC实战

理解GC日志

ParallelGC YoungGC日志

GC_ParallelGCYoungGC日志

ParallelGC FullGC日志

GC_ParallelGCFullGC日志

每个收集器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性,例如以下两段典型的GC日志:

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
100.667:[FullGC[Tenured:0K->210K(10240K),0.0 149142secs]4603K->210K(19456K),[Perm:2999K->
2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs] 

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。

GC日志开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“Full”,说明这次GC是发生了Stop-The-World的,例如下面这段新生代收集器ParNew的日志也会出现“[Full GC”(这一般是因为出现了分配担保失败之类的问题,所以才导致STW)。如果是调用System.gc()方法所触发的收集,那么在这里将显示“[Full GC(System)”。

[Full GC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]

接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的,例如上面样例所使用的Serial收集器中的新生代名为“Default New Generation”,所以显示的是“[DefNew”。如果是ParNew收集器,新生代名称就会变为“[ParNew”,意为“Parallel New Generation”。如果采用Parallel Scavenge收集器,那它配套的新生代称为“PSYoungGen”,老年代和永久代同理,名称也是由收集器决定的。

后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而在方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。

再往后,“0.0025925 secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如“[Times:user=0.01 sys=0.00,real=0.02 secs]”,这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU事件和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。CPU时间与墙钟时间的区别是,墙钟时间包括各种非运算的等待耗时,例如等待磁盘I/O、等待线程阻塞,而CPU时间不包括这些耗时,但当系统有多CPU或者多核的话,多线程操作会叠加这些CPU时间,所以读者看到user或sys时间超过real时间是完全正常的。

最佳实践

在不同的 JVM 的不垃圾回收器上,看参数默认是什么,不要轻信别人的建议,命令行示例如下:

java -XX:+PrintFlagsFinal -XX:+UseG1GC  2>&1 | grep UseAdaptiveSizePolicy

PrintCommandLineFlags:通过它,你能够查看当前所使用的垃圾回收器和一些默认的值。

# java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=127905216 -XX:MaxHeapSize=2046483456 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
openjdk version "1.8.0_41"
OpenJDK Runtime Environment (build 1.8.0_41-b04)
OpenJDK 64-Bit Server VM (build 25.40-b25, mixed mode)

G1垃圾收集器JVM参数最佳实践:

# 1.基本参数
-server                  # 服务器模式
-Xmx12g                  # 初始堆大小
-Xms12g                  # 最大堆大小
-Xss256k                 # 每个线程的栈内存大小
-XX:+UseG1GC             # 使用 G1 (Garbage First) 垃圾收集器   
-XX:MetaspaceSize=256m   # 元空间初始大小
-XX:MaxMetaspaceSize=1g  # 元空间最大大小
-XX:MaxGCPauseMillis=200 # 每次YGC / MixedGC 的最多停顿时间 (期望最长停顿时间)

# 2.必备参数
-XX:+PrintGCDetails            # 输出详细GC日志
-XX:+PrintGCDateStamps         # 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintTenuringDistribution # 打印对象分布:为了分析GC时的晋升情况和晋升导致的高暂停,看对象年龄分布日志
-XX:+PrintHeapAtGC                 # 在进行GC的前后打印出堆的信息
-XX:+PrintReferenceGC              # 打印Reference处理信息:强引用/弱引用/软引用/虚引用/finalize方法万一有问题
-XX:+PrintGCApplicationStoppedTime # 打印STW时间
-XX:+PrintGCApplicationConCurrentTime # 打印GC间隔的服务运行时长

# 3.日志分割参数
-XX:+UseGCLogFileRotation   # 开启日志文件分割
-XX:NumberOfGCLogFiles=14   # 最多分割几个文件,超过之后从头文件开始写
-XX:GCLogFileSize=32M       # 每个文件上限大小,超过就触发分割
-Xloggc:/path/to/gc-%t.log  # GC日志输出的文件路径,使用%t作为日志文件名,即gc-2021-03-29_20-41-47.log

CMS垃圾收集器JVM参数最佳实践:

# 1.基本参数
-server   # 服务器模式
-Xmx4g    # JVM最大允许分配的堆内存,按需分配
-Xms4g    # JVM初始分配的堆内存,一般和Xmx配置成一样以避免每次gc后JVM重新分配内存
-Xmn256m  # 年轻代内存大小,整个JVM内存=年轻代 + 年老代 + 持久代
-Xss512k  # 设置每个线程的堆栈大小
-XX:+DisableExplicitGC                # 忽略手动调用GC, System.gc()的调用就会变成一个空调用,完全不触发GC
-XX:+UseConcMarkSweepGC               # 使用 CMS 垃圾收集器
-XX:+CMSParallelRemarkEnabled         # 降低标记停顿
-XX:+UseCMSCompactAtFullCollection    # 在FULL GC的时候对年老代的压缩
-XX:+UseFastAccessorMethods           # 原始类型的快速优化
-XX:+UseCMSInitiatingOccupancyOnly    # 使用手动定义初始化定义开始CMS收集
-XX:LargePageSizeInBytes=128m         # 内存页的大小
-XX:CMSInitiatingOccupancyFraction=70 # 使用cms作为垃圾回收使用70%后开始CMS收集

# 2.必备参数
-XX:+PrintGCDetails                # 输出详细GC日志
-XX:+PrintGCDateStamps             # 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintTenuringDistribution     # 打印对象分布:为分析GC时的晋升情况和晋升导致的高暂停,看对象年龄分布
-XX:+PrintHeapAtGC                 # 在进行GC的前后打印出堆的信息
-XX:+PrintReferenceGC              # 打印Reference处理信息:强引用/弱引用/软引用/虚引用/finalize方法万一有问题
-XX:+PrintGCApplicationStoppedTime # 打印STW时间
-XX:+PrintGCApplicationConCurrentTime # 打印GC间隔的服务运行时长

# 3.日志分割参数
-XX:+UseGCLogFileRotation   # 开启日志文件分割
-XX:NumberOfGCLogFiles=14   # 最多分割几个文件,超过之后从头文件开始写
-XX:GCLogFileSize=32M       # 每个文件上限大小,超过就触发分割
-Xloggc:/path/to/gc-%t.log  # GC日志输出的文件路径,使用%t作为日志文件名,即gc-2021-03-29_20-41-47.log

test、stage 环境jvm使用CMS 参数配置(jdk8)

-server -Xms256M -Xmx256M -Xss512k -Xmn96M -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=128M -XX:InitialHeapSize=256M -XX:MaxHeapSize=256M  -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=80 -XX:+CMSClassUnloadingEnabled -XX:+CMSParallelRemarkEnabled -XX:+CMSScavengeBeforeRemark -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=2 -XX:+CMSParallelInitialMarkEnabled -XX:+CMSParallelRemarkEnabled -XX:+UnlockDiagnosticVMOptions -XX:+ParallelRefProcEnabled -XX:+AlwaysPreTouch -XX:MaxTenuringThreshold=8  -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+PrintGC -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC  -XX:+PrintTenuringDistribution  -XX:SurvivorRatio=8 -Xloggc:../logs/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=../dump

online 环境jvm使用CMS参数配置(jdk8)

-server -Xms4G -Xmx4G -Xss512k  -Xmn1536M -XX:MetaspaceSize=128M -XX:MaxMetaspaceSize=128M -XX:InitialHeapSize=4G -XX:MaxHeapSize=4G  -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=80 -XX:+CMSClassUnloadingEnabled -XX:+CMSParallelRemarkEnabled -XX:+CMSScavengeBeforeRemark -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=2 -XX:+CMSParallelInitialMarkEnabled -XX:+CMSParallelRemarkEnabled -XX:+UnlockDiagnosticVMOptions -XX:+ParallelRefProcEnabled -XX:+AlwaysPreTouch -XX:MaxTenuringThreshold=10  -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+PrintGC -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintHeapAtGC  -XX:+PrintTenuringDistribution  -XX:SurvivorRatio=8 -Xloggc:../logs/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=../dump

JDK 1.7中的各种垃圾收集器到此已全部介绍完毕,在描述过程中提到了很多虚拟机非稳定的运行参数,在表3-2中整理了这些参数供读者实践时参考。 这里写图片描述

这里写图片描述

内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于回收内存这一点,我们已经使用了大量篇幅去介绍虚拟机中的垃圾收集器体系以及运作原理,现在我们再一起来探讨一下给对象分配内存的那点事儿。

对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。本节下面的代码在测试时使用Client模式虚拟机运行,没有手工指定收集器组合,换句话说,验证的是在使用Serial/Serial Old收集器下(ParNew/Serial Old收集器组合的规则也基本一致)的内存分配和回收的策略。读者不妨根据自己项目中使用的收集器写一些程序去验证一下使用其他几种收集器的内存分配策略。

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析,不过本实验的日志并不多,直接阅读就能看得很清楚。

代码清单3-5的testAllocation()方法中,尝试分配3个2MB大小和1个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1,从输出的结果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次GC的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少(因为allocation1、allocation2、allocation3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,因此发生Minor GC。GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

这次GC结束后,4MB的allocation4对象顺利分配在Eden中,因此程序执行完的结果是Eden占用4MB(被allocation4占用),Survivor空闲,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。通过GC日志可以证实这一点。

注意 作者多次提到的Minor GC和Full GC有什么不一样吗?

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major 的速度一般会比Minor GC慢10倍以上。

代码清单3-5 新生代Minor GC

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  */
public static void testAllocation() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation4 = new byte[4 * _1MB];  // 出现一次Minor GC
 }

运行结果:

[GC[DefMew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K), 0.0070426 secs] [Times :user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4326K[0x029d0000 ,0x033d0000 ,0x033d0000 ) eden space 8192K ,5Uused[0x029d0000 ,0x02de4828 ,0x031d0000 )
from space 1024K ,14Sused[0x032d0000 ,0x032f5370 ,0x033d0000 )
to space 1024K ,0%used[0x03ldO000 ,0x031d0000 ,0x032d0000 )
tenured generation total 1024OK,used 6144K[0x033d0000 ,0x03dd0000 ,0x03dd0000 ) 
the space 1024OK,60lused[0x033d0000,0x039d0030,0x039d0200,0x03dd0000) 
compacting perm gen total 12288K,used 2114K[0x03dd0000 ,0x049d0000 ,0x07dd0000 ) 
the space 12288K ,17lused[0x03dd0000 ,0x03fe0998 ,0x03fe0a00 ,0x049d0000 )
Mo shared spaces configured.

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。

执行代码清单3-6中的testPretenureSizeThreshold()方法后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数一样直接写3MB),因此超过3MB的对象都会直接在老年代进行分配。注意PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。

代码清单3-6 大对象直接进入老年代

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 * -XX:PretenureSizeThreshold=3145728
 */
public static void testPretenureSizeThreshold() {
    byte[] allocation;
    allocation = new byte[4 * _1MB];  //直接分配在老年代中
}

运行结果:

Heap
def new generation total 9216K,used 671K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,8%used[0x029d0000,0x02a77e98,0x031d0000)
from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000)
compacting perm gen total 12288K,used 2107K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fdefd0,0x03fdf000,0x049d0000)
No shared spaces configured.

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

读者可以试试分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行代码清单3-7中的testTenuringThreshold()方法,此方法中的allocation1对象需要256KB内存,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后非常干净地变成0KB。而MaxTenuringThreshold=15时,第二次GC发生后,allocation1对象则还留在新生代Survivor空间,这时新生代仍然有404KB被占用。

代码清单3-7 长期存活的对象进入老年代

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
    byte[] allocation1, allocation2, allocation3;
    allocation1 = new byte[_1MB / 4];  // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
    allocation2 = new byte[4 * _1MB];
    allocation3 = new byte[4 * _1MB];
    allocation3 = null;
    allocation3 = new byte[4 * _1MB];
}

以MaxTenuringThreshold=1参数来运行的结果:

[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1(max 1)
-age 1:414664 bytes,414664 total :4859K->404K(9216K),0.0065012 secs]4859K->4500K(19456K),0.0065283 secs][Times:user=0.02 sys=0.00,real=0.02 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1(max 1)
:4500K->0K(9216K),0.0009253 secs]8596K->4500K(19456K),0.0009458 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4500K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,43%used[0x033d0000,0x03835348,0x03835400,0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000)
No shared spaces configured. 

以MaxTenuringThreshold=15参数来运行的结果:

[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15(max 15)
-age 1:414664 bytes,414664 total :4859K->404K(9216K),0.0049637 secs]4859K->4500K(19456K),0.0049932 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15(max 15)
-age 2:414520 bytes,414520 total :4500K->404K(9216K),0.0008091 secs]8596K->4500K(19456K),0.0008305 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4582K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
from space 1024K,39%used[0x031d0000,0x03235338,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000)
No shared spaces configured.

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

执行代码清单3-8中的testTenuringThreshold2()方法,并设置-XX: MaxTenuringThreshold=15,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了6%,也就是说,allocation1、allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。

代码清单3-8 动态对象年龄判定

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[_1MB / 4];   // allocation1+allocation2大于survivo空间一半
    allocation2 = new byte[_1MB / 4];  
    allocation3 = new byte[4 * _1MB];
    allocation4 = new byte[4 * _1MB];
    allocation4 = null;
    allocation4 = new byte[4 * _1MB];
}

运行结果:

[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 1(max 15)
-age 1:676824 bytes,676824 total :5115K->660K(9216K),0.0050136 secs]5115K->4756K(19456K),0.0050443 secs][Times:user=0.00 sys=0.01,real=0.01 secs]
[GC[DefNew
Desired Survivor size 524288 bytes,new threshold 15(max 15)
:4756K->0K(9216K),0.0010571 secs]8852K->4756K(19456K),0.0011009 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
Heap
def new generation total 9216K,used 4178K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4756K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,46%used[0x033d0000,0x038753e8,0x03875400,0x03dd0000)
compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fe09a0,0x03fe0a00,0x049d0000)
No shared spaces configured.

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁,参见代码清单3-9,请读者在JDK 6 Update 24之前的版本中运行测试。

代码清单3-9 空间分配担保

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
    byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation1 = null;
    allocation4 = new byte[2 * _1MB];
    allocation5 = new byte[2 * _1MB];
    allocation6 = new byte[2 * _1MB];
    allocation4 = null;
    allocation5 = null;
    allocation6 = null;
    allocation7 = new byte[2 * _1MB];
}

以HandlePromotionFailure=false参数来运行的结果:

[GC[DefNew:6651K->148K(9216K),0.0078936 secs]6651K->4244K(19456K),0.0079192 secs][Times:user=0.00 sys=0.02,real=0.02 secs]
[G C[D e f N e w:6 3 7 8 K->6 3 7 8 K(9 2 1 6 K),0.0 0 0 0 2 0 6 s e c s][T e n u r e d:4096K->4244K(10240K),0.0042901 secs]10474K->
4244K(19456K),[Perm:2104K->2104K(12288K)],0.0043613 secs][Times:user=0.00 sys=0.00,real=0.00 secs]

以HandlePromotionFailure=true参数来运行的结果:

[GC[DefNew:6651K->148K(9216K),0.0054913 secs]6651K->4244K(19456K),0.0055327 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
[GC[DefNew:6378K->148K(9216K),0.0006584 secs]10474K->4244K(19456K),0.0006857 secs][Times:user=0.00 sys=0.00,real=0.00 secs]

在JDK 6 Update 24之后,这个测试结果会有差异,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化(见代码清单3-10),虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

代码清单3-10 HotSpot中空间分配检查的代码片段

bool TenuredGeneration:promotion_attempt_is_safe(size_tmax_promotion_in_bytes)const{
    //老年代最大可用的连续空间
    size_t available=max_contiguous_available();
    //每次晋升到老年代的平均大小
    size_t av_promo=(size_t)gc_stats()->avg_promoted()->padded_average();
    //老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此GC时新生代所有对象容量
    bool res=(available>=av_promo)||(available>=
    max_promotion_in_bytes);
    return res;
}

Full GC场景

场景一:System.gc()方法的调用

此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过 -XX:+ DisableExplicitGC 来禁止RMI调用System.gc()。

场景二:老年代代空间不足

  • 原因分析:新生代对象转入老年代、创建大对象或数组时,执行FullGC后仍空间不足
  • 抛出错误:Java.lang.OutOfMemoryError: Java heap space
  • 解决办法:
    • 尽量让对象在YoungGC时被回收
    • 让对象在新生代多存活一段时间
    • 不要创建过大的对象或数组

场景三:永生区空间不足

  • 原因分析:JVM方法区因系统中要加载的类、反射的类和调用的方法较多而可能会被占满
  • 抛出错误:java.lang.OutOfMemoryError: PermGen space
  • 解决办法:
    • 增大老年代空间大小
    • 使用CMS GC

场景四:CMS GC时出现promotion failed和concurrent mode failure

  • 原因分析:
    • promotion failed:是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成
    • concurrent mode failure:是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的
  • 抛出错误:GC日志中存在promotion failedconcurrent mode
  • 解决办法:增大幸存区或老年代

场景五:堆中分配很大的对象

  • 原因分析:创建大对象或长数据时,此对象直接进入老年代,而老年代虽有很大剩余空间,但没有足够的连续空间来存储
  • 抛出错误:触发FullGC
  • 解决办法:配置-XX:+UseCMSCompactAtFullCollection开关参数,用于享受用完FullGC后额外免费赠送的碎片整理过程,但同时停顿时间不得不变长。可以使用-XX:CMSFullGCsBeforeCompaction参数来指定执行多少次不压缩的FullGC后才执行一次压缩

CMS GC场景

场景一:动态扩容引起的空间震荡

  • 现象

    服务刚刚启动时 GC 次数较多,最大空间剩余很多但是依然发生 GC,这种情况我们可以通过观察 GC 日志或者通过监控工具来观察堆的空间变化情况即可。GC Cause 一般为 Allocation Failure,且在 GC 日志中会观察到经历一次 GC ,堆内各个空间的大小会被调整,如下图所示:

    动态扩容引起的空间震荡

  • 原因

    在 JVM 的参数中 -Xms-Xmx 设置的不一致,在初始化时只会初始 -Xms 大小的空间存储信息,每当空间不够用时再向操作系统申请,这样的话必然要进行一次 GC。另外,如果空间剩余很多时也会进行缩容操作,JVM 通过 -XX:MinHeapFreeRatio-XX:MaxHeapFreeRatio 来控制扩容和缩容的比例,调节这两个值也可以控制伸缩的时机。整个伸缩的模型理解可以看这个图,当 committed 的空间大小超过了低水位/高水位的大小,capacity 也会随之调整:

    JVM内存伸缩模型

  • 策略

    观察 CMS GC 触发时间点 Old/MetaSpace 区的 committed 占比是不是一个固定的值,或者像上文提到的观察总的内存使用率也可以。尽量 将成对出现的空间大小配置参数设置成固定的 ,如 -Xms-Xmx-XX:MaxNewSize-XX:NewSize-XX:MetaSpaceSize-XX:MaxMetaSpaceSize 等。

场景二:显式GC的去与留

  • 现象

    除了扩容缩容会触发 CMS GC 之外,还有 Old 区达到回收阈值、MetaSpace 空间不足、Young 区晋升失败、大对象担保失败等几种触发条件,如果这些情况都没有发生却触发了 GC ?这种情况有可能是代码中手动调用了 System.gc 方法,此时可以找到 GC 日志中的 GC Cause 确认下。

  • 原因

    保留 System.gc:CMS中使用 Foreground Collector 时将会带来非常长的 STW,在应用程序中 System.gc 被频繁调用,那就非常危险。增加 -XX:+DisableExplicitGC 参数则可以禁用。去掉 System.gc:禁用掉后会带来另一个内存泄漏的问题,为 DirectByteBuffer 分配空间过程中会显式调用 System.gc ,希望通过 Full GC 来强迫已经无用的 DirectByteBuffer 对象释放掉它们关联的 Native Memory,如Netty等。

  • 策略

    无论是保留还是去掉都会有一定的风险点,不过目前互联网中的 RPC 通信会大量使用 NIO,所以建议保留。此外 JVM 还提供了 -XX:+ExplicitGCInvokesConcurrent-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses 参数来将 System.gc 的触发类型从 Foreground 改为 Background,同时 Background 也会做 Reference Processing,这样的话就能大幅降低了 STW 开销,同时也不会发生 NIO Direct Memory OOM。

场景三:MetaSpace区OOM

  • 现象

    JVM 在启动后或者某个时间点开始, MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决

  • 原因

    在讨论为什么会 OOM 之前,我们先来看一下这个区里面会存什么数据,Java 7 之前字符串常量池被放到了 Perm 区,所有被 intern 的 String 都会被存在这里,由于 String.intern 是不受控的,所以 -XX:MaxPermSize 的值也不太好设置,经常会出现 java.lang.OutOfMemoryError: PermGen space 异常,所以在 Java 7 之后常量池等字面量(Literal)、类静态变量(Class Static)、符号引用(Symbols Reference)等几项被移到 Heap 中。而 Java 8 之后 PermGen 也被移除,取而代之的是 MetaSpace。由场景一可知,为了避免弹性伸缩带来的额外 GC 消耗,我们会将 -XX:MetaSpaceSize-XX:MaxMetaSpaceSize 两个值设置为固定的,但这样也会导致在空间不够的时候无法扩容,然后频繁地触发 GC,最终 OOM。所以关键原因是 ClassLoader不停地在内存中load了新的Class ,一般这种问题都发生在动态类加载等情况上。

  • 策略

    可以 dump 快照之后通过 JProfiler 或 MAT 观察 Classes 的 Histogram(直方图) 即可,或者直接通过命令即可定位, jcmd 打几次 Histogram 的图,看一下具体是哪个包下的 Class 增加较多就可以定位。如果无法从整体的角度定位,可以添加 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 参数观察详细的类加载和卸载信息。

场景四:过早晋升

  • 现象

    这种场景主要发生在分代的收集器上面,专业的术语称为“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 区。

    • Full GC 比较频繁 ,且经历过一次 GC 之后 Old 区的 变化比例非常大

      如Old区触发回收阈值是80%,经历一次GC之后下降到了10%,这说明Old区70%的对象存活时间其实很短。

      FullGC变化比例大

    过早晋升的危害:

    • Young GC 频繁,总的吞吐量下降
    • Full GC 频繁,可能会有较大停顿
  • 原因

    主要的原因有以下两点:

    • Young/Eden 区过小: 过小的直接后果就是 Eden 被装满的时间变短,本应该回收的对象参与了 GC 并晋升,Young GC 采用的是复制算法,由基础篇我们知道 copying 耗时远大于 mark,也就是 Young GC 耗时本质上就是 copy 的时间(CMS 扫描 Card Table 或 G1 扫描 Remember Set 出问题的情况另说),没来及回收的对象增大了回收的代价,所以 Young GC 时间增加,同时又无法快速释放空间,Young GC 次数也跟着增加
    • 分配速率过大: 可以观察出问题前后 Mutator 的分配速率,如果有明显波动可以尝试观察网卡流量、存储类中间件慢查询日志等信息,看是否有大量数据被加载到内存中
  • 策略

    • 如果是 Young/Eden 区过小 ,可以在总的 Heap 内存不变的情况下适当增大Young区。一般情况下Old的大小应当为活跃对象的2~3倍左右,考虑到浮动垃圾问题最好在3倍左右,剩下的都可以分给Young区

    • 过早晋升优化来看,原配置为Young 1.2G+Old 2.8G,通过观察CMS GC的情况找到存活对象大概为 300~400M,于是调整Old 1.5G左右,剩下2.5G分给Young 区。仅仅调了一个Young区大小参数(-Xmn),整个 JVM 一分钟Young GC从26次降低到了11次,单次时间也没有增加,总的GC时间从1100ms降低到了500ms,CMS GC次数也从40分钟左右一次降低到了7小时30分钟一次:

      过早晋升优化GC

      过早晋升优化Oldgen

      如果是分配速率过大:

      • 偶发较大 :通过内存分析工具找到问题代码,从业务逻辑上做一些优化
      • 一直较大 :当前的 Collector 已经不满足 Mutator 的期望了,这种情况要么扩容 Mutator 的 VM,要么调整 GC 收集器类型或加大空间
  • 小结

    过早晋升问题一般不会特别明显,但日积月累之后可能会爆发一波收集器退化之类的问题,所以我们还是要提前避免掉的,可以看看自己系统里面是否有这些现象,如果比较匹配的话,可以尝试优化一下。一行代码优化的 ROI 还是很高的。如果在观察 Old 区前后比例变化的过程中,发现可以回收的比例非常小,如从 80% 只回收到了 60%,说明我们大部分对象都是存活的,Old 区的空间可以适当调大些。

场景五:CMS Old GC频繁

  • 现象

    Old 区频繁的做 CMS GC,但是每次耗时不是特别长,整体最大 STW 也在可接受范围内,但由于 GC 太频繁导致吞吐下降比较多。

  • 原因

    这种情况比较常见,基本都是一次 Young GC 完成后,负责处理 CMS GC 的一个后台线程 concurrentMarkSweepThread 会不断地轮询,使用 shouldConcurrentCollect() 方法做一次检测,判断是否达到了回收条件。如果达到条件,使用 collect_in_background() 启动一次 Background 模式 GC。轮询的判断是使用 sleepBeforeNextCycle() 方法,间隔周期为 -XX:CMSWaitDuration 决定,默认为 2s。

  • 策略

    处理这种常规内存泄漏问题基本是一个思路,主要步骤如下:

    CMSOldGC频繁

    Dump Diff 和 Leak Suspects 比较直观,这里说下其它几个关键点:

    • 内存 Dump: 使用 jmap、arthas 等 dump 堆进行快照时记得摘掉流量,同时 分别在 CMS GC 的发生前后分别 dump 一次
    • 分析 Top Component: 要记得按照对象、类、类加载器、包等多个维度观察Histogram,同时使用 outgoing和incoming分析关联的对象,其次Soft Reference和Weak Reference、Finalizer 等也要看一下
    • 分析 Unreachable: 重点看一下这个,关注下 Shallow 和 Retained 的大小。如下图所示的一次 GC 优化,就根据 Unreachable Objects 发现了 Hystrix 的滑动窗口问题。

    分析Unreachable

场景六:单次CMS Old GC耗时长

  • 现象

    CMS GC 单次 STW 最大超过 1000ms,不会频繁发生,如下图所示最长达到了 8000ms。某些场景下会引起“雪崩效应”,这种场景非常危险,我们应该尽量避免出现。

    CMSGC单次STW长

  • 原因

    CMS在回收的过程中,STW的阶段主要是 Init Mark 和 Final Remark 这两个阶段,也是导致CMS Old GC 最多的原因,另外有些情况就是在STW前等待Mutator的线程到达SafePoint也会导致时间过长,但这种情况较少。

  • 策略

    知道了两个 STW 过程执行流程,我们分析解决就比较简单了,由于大部分问题都出在 Final Remark 过程,这里我们也拿这个场景来举例,主要步骤:

    • 【方向】 观察详细 GC 日志,找到出问题时 Final Remark 日志,分析下 Reference 处理和元数据处理 real 耗时是否正常,详细信息需要通过 -XX:+PrintReferenceGC 参数开启。 基本在日志里面就能定位到大概是哪个方向出了问题,耗时超过 10% 的就需要关注
    2019-02-27T19:55:37.920+0800: 516952.915: [GC (CMS Final Remark) 516952.915: [ParNew516952.939: [SoftReference, 0 refs, 0.0003857 secs]516952.939: [WeakReference, 1362 refs, 0.0002415 secs]516952.940: [FinalReference, 146 refs, 0.0001233 secs]516952.940: [PhantomReference, 0 refs, 57 refs, 0.0002369 secs]516952.940: [JNI Weak Reference, 0.0000662 secs]
    [class unloading, 0.1770490 secs]516953.329: [scrub symbol table, 0.0442567 secs]516953.373: [scrub string table, 0.0036072 secs][1 CMS-remark: 1638504K(2048000K)] 1667558K(4352000K), 0.5269311 secs] [Times: user=1.20 sys=0.03, real=0.53 secs]
    
    • 【根因】 有了具体的方向我们就可以进行深入的分析,一般来说最容易出问题的地方就是 Reference 中的 FinalReference 和元数据信息处理中的 scrub symbol table 两个阶段,想要找到具体问题代码就需要内存分析工具 MAT 或 JProfiler 了,注意要 dump 即将开始 CMS GC 的堆。在用 MAT 等工具前也可以先用命令行看下对象 Histogram,有可能直接就能定位问题。
      • 对 FinalReference 的分析主要观察 java.lang.ref.Finalizer 对象的 dominator tree,找到泄漏的来源。经常会出现问题的几个点有 Socket 的 SocksSocketImpl 、Jersey 的 ClientRuntime、MySQL 的 ConnectionImpl 等等
      • scrub symbol table 表示清理元数据符号引用耗时,符号引用是 Java 代码被编译成字节码时,方法在 JVM 中的表现形式,生命周期一般与 Class 一致,当 _should_unload_classes 被设置为 true 时在 CMSCollector::refProcessingWork() 中与 Class Unload、String Table 一起被处理
    • 【策略】 知道 GC 耗时的根因就比较好处理了,这种问题不会大面积同时爆发,不过有很多时候单台 STW 的时间会比较长,如果业务影响比较大,及时摘掉流量,具体后续优化策略如下:
      • FinalReference:找到内存来源后通过优化代码的方式来解决,如果短时间无法定位可以增加 -XX:+ParallelRefProcEnabled 对 Reference 进行并行处理
      • symbol table:观察 MetaSpace 区的历史使用峰值,以及每次 GC 前后的回收情况,一般没有使用动态类加载或者 DSL 处理等,MetaSpace 的使用率上不会有什么变化,这种情况可以通过 -XX:-CMSClassUnloadingEnabled 来避免 MetaSpace 的处理,JDK8 会默认开启 CMSClassUnloadingEnabled,这会使得 CMS 在 CMS-Remark 阶段尝试进行类的卸载
  • 小结

    正常情况进行的 Background CMS GC,出现问题基本都集中在 Reference 和 Class 等元数据处理上,在 Reference 类的问题处理方面,不管是 FinalReference,还是 SoftReference、WeakReference 核心的手段就是找准时机 dump快照,然后用内存分析工具来分析。Class处理方面目前除了关闭类卸载开关,没有太好的方法。在 G1 中同样有 Reference 的问题,可以观察日志中的 Ref Proc,处理方法与 CMS 类似。

场景七:内存碎片&收集器退化

  • 现象

    并发的 CMS GC 算法,退化为 Foreground 单线程串行 GC 模式,STW 时间超长,有时会长达十几秒。其中 CMS 收集器退化后单线程串行 GC 算法有两种:

    • 带压缩动作的算法,称为 MSC,上面我们介绍过,使用标记-清理-压缩,单线程全暂停的方式,对整个堆进行垃圾收集,也就是真正意义上的 Full GC,暂停时间要长于普通 CMS
    • 不带压缩动作的算法,收集 Old 区,和普通的 CMS 算法比较相似,暂停时间相对 MSC 算法短一些
  • 原因

    CMS 发生收集器退化主要有以下几种情况:

    • 晋升失败(Promotion Failed)

    • 增量收集担保失败

    • 显式 GC

    • 并发模式失败(Concurrent Mode Failure)

  • 策略

    分析到具体原因后,我们就可以针对性解决了,具体思路还是从根因出发,具体解决策略:

    • 内存碎片: 通过配置 -XX:UseCMSCompactAtFullCollection=true 来控制 Full GC 的过程中是否进行空间的整理(默认开启,注意是 Full GC,不是普通 CMS GC),以及 -XX: CMSFullGCsBeforeCompaction=n 来控制多少次 Full GC 后进行一次压缩
    • 增量收集: 降低触发 CMS GC 的阈值,即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保证有足够的连续空间,也减少 Old 区空间的使用大小,另外需要使用 -XX:+UseCMSInitiatingOccupancyOnly 来配合使用,不然 JVM 仅在第一次使用设定值,后续则自动调整
    • 浮动垃圾: 视情况控制每次晋升对象的大小,或者缩短每次 CMS GC 的时间,必要时可调节 NewRatio 的值。另外使用 -XX:+CMSScavengeBeforeRemark 在过程中提前触发一次Young GC,防止后续晋升过多对象
  • 小结

    正常情况下触发并发模式的 CMS GC,停顿非常短,对业务影响很小,但 CMS GC 退化后,影响会非常大,建议发现一次后就彻底根治。只要能定位到内存碎片、浮动垃圾、增量收集相关等具体产生原因,还是比较好解决的,关于内存碎片这块,如果 -XX:CMSFullGCsBeforeCompaction 的值不好选取的话,可以使用 -XX:PrintFLSStatistics 来观察内存碎片率情况,然后再设置具体的值。最后就是在编码的时候也要避免需要连续地址空间的大对象的产生,如过长的字符串,用于存放附件、序列化或反序列化的 byte 数组等,还有就是过早晋升问题尽量在爆发问题前就避免掉。

场景八:堆外内存OOM

  • 现象

    内存使用率不断上升,甚至开始使用 SWAP 内存,同时可能出现 GC 时间飙升,线程被 Block 等现象, 通过 top 命令发现 Java 进程的 RES 甚至超过了 **-Xmx** 的大小 。出现这些现象时,基本可确定是出现堆外内存泄漏。

  • 原因

    JVM 的堆外内存泄漏,主要有两种的原因:

    • 通过 UnSafe#allocateMemoryByteBuffer#allocateDirect 主动申请了堆外内存而没有释放,常见于 NIO、Netty 等相关组件
    • 代码中有通过 JNI 调用 Native Code 申请的内存没有释放
  • 策略

    原因一:主动申请未释放

    原因二:通过 JNI 调用的 Native Code 申请的内存未释放

场景九:JNI引发的GC问题

  • 现象

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

  • 策略

    • 添加 -XX+PrintJNIGCStalls 参数,可以打印出发生 JNI 调用时的线程,进一步分析,找到引发问题的 JNI 调用
    • JNI 调用需要谨慎,不一定可以提升性能,反而可能造成 GC 问题
    • 升级 JDK 版本到 14,避免 JDK-8048556 导致的重复 GC

Search

    微信好友

    博士的沙漏

    Table of Contents