深入解析GC频繁与堆内存溢出原因:排查思路与堆内存分布查看方法

前言
GC(垃圾回收)频繁的原因,简单来讲就是对象占用堆空间后难以回收。由于对象占用堆空间难以回收,就会出现新对象无法分配的情况,这会触发 GC。还有可能直接导致内存溢出,最终致使进程结束。
排查思路首先是查看进程中各种类型对象所占用的空间大小以及其比例,接着锁定占用空间较多的对象,然后再分析相关的程序是否存在使用不当的情况。下文主要是通过多种方式来查看堆内存的分布。
例子程序
首先编译(javac FrequentFullGCSample.java)这个例子程序,然后执行以下指令来创建 JVM 进程。
指定堆内存大小为 2 兆,使用 -Xms2M -Xmx2M 来进行限制,这个大小对于例子程序来说是够用的;为了方便演示,我们添加了两个选项,一是 -XX:+PrintCommandLineFlags ,用于打印有变动的 JVM 选项值,二是 -XX:+PrintGCDetails ,用于打印垃圾回收的详细信息。
使用 java 命令,并设置 -Xms2M 和 -Xmx2M 这两个内存参数,同时加上 -XX:+PrintCommandLineFlags 和 -XX:+PrintGCDetails 这两个标志,还进行了 FrequentFullGCSample 操作。
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
public class FrequentFullGCSample {
private static final Queue<Object> QUEUE = new LinkedList<>();
public static void main(String[] args) throws InterruptedException {
while (true) {
QUEUE.offer(new Object());
TimeUnit.MILLISECONDS.sleep(3);
}
}
}
从控制台日志能够看到,首先输出了部分有变动的 JVM 选项值。其中,初始堆(-XX:InitialHeapSize)的大小为 2097152 字节(B),即 2 兆(M);最大堆(-XX:MaxHeapSize)的大小也为 2097152 字节(B),同样是 2 兆(M)。并且使用了类指针压缩(-XX:+UseCompressedClassPointers)以及对象指针压缩(-XX:+UseCompressedOops)。
我们之后可以看到输出的 GC 日志信息。对象首先会分配在年轻代,当年轻代的空间占满时,就会开始频繁进行 YGC(Young garbage collection)。
经过几代回收,对象的分代年龄会增长并转移到老年代。随着老年代空间逐渐被占满,就会开始频繁进行 Full GC。
例子程序新创建的对象皆为强引用类型,所以不能被定为垃圾,也就无法进行回收。在经过频繁的 Full GC 后仍无效果,此时新对象无法分配到空间,进而报错内存溢出(java.lang.OutOfMemoryError)。从后面打印出的各个分代的内存使用情况能够得知,年轻代(def new generation)与老年代(tenured generation)的占用比例近乎 100%。
先通过jps找到我们的JVM进程ID
查看堆中对象分配信息 jmap -histo(推荐)
jmap 是 JDK 所自带的指令,它能够查看堆内存相关的信息。jmap -histo 这个操作会将进程中每个类型对象的实例数量以及占用空间大小列出来,并且是按照降序排列的。我们加上 head -10 这个命令,就可以查看前 10 行的信息,从而能够看到排名靠前的几位对象的相关信息。
这种方式适合在进程正在运行的时候使用。它统计输出的信息不多,对进程本身的影响相对较小。
jmap -histo 20091 | head -10
jmap -dump + Java VisualVM
jmap -dump 能够把堆内存的信息以二进制的形式转存到文件里,其格式为 jmap -dump:format=b,file= 。
这种方式转储的数据量较大。当我们的 JVM 堆内存使用空间较大时(例如达到上 G 的大小),就需要一定的时间。并且在转储过程中会对进程运行产生影响。一般不建议采取这种方式,除非 JVM 配置或使用的空间比较小。
jmap -dump:format=b,file=71976.dump 71976
我们有了堆转储文件,需要找一个能够分析这个文件的工具去查看。在这里,我们选用 JDK 自带的 JVM 图形化工具 Java VisualVM 。
# Mac在命令行输出下面命令打开
System 目录下的 Library 文件夹中的 Frameworks 文件夹里的 JavaVM.framework 文件夹下的 Versions 文件夹中的 Current 文件夹中的 Commands 文件夹里有 jvisualvm 这个文件。
# Windows在JDK中bin目录下
最后打开该文件。
装入后点击相关类,能够在图形化界面中观察到当时堆内各种类的实例数量以及大小。
内存溢出时自动生成堆转储文件(推荐)
启动时添加一个 JVM 选项 -XX:+HeapDumpOnOutOfMemoryError 。默认情况下,文件会生成在当前目录下,其名为 java_pid.hprof 。可以通过选项 -XX:HeapDumpPath=将文件生成到指定的目录。之后,使用 Java VisualVM 进行装入并查看即可。
这种方式较为推荐。进程已出现问题需要退出,在退出之前能够生成信息,以便我们事后进行分析。
java -Xms2M -Xmx2M \
-XX:+PrintCommandLineFlags \
-XX:+PrintGCDetails \
XX 开启了在内存溢出错误时生成堆转储文件的功能,并且会进行频繁的全垃圾回收采样。
Java VisualVM 远程监控
在 java 命令中添加几个选项来启用远程监控。将 -Djava.rmi.server.hostname 填写为本机的 IP 地址;将 -Dcom.sun.management.jmxremote.port 填写为远程访问所使用的端口;另外两个选项是为了关闭鉴权,即不校验用户名和密码。
进程开启监控需要额外工作,比如下发数据、进行网络交互等,这些会对进程运行产生影响,所以一般不会使用。不过在测试时可以使用,比如在压力测试的同时进行监控,这样便于分析。
使用 Java 并设置 -Xms2M 和 -Xmx2M ,同时启用 -XX:+PrintCommandLineFlags 和 -XX:+PrintGCDetails 。\
Dcom.sun.management 以及 jmxremote\
Dcom.sun.management.jmxremote 进行身份验证。=false \
Dcom.sun.management.jmxremote 具备 ssl 方面的内容。=false \
Dcom 下 sun 管理的 jmxremote 端口=1099 \
-Djava.rmi.server.hostname=192.168.252.132 FrequentFullGCSample
双击【远程】,填好主机地址后点【确定】。
右键点击刚才配置出现的主机,接着选择【添加 JMX】进行连接操作,然后填写好端口号并点击【确定】。
双击刚配置好的 JMX 连接,接着点击【抽样器】,然后再点击【内存】,这样就能够实时看到堆内存中各种对象的分配情况。
总结
以上方式推荐的是 jmap -histo 以及内存溢出时的自动转储,这两种方式对进程本身的影响比较小。
另外,jmap -heap 能够查看与堆内存相关的 JVM 配置以及堆内存的整体分配情况。