JVM堆内存分析

Posted by keys961 on May 28, 2018

1. jmap

jmap工具可以很好地去分析一个Java进程堆内存的情况,并生成dump文件。

常用的有:

  • jmap -heap <pid>: 对PID进程进行堆分析,可以看到堆内存分配的配置,以及堆内存各个区域的占用情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4263510016 (4066.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 1420820480 (1355.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 1103101952 (1052.0MB)
   used     = 43295592 (41.289894104003906MB)
   free     = 1059806360 (1010.7101058959961MB)
   3.924894876806455% used
From Space:
   capacity = 110100480 (105.0MB)
   used     = 109700880 (104.61891174316406MB)
   free     = 399600 (0.3810882568359375MB)
   99.63705880301339% used
To Space:
   capacity = 167247872 (159.5MB)
   used     = 0 (0.0MB)
   free     = 167247872 (159.5MB)
   0.0% used
PS Old Generation
   capacity = 385875968 (368.0MB)
   used     = 251045376 (239.41552734375MB)
   free     = 134830592 (128.58447265625MB)
   65.05856721297555% used

36764 interned Strings occupying 4003016 bytes.
  • jmap -dump:format=b, file=<filename> <pid>: 对PID进程堆内存区域进行dump,生成二进制的转储文件,一般还需要压缩,它可以被用于MAT工具以进一步分析
  • jmap -histo[:live] <pid>: 对PID进程进行堆内存(可选活跃对象)分析,生成统计文本,一般像这样:
1
2
3
4
5
6
num     #instances         #bytes  class name
----------------------------------------------
   1:       1597147       86346272  [Ljava.lang.Object;
   2:       1383173       55326920  java.util.TreeMap$Entry
   3:        585564       37193120  [C
   4:       1495556       35893344  java.util.ArrayList

2. 使用 Memory Analyzer 分析问题

MAT可提供多种分析维度,如:

  • Histogram: 列出内存中的对象,对象的个数以及大小

  • Dominator Tree: 列出大小最大(biggest)的一些对象,并显示出什么让其存活
  • Top Consumers:通过图形列出哪些对象消耗最大(most expensive)
  • Leak Suspects: 分析内存泄漏

以线上一个Full GC Dump的例子,说明一下:

2.1. Histogram

进入这个界面后,可以看到byte[]对象占用了非常大的空间:

1

右键List objects -> with incomming references可以看到这个对象的实例,发现一个75MB的大对象:

2

这里(可参考这里Shallow & Retained Heap):

  • Shallow Heap :一个对象内存的消耗大小,不包含对其他对象的引用;
  • Retained Heap :是Shallow Heap的总和,也就是该对象被GC之后所能回收的内存大小;

我们需要查看其GC Root,右键Path to GC Roots -> exclude all phantom/weak/soft etc. references。可以看到,这个东西被BufferedImage引用,然后被包到一个对象BrandGoodInfo中,被缓存了起来。

3

因此我们怀疑,在配置内存缓存的时候,配置不当,造成BufferedImage对象过多的缓存,内存爆掉,引发Full GC。

2.2. Dominator Tree

结果如下,下面的哪个类是我们用的第二方包,暂时先不考虑,第一个很明显,缓存占了大头,那个70多MB的东西也在那里面,加深了2.1.节的怀疑。

4

2.3. Top Consumer

结果和2.2类似,如下图,缓存这里占了大多数的内存。

5

6

2.4. Leak Suspects

内存泄漏分析只能做一个参考,并不能代表其真正内存泄漏了。如下图,可以看出,问题出在:

  • SummerCacheManager:这是用于管理缓存的,我们这里用它来管理缓存的
  • RestServiceProviderImpl: 项目中我没有看到其自定义配置项,只是引用了外部的配置文件(只读),所以忽略

7

所以极大的可能出在了缓存问题,尤其是BufferedImage的缓存

3. 修改配置

在上文中,BrandGoodInfo被缓存起来,在Caffeine中给了很多的槽,显然是过多了:

8

后来我减少了内存缓存数量,之后直接取消内存缓存,使用磁盘缓存。

而为什么要这么做呢?

下面要说缓存BufferedImage的一些坑:

  • 项目中,BufferedImage的缓存流程是:下载图片 -> 通过ImageIO.read()转成BufferedImage。很有可能的是,下载的图片过大,那么转成BufferedImage的对象也会很大。试想一下,1000个70MB的图片被缓存,岂不是要爆炸?
  • 此外BufferedImage存储的内容是不经过压缩的,你本地磁盘上读取了一个图片文件,转成BufferedImage对象后,大小可能是文件大小的数倍。这是因为BufferedImage的对象大小是要按照位图那一套算法计算的,即像素数 * 单个像素存储大小。一般项目中都是用彩图,即24位。
    • 一个例子:一张1200 * 900的彩图A和黑白图B,大小分别为800KB和100KB,均为JPG格式,但是读到内存里后,大小变为了3MB多,这是因为它们都用彩图存储(ImageIO.read()就是这么处理的),且size = 1200 * 900 * 24 / 8 = 3240000 bytes

所以当要缓存图片或者大对象(包含大量字节串)的时候,可以考虑:

  • 对对象进行压缩(显然BufferedImage压缩比较困难,可以用第三方库?)
  • 另外我认为,对于图像的缓存(或者是大的byte[]对象),应该利用磁盘缓存或者用类似Redis那样的缓存,而不是保存在本机内存里