Java服务监控分析及案例 - 内存篇

日常工作中,经常要观测系统的各项指标使用情况,判断服务是否处于健康运行状态。观测期间,时常会想到每个指标都是如何获取的,代表着什么含义。

于是萌生了写这个系列博客的想法,本篇博客会从系统层面,Java应用层面分析命令行工具输出的含义以及用法,同时也会结合实际案例讲解。

系统内存指标

free命令及参数说明

为了便于理解,我们先来看一下常用的free命令输出的各项指标:

可以看到输出中分为物理内存Mem和交换分区Swap两行

对于其中各个参数的含义,我们可以通过man free命令来查看说明文档

  • total 表示内存的总量
  • used 是已使用内存的大小,包含了共享内存,其大小等于 total - free - buffers - cache
  • free 是未使用内存的大小
  • shared 是多个进程共享的内存大小
  • buff/cache 是操作系统用来做缓存的内存占用
  • available 是新进程可用内存的大小

其中total = used + free + buff/cache

不难发现其中available的值要大于free的值,这是因为available不仅包含了未使用的内存,还包含了可回收的缓存。当 free 内存不够分配使用时,cache/buffers 会释放一些供应用程序使用。

Buffer & Cache

大致了解了free命令中的参数含义,接下来将深入了解buff/cache参数代表的含义。

通过之前man free命令显示的说明文档,free的数据来源于/proc/meminfo,其中buffers和cache的数据也分别对应/proc/meminfo中的Buffers,以及proc/meminfo中的Cache和SReclaimable

所以要想知道Buffers和Cache所代表的真正含义,我们还需要运行man proc来查找对应的说明文档


  • Buffers: 是原始磁盘块的临时存储,大小应该在20MB左右,可以理解为是堆磁盘数据的缓存。
  • Cached: 是对磁盘读取文件的页缓存,不包括SwapCached
  • SReclaimable: 是Slab (一种内存分配器,用于实现内核中更小粒度的内存分配) 的一部分,可能会被回收的,而不可回收的部分则是SUnreclaim

其中Buffers缓存磁盘的读写数据,Cached缓存文件系统的读写数据。使得磁盘和文件的写入更快速,同时也降低了读取数据时频繁I/O对磁盘的压力。

同时在内存空间紧张时,buffers/cache不会与应用程序争抢内存空间,只要应用程序需要更多的内存,它就把用来做磁盘页缓存的内存段归还回去,给到应用程序使用。

Swap

free中的物理内存Mem行对应的参数了解完了,接下来看一下第二行交换空间Swap代表的是什么意思。

Swap是干啥用的

Swap是磁盘上的一块区域,当进程向操作系统请求内存使用但内存不足时,操作系统会将不常访问的内存数据交换出去,放在Swap分区中,这个过程就是swap out。而当系统需要访问Swap中存储的这块内容时,系统又会将Swap上的数据加载到内存中,这个过程称为swap in

所以有了Swap的存在,使得内存可使用的空间看起来比实际上更大了。但是Swap也是有上限的,一旦用完,系统会触发OOM-killer机制,把消耗内存最多的进程kill掉。

Swap带来的问题

即使在前面的描述中,我们看到Swap给我们带来的诸多好处,但是频繁的发生Swap也会给系统带来性能问题。

因为Swap是存在磁盘上的,而磁盘的读写速度远低于内存的读写速度,当内存不足时,可能会触发频繁的Swap,导致系统性能的下降。

由于上述Swap可能带来的潜在问题,Docker Container中默认是关闭Swap的。

Java内存分配

JVM内存结构

要想弄明白各个数据区存放了哪些数据,可以分析一下如下Java 代码:

分析代码以及对应的内存分布不难得出:

  • 堆:堆中主要存放的是对象实例和数组,该内存被所有线程共享。
  • 方法区: 也是线程共享的内存区域,主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。
  • 栈: 是线程隔离的内存区域,主要用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。

系统层面排查思路

系统内存使用情况

首先通过free命令查看系统整体的内存使用情况,并判断系统是否开启了Swap。

进程内存使用情况

接着通过top命令观察进程列表的内存使用情况,分析哪些进程的内存使用占用最高,且在持续升高。

同时通过top命令也可以分析出系统当前负载情况,系统CPU使用情况以及各进程CPU的使用率。

内存使用动态变化情况

vmstat命令可以动态的打印系统的内存,CPU以及I/O的使用情况,相比于free命令,vmstat命令更关注于系统整体的动态变化。

对于复杂的系统性能问题,我们往往无法在一开始就确认问题原因,这时vmstat命令就可以帮助我们更全面的了解系统的使用情况,以便于更快速,更精准的找出root cause。

free命令也可以动态打印出系统内存使用情况,相比于vmstat,free命令会更专注于内存的信息。

案例分析

了解了内存分析相关的知识和命令工具使用,让我们来分析一个实际案例吧

代码例子

案例中分析的代码非常简单,一共就分为两个endpoint,一个是对Object的List循环append,另一个是对Class的List循环append。以此模拟Heap space OutOfMemoryErrorMetaspace OutOfMemoryError的场景。

其中non-heap的创建Class的代码,是从网上找到的代码示例,主要是运用ClassLoader, ClassWriter以及Metaspace来构造Class,具体代码过长就不展示了。

Heap space OutOfMemoryError

在启动Java服务时,我们加上了-XX:+HeapDumpOnOutOfMemoryError的配置,该配置可以让服务发生OutOfMemoryError时自动dump内存映像文件。

启动服务后,我们调用/heap endpoint。


通过free命令每隔3秒打印的信息,可以看出随着/heap endpoint的调用,系统free内存在不断下降,在内存不足时,buffers区域会进行缓存回收,提供给应用服务使用。

接着使用top命令观察进程内存使用情况,从输出结果可以发现PID为64960的Java进程占用了199%的CPU和32%的内存,且观察到第三行%Cpu(s)中用户空间使用率为99.3%,而内核空间使用率仅为0.2%,由此推断应用程序可能存在死循环或者阻塞调用。

同时一段时间后,Java服务也抛出了OutOfMemoryError: Java heap space的异常,并且自动dump出内存溢出时的内存映像文件。

接着我们使用Eclipse Memory Analyze Tool (MAT)来分析一下内存映像。

导入hprof内存映像文件,进入Leak Suspect界面,可以看到内存占用饼图和相关的Problem Suspect,从中可以很快找到占用内存最多的区域来自于MemoryController类,并且大部分为**Object[]**。

接着进入Histogram界面,可以发现占用率较高的对象是User

最后来到dominator_tree界面,找到占用率最高的Class并逐个展开child tree,最终可以发现内存溢出的原因是userList循环append导致的。

Metaspace OutOfMemoryError

接下来分析一下Metaspace OutOfMemoryError的案例。

为了让内存溢出的影响更明显,在运行Java服务时,增大了MetaspaceSize的值,同样的也加上了-XX:+HeapDumpOnOutOfMemoryError的配置,以便导出内存映像文件。

启动服务后,向/non-heap endpoint发起请求,并使用free -w -s 3命令观察系统内存的动态变化。

从输出结果发现,这一次当内存不足(内存使用率超过了80%)时,系统让出了更多的buff/cache区以供应用服务使用,并且惊奇的发现available的值竟然小于free的值。

通过查询Linux内核代码发现,MemAvailable的计算公式如下:

1
MemAvailable = MemFree - LowWaterMark + (PageCache - min(PageCache / 2, LowWaterMark))

其中LowWaterMarkmin_free_kbytes参数影响,所以当空闲内存较小时,且系统设置了min_free_kbytes的情况下,就会出现available的值小于free的值的现象。

接着我们使用htop命令观察进程内存使用情况,通过输出结果可以发现Java服务的CPU和内存使用率最大。

回到Java服务的日志,发现已经出现了OutOfMemoryError: Metaspace的报错,且dump出了案发现场的内存映像文件。

将内存映像文件导入MAT进行分析,得到如下的report:

Leak Suspect界面,可以看到Suspect中指出91%的内存都被Object[]占用,且主要的内存消耗来源于createClass()方法调用以及ArrayList对象。

最后进入dominator_tree界面,发现内存占用最大的部分来自于承载ClassArrayList

通过以上输出结果,得知问题原因在于Metaspace的createClasses()方法调用,将大量的Class添加到了ArrayList中,导致OutOfMemoryError的发生。