15 如何基于 JVM 分析内存使用对象?

上一讲我带你学习了基于 JVM 的线程分析,相信你已经可以通过热点线程分析出哪些方法在消耗 CPU,拿到这些方法之后你就可以和研发人员讨论后续的优化方案了。那这一讲我们就来重点学习 JVM 内存是如何管理的,有哪些手段可以分析内存对象,并帮助你定位内存的瓶颈。

提到分析 JVM 的内存对象,可能你会问我,之前讲过如何判断服务器内存瓶颈,那 JVM 内存和服务器内存有什么联系呢。我们先来看下这两者的关系,如下图所示:

图片3.png

图 1:内存关系示意图

其实二者的关系很简单,对于服务器系统而言,JVM 只是其中的一部分。当操作系统内存出现瓶颈时,我们便会重点排查哪些应用会占用内存。不过对于更深一步分析内存的使用,并不仅仅是统计使用、空闲等这些数值,我们需要进一步去了解内存结构,以及内存如何分配、如何回收,这样你才能更好地确定内存的问题。

JVM 内存分配

通过第 14 讲的学习你可以知道,Java 文件一般是先编译成 class 结尾的文件,然后通过类加载器到 JVM 内存中。接着我们来看看 JVM 内存结构图,这样能够对它有个全局的了解。

图片1.png

图 2:JVM 内存分配示意图

1.本地方法栈

本地方法栈保存的是 native 方法的信息,native 方法就是 Java 调用非 Java 代码的接口,为什么会有这样的设置呢?简单来说,sun 的解释器是由 C 语言实现的,而 jre 又是基于 Java 语言,所以需要 native 方法来进行跨语言的调用。

2.Java 栈

Java 栈是常用的内存区域之一,它里面存放着基本数据类型对象的引用,可能你不太清楚什么是对象的引用,拿上一讲中 HelloTester helloTester=new HelloTester() 为例,在 Java 栈中 HelloTester 是个引用,指向在堆空间中开辟的该对象的空间。

3.方法区(JDK 1.8+已经移除)

也叫作永久区,用来存储类信息,如上文描述的 HelloTester。值得注意的是方法区在 JDK 1.8 以上已经被元空间取代,并且元空间不在 JVM 中了,而是在本地内存中独立开辟存储空间。

4.程序计数器

可以认为是线程的信号指示器,它的作用是保存线程当前程序的执行位置,以保证多线程的切换。因为在多线程的情况下,CPU 并不是完成一个线程执行再去执行另外一个线程,而是不停地切换线程执行,这时程序计数器就可以发挥作用了。

5.堆

堆区域是 JVM 调优最重要的区域,堆中存放的数据很多是对象实例,如 HelloTester 的对象存储。堆空间占据着 JVM 中最大的存储区域,存放了很多对象,所以大多数基于 JVM 的内存调优也是对堆空间的调优。

堆空间并非取之不尽,如果一直存放总有用完的时候,所以对于有用的对象应当保存起来,无用的对象应当回收,为了更好地实现这一机制,JVM 将堆空间分成了新生代和老生代,如下图所示:

4.png

图 3:GC 示意对比图

通过图 3 可以看到新生代和老年代的对比,Minor GC 发生在新生代,而 Full GC 发生在老年代。新生代分为三个区,一个 Eden 区和两个 Survivor 区。

先来看下 Eden 区的作用,大部分新生成的对象都是在 Eden 区,Eden 区满了之后便没有内存给新对象使用,Eden 区便会 Minor GC 回收无用内存,剩下的存活对象便会转移到 Survivor 区。

那两个 Survivor 区的作用分别是什么呢?两者其实是对称分布的,一个是 From 区,一个是 To 区。从 Eden 区存活下来的对象首先会被复制到 From 区,当 From 区满时,此时还存活的对象会被转移到 To 区,经历了多次的 Minor GC 后,还存活的对象就会被复制到老年代,老年代的 GC 一般叫作 FullGC 或者 MajorGC。

我们对比下新生代垃圾回收和老年代垃圾回收的区别,如下表所示:

图片2.png

如何定位内存占用问题

回到我们实际工作当中,当你发现 JVM 中使用的内存越来越多或者增长很快的时候,频繁 GC 的时候,应当如何去定位哪些对象导致的这些问题呢?

这其实涉及两个问题:

1.如何观察 GC 的频次?

本部分我以 JDK 自带的工具来讲解,我一般使用 jstat 来查看 GC 的频次。首先我们来看下基本用法,如下所示:

[root@JD ~]# jstat -gc 26607 1000 3

 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT

512.0  512.0  320.0   0.0   86016.0  27828.5   175104.0   157974.6  122840.0 116934.9 16128.0 15060.4   5328   37.311   4      1.042   38.353

512.0  512.0  320.0   0.0   86016.0  27981.9   175104.0   157974.6  122840.0 116934.9 16128.0 15060.4   5328   37.311   4      1.042   38.353

512.0  512.0  320.0   0.0   86016.0  28885.4   175104.0   157974.6  122840.0 116934.9 16128.0 15060.4   5328   37.311   4      1.042   38.353

我们来解析下终端输入的命令:

jstat -gc 26607 1000 3

接着我们再来看输出选项代表的含义有哪些?这个输出的信息含量比较大,不过信息是有对应关系的,比如 S0C 和 S0U:

这是通用的,你可以看到输出项中有很多以 C 或者 U 结尾。S0 则代表第一个 Survivor 区,也就是我上文说的 From 区。通过以上的讲解,我相信很多名词你不用死记硬背也能理解了,比如 S1C 和 S1U 则表示第二个 Survivor 区也就是 To 区的总容量和使用容量。

接下来我罗列下其他的输出选项含义。

这样对比着看会更直观一点,对于上述输出选项的含义我们都需要有一定的印象,从而通过垃圾回收频率和消耗时间初步判断 GC 是否存在可疑问题。

有同学问过这样的问题,堆内存区域划分了这么多代,感觉很复杂,为什么要这么做呢?

我想不分代,内存垃圾肯定也是可以回收的。而让内存区域分代,主要就是优化垃圾回收的性能,也就是 GC 的性能。有点类似于我们日常生活中的垃圾分类,你把干湿垃圾分离,一方面有利于下一步的再利用,再者对于我们后续垃圾的处理效率也会有较大的提升。对于内存回收其实也是这样的,如果不分代那么所有的对象可能都在同一个大的区间里,GC 依次判断则效率必然是很低,如果是分代处理,对不同的区域分以不同的回收策略,这样效率会高很多。

2.如何定位占用内存的对象?

这里我将推荐一个工具 jmap,通过 jmap 可以指定 Java 进程的 PID,查看该进程的对象、数量等等,接下来我做一个演示。

首先我们来查看进程号为 18658 的应用包,如下所示:

[root@JD ~]# ps -ef|grep demo

root     18658     1  0 Dec09 ?后续省略

其中上述输出的第二列 18658 为进程号,然后将进程号通过命令组合可以查看以下信息:

[root@JD ~]# jmap -histo 18658|head -n 20

 num     #instances         #bytes  class name

----------------------------------------------

   1:        157619       18840672  [C

   2:          8326        8324360  [B

   3:        146319        3511656  java.lang.String

   4:          9224        2825584  [I

   5:         65733        2103456  com.example.demo.entity.User

   6:         62508        2000256  java.util.HashMap$Node

   7:         21868        1618832  [Ljava.lang.Object;

其中 [C、[S、[I、[B 对应的类型如下所示:

[C is a char[]

[S is a short[]

[I is a int[]

[B is a byte[]

你注意下第五行,这是能够最直接看到的业务类,如果是业务对象尤其需要关注,看是否一直上升。

可视化的 JVM 监控工具

在第三模块中,你可以知道,对于监控定位我一般会采用命令行结合可视化的方案一并讲解,接下来我介绍一个 JDK 自带的 JVM 监控工具:jvisual。

jvisual 能做的事情很多,监控内存泄漏、跟踪垃圾回收、执行时内存分析、CPU 线程分析等,而且通过图形化的界面指引就可以完成,接下来我主要讲述 jvisual 如何使用以及如何看内存对象的占用。

先来看下 jvisual 是如何使用的,一般我们会在启动被测的 jar 服务里进行如下配置:

nohup java -Djava.rmi.server.hostname=实际ip -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -jar demo-0.0.1-SNAPSHOT.jar &

通过这样的方式可以启动暴露 1099 端口,且连接时不需要认证。

然后在本机电脑 jdk 路径 bin 目录下找到 jvisualvm,双击打开,如下图所示:

Drawing 3.png

我们再配置相应的 jmx 连接,如下图所示:

Drawing 4.png

如果出现如下图所示的界面,就证明连接成功了。

image

这样我们就能够概览 JVM 的 CPU 和内存的使用情况,如下图所示,通过点击抽样器,你可以分别获得对象在 CPU 和内存的占用。值得注意的是很多初学者把这部分 CPU 监控或者内存监控认为是服务器硬件级别的,这是不对的,这些都是基于 JVM 的监控。

Drawing 6.png

按照内存占用进行排序是非常清晰的,你可以看到随着性能测试的进行,User 类字节占用比例越来越高,如下图所示:

Drawing 7.png

总结

通过本讲的学习,你了解了 JVM 的内存结构,知道了 Java 内存对象经常活动的区域,同时列举了常见的排查手段诊断内存问题。