Bootstrap

《企业实战分享 · 内存溢出分析》

📢 大家好,我是 【战神刘玉栋】,有10多年的研发经验,致力于前后端技术栈的知识沉淀和传播。 💗
🌻 近期刚转战 CSDN,会严格把控文章质量,绝不滥竽充数,如需交流,欢迎留言评论。👍


写在前面的话

书写背景

博主所在公司采用的是技术栈为:后端 SpringCloud,前端 Nuxt,部署 K8S。(看过前面文章的应该知道)
由于涉及服务较多,代码量也随着需求研发不断增加,难免出现一些臭鱼烂虾的隐患代码。
最终导致的是,线上服务的内存溢出问题屡见不鲜,每次排查都要依靠架构等少数人员,现着手整理了一份分析内存溢出问题的流程文档,方便研发主管人员可以自行排查,而不需要依赖架构部门。
此篇博文将以上述文档为基础略为调整,发布到博客,若出现一些未及时调整的话术,希望理解。

知识补充

Tips:这部分纯属知识补充,可以跳过。

关于内存泄漏和内存溢出
一般来说内存异常问题分为内存泄漏和内存溢出。
内存泄漏:对象已经不使用了,但是还占用着内存空间,没有被释放。
内存溢出:堆空间不够用了,通常表现为 OutOfMemoryError,内存泄漏通常会导致内存溢出。
再看一下“内存泄漏”的定义:一个不再被程序使用的对象或变量还在内存中占有存储空间。
一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
“内存溢出”:指程序申请内存时,没有足够的内存供申请者使用,或者说,给了你一块存储 int 类型数据的存储空间,但是你却存储 long 类型的数据,那么结果就是内存不够用,此时就会报错OOM,即所谓的内存溢出。
二者的关系:内存泄漏的堆积最终会导致内存溢出
**内存泄漏 :**是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。就相当于你租了个带钥匙的柜子,你存完东西之后把柜子锁上之后,把钥匙丢了或者没有将钥匙还回去,那么结果就是这个柜子将无法供给任何人使用,也无法被垃圾回收器回收,因为找不到他的任何信息。
**内存溢出:就是你要的内存空间超过了系统实际分配给你的空间,此时系统相当于没法满足你的需求,就会报内存溢出的错误。

内存溢出种类与解决
JVM 管理的内存大致包括三种区域:Heap space(堆区域)、Java Stacks(Java 栈)、Permanent Generation space(永久保存区域)。由此,OOM 简单的分为堆溢出、栈溢出、永久代溢出(常量池/方法区)。Java 程序的每个线程中都有一个独立的堆栈。

PS:我们日常开发中,遇到最后的就是堆内存溢出,这里重点介绍这块,其他两块自行了解。

Java 堆内存溢出
Java 堆是线程共有的区域,主要用来存放对象实例,几乎所有的 Java 对象都在这里分配内存,也是 JVM 内存管理最大的区域。Java堆内存分年轻代和年老代,堆内存溢出一般是年老代溢出。当程序不断地创建大量对象实例并且没有被GC回收时,就容易产生内存溢出。当一个对象产生时,主要过程是这样的:
1、JVM首先在年轻代的Eden区为它分配内存;
2、若分配成功,则结束,否则JVM会触发一次Young GC,试图释放Eden区的不活跃对象;
3、如果释放后还没有足够的内存空间,则将Eden区部分活跃对象转移到Survivor区,Survivor区长期存活的对象会被转移到老年代;
4、当老年代空间不够,会触发Full GC,对年老代进行完全的垃圾回收;
5、回收后如果Suvivor和老年代仍没有充足的空间接收从Eden复制过来的对象,使得Eden区无法为新产生的对象分配内存,即溢出。
由此可见,当程序不断地创建大量对象实例并且没有被GC回收时,就容易产生内存溢出。如下:

public static void main(String[] args){
    ArrayList list = new ArrayList();
    while(true){
        list.add(new heap());
    }
}

堆内存溢出很可能伴随内存泄漏,应首先排查可能泄露的对象,再通过工具检查 GC roots 引用链,从而发现泄露对象是由于何种引用关系使得GC无法回收他们;若不存在内存泄漏,换句话说就是内存中的对象还都需要继续存活,则可通过修改虚拟机的堆参数将堆内存增大。

补充说明:
堆区域用来存放 Class 的实例(即对象),对象需要存储的内容主要是非静态属性。每次用 new 创建一个对象实例后,对象实例存储在堆区域中,这部分空间也被 JVM 的垃圾回收机制管理。
java.lang.OutOfMemoryError: Java heap space 此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。原因是 JVM 创建的对象太多,在进行垃圾回收之间,虚拟机分配的到堆内存空间已经用满了,与 Heap space 有关。
解决这类问题有两种思路:
1、对于内存泄露,可以通过内存监控软件查找程序中的泄露代码。检查程序,看是否有死循环或不必要地重复创建大量对象。找到原因后,修改程序和算法。
2、增加 JVM 中 Xms(初始堆大小)和 Xmx(最大堆大小)参数的大小。如:set JAVA_OPTS= -Xms256m -Xmx1024
PS:JVM大小通常运维人员会考虑一个合适的值,研发要注意的就是第一点。

常见原因

首先排除Java程序的JVM配置问题,代码有哪些常见问题会导致内存溢出:
1、内存中加载的数据量过大,一次从数据库取出过多数据导致内存溢出;
2、集合类中有对对象的引用,使用完后没有及时清空,使得 JVM 不能回收;
3、代码中存在死循环或循环产生过多重复的实体对象;
4、未完待续。。。

排查方式

凭经验肉眼扫描代码
针对上述列的常见导致内存溢出的代码,日常排查代码可以遵循如下轨迹:
1、检查对数据库查询中,是否有一次获得全部数据的查询,一般来说,如果一次取十万条记录到内存,就可能引起内存溢出。这个问题比较隐蔽且有潜伏性,在上线前,数据库中数据较少,不容易出问题,上线后,数据库中数据多了,一次查询就有可能引起内存溢出。所以可以使用分页查询数据库;
2、检查代码中是否有死循环或递归调用导致有大循环重复产生新对象实体;
3、检查 List、MAP 等集合对象是否有使用完后未清除的问题,集合中存在对对象的引用会导致这些对象不能被 GC 回收;

借助工具定位问题
1、服务启动的时候,设置其参数,使其支持在发生内存溢出时自动dump内存快照;

PS:也就是通常说的dump文件,这步运维那边会做好。

2、内存快照文件是后缀为 .hprof 的文件,内存溢出的时候产生,可以从服务器下载到本地;

PS:找运维拿到这个文件,或者从SpringBootAdmin尝试下载。

3、借助内存溢出排查工具 VisualVM、Jprofiler 等,直接加载hprof文件,按步骤定位问题代码;

PS:安装一款你顺手的工具,排查一下。

研发人员如何手动操作
1、进入到对应的容器里面去
docker exec -it 容器id /bin/bash
2、ps -ef | grep java 查看java进程id
3、保存堆和栈的现场快照 ,jstack命令用于打印指定Java进程、核心文件或远程调试服务器的Java线程的Java堆栈跟踪信息
jstack pid > stack.log
jmap -dump:format=b,file=heap.hprof pid
4、退出容器
5、看情况是否重启容器
docker restart 容器id
6、将文件从容器导出到宿主机
docker cp 容器id:要导出的文件路径 宿主机路径
7、再次进入容器删掉 相关文件
8、下载文件导本地进行分析并且删除
9、接下来使用具体工具分析


使用 JProfiler 排查

具体步骤

选择指定的 dump 文件(后缀是hprof),使用 Jprofiler 加载,第一次可能较慢。

文件范例:D:\cjwmy1013\devlop\zoe-optimus-dia-dc-8157.hprof

image.png
如图点击大对象
image.png
image.png
image.png
image.png
image.png

补充说明
若软件提示内存不够,可以加大内存,如下:
C:\Users\cjwmy1013.jprofiler11\jprofiler.vmoptions
-Xmx4036m
-Xss2m


使用 VisualVM 排查

VisualVM是jdk自带的jvm监测工具,主要用来监测cpu、线程和堆内存的使用情况,使用简单,不需要额外配置,可以支持本地和远程环境,软件的位置在jdk的bin目录下,找到jvisualvm.exe,双击打开。
image.png
image.png
image.png

监视视图

选择本地的运行的某个进程双击,选择监视tab按钮就可以查询到当前进程的cpu、内存、类、线程运行信息
image.png

点击堆Dump用来生成某一时刻的堆转储文件快照(.hprof),并且把堆转储信息转换成堆转储标签内,我们可以看到摘要、类、实例数等信息以及通过 OQL 控制台执行查询语句功能,帮助我们分析对象的引用关系、是否有内存泄漏情况的发生。
image.png
堆转储的概要包括转储的文件大小、路径等基本信息,运行的系统环境信息,也可以显示所有的线程信息。
image.png
从类视图可以获得各个类的实例数和占用堆大小数,分析出内存空间的使用情况,找出内存的瓶颈,避免内存的过度使用。
image.png
对两个堆转储文件进行比较,通过比较我们能够分析出两个时间点哪些对象被大量创建或销毁。
image.png
image.png
打开实例数视图需要指定一个类,即在类视图中双击某一个类进入该类的实例数视图,通过下图可以看出主要的信息包含该类的实例(对象)总数,单个实例、总实例占堆内存的大小,单个实例在堆内存中的所有字段、值,以及对象与引用对象之间的依赖关系。
image.png

线程视图

线程视图显示当前进程包含的所有线程,类型分为User Thread(用户线程)、Daemon Thread(守护线程),任何一个守护线程都是整个JVM中所有非守护线程的保姆,Daemon的作用是为其他线程的运行提供便利服务,守护线程最典型的应用就是 GC (垃圾回收器)
image.png
image.png

  • 运行状态:线程正在运行
  • 休眠状态:线程在休眠
  • 等待状态:调用Object.wait的线程,此处要注意,condtion.await并不是此状态,而是下面的状态。
  • 驻留状态:调用了LockSupport.park的线程就是此状态,常见的有如下:
Lock lock = new ReentrantLock();
lock.lock();
Condition condition = lock.newCondition();
condition.await();
  • 监视状态:synchrnoiezed获取锁被阻塞时的状态

如果想保存某个时间节点的线程快照信息,就点击右上角的线程Dump,其实就是执行jstack命令,jstack命令可以生成JVM当前时刻的线程快照。线程快照是当前JVM内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。
image.png

抽样器

抽样器可以对该进程的某个时间段的CPU使用情况进行抽样,即点击CPU样例的快照按钮。
image.png
image.png
image.png
抽样器同样可以对内存使用情况进行实时监测,以及保存快照信息,根据内存大小(字节数)降序排序就能快速定位出占用内存较多的类,下图可以看出是byte[]数组占用了大量内存,如果此时还不能定位到程序哪里的问题,那就通过保存内存的快照,然后分析这个dump。
image.png
根据下图可以看出byte[]数组实例很少,但是占用的内存很多,继续向上查找引用它的实例,最后发现是arrayList中引用了byte[],既然问题找到了,后续的解决方法就是在程序中进行定位,优化现有代码。
image.png
image.png

Visual GC插件

visual gc插件可以清晰的看到堆的使用情况以垃圾收集信息,工具栏选择插件,可用插件搜索visual gc点击安装,
image.png
image.png
安装完毕后,重启visualVM,会出现如下图:
image.png
visual gc分为两个板块:
1、visual gc window(即spaces区域)
Metaspace :方法区的内存,如果JDK1.8之前的版本,就是Perm,JDK7和之前的版本都是以永久
Old:老年代
新生代:由Eden、S0、S1组成

每个方框都使用不同的颜色表示,有颜色的区域是占用的空间,空白的是剩余的空间,当程序运行时,会动态的显示
image.png

2、Graph区域,包含了以时间轴为横坐标的状态面板
Compile Time:编译情况,1407 compoles - 2.644s 表示编译总数为1407,编译总耗时为2.644s。
一个脉冲表示一次JIT编译,脉冲越宽表示编译时间越长。
Class Loader Time:类加载情况,1709 loaded,0 unloaded - 696.184ms表示已加载的数量为1709,卸载的数量为0,耗时为696.184ms。
GC Time:总的(包含新生代和老年代)gc情况记录 52 collections,708.580ms Last Cause:Allocation Failure表示一共经历了52次gc,总共耗时708.580ms。
Eden Space:新生代Eden区内存使用情况
Survivor 0和Survivor 1:新生代的两个Survivor区内存使用情况
Old Gen:老年代内存使用情况
Metaspace:方法区内存使用情况(最大容量,当前容量)
image.png


使用Eclipse Memory Analyzer排查

工具介绍

Java VisualVM只提供了一些基本的功能,所以我们一般不使用Java VisualVM来分析,而是使用Eclipse Memory Analyzer(MAT)来分析,MAT工具是一款强大的Java堆内存分析工具,特点是免费使用,无需安装解压即用,可用于查找内存泄露以及查看内存消耗情况,便于开发或运维人员快速定位内存溢出或内存泄露问题。
需要注意必须安装jdk1.8环境才能使用
下载地址:https://www.eclipse.org/mat/previousReleases.php
解压后,双击MemoryAnalyzer.exe打开
image.png
界面效果如下:
image.png

这边写一个小案例来演示内存不断增加的场景:

public class Main {
    public static void main(String[] args) {
        List<Demo> list = new ArrayList<>();
        while (true) {
            list.add(new Demo());
        }
    }
}

然后设置启动参数,让程序内存溢出时自动生成Dump文件
image.png

-Xmx30m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\dump

-Xmx30m:最大堆内存为30m -XX:+HeapDumpOnOutOfMemoryError:当JVM发生OOM时,自动生成DUMP文件。 -XX:HeapDumpPath:指定文件路径,例如:-XX:HeapDumpPath=${目录}/java_heapdump.hprof。如果不指定文件名,默认为:java_pid.hprof

image.png
当内存溢出的时候自动在指定的目录下生成了一个hprof文件,然后用Eclipse Memory Analyzer打开这个文件:
image.png

点击File-》Open Heap Dump,选择内存溢出生产的.hprof文件,默认选择Leak Suspects Report泄漏可疑报告
image.png
导入成功之后首先来看这个界面,我们先不着急如何分析问题,先快速了解下面板上各个组件的用法,方便后面的学习。
image.png

Overview组件

OverView功能是导入了dump文件之后,对可能出现的问题做了一个整体性的分析概述,首先呈现在眼前的是以一个形状图的方式,快速展现了该文件中dump文件大小,以及类、对象和类加载器的数量,当光标移动到蓝色区域时候,会呈现该日志文件的主要线程,类加载器信息。
image.png
![4E3B`I)FS4I4}B3NN%Z_OQ.png
总共使用的内存为24M Thread对象占用了23M,类加载器占用了206kb,其他对象占用了752kb。

Top consumers组件

展示的是占用内存比较多的对象的分布,下面是具体的一些类和占用
![MSN(4598`T$JEKW~H4RT_]5.png](https://img-blog.csdnimg.cn/img_convert/9b754529578285828253081bd16d22f1.png)

Histogram组件

Histogram 可以列出内存中的对象,对象的个数以及大小
Objects:对象的个数
Shallow Heap:对象所占用的本身内存大小(不包含引用对象)
Retained Heap:对象以及它所持有的其它引用(包括直接和间接)所占的总内存大小

当点击进去之后,为我们呈现出dump文件中,已经创建的主要的对象信息,默认按照对象的个数进行排序,而这个排序,多少也反映出在当前的dump文件中,那些排在前面的数量最多的对象可能是我们分析问题的关键入口
image.png
顶部的可以支持对象名称的模糊匹配,比如搜索Picture,可以快速找到我们需要的对象名称。
image.png
MAT还提供了分组查看功能,方便快速定位类的信息,默认视图就是Group by class根据类分组。
image.png
Group by superclass根据父类分组,所有类的父类都是Object,所以会生成树形结构。
![F`3TDHG8CHI5J3`ZLLPSVM.png
Group by class loader根据类加载器分组
2)3VWXHT23V{~HXP1)N%5Y.png
Group by package根据包目录分组
image.png
除了分组功能,还支持按照字段排序,右键某个对象可以选择按照类名、对象个数、浅堆数量、深堆数量排序,这也是快速定位到需要查找到的对象的一种方式
![(`X]0DS7UYQ4H163JJJ7HE5.png

这里需要了解浅堆、深堆的概念,因为这两个参数是分析OOM的一个关键点。
浅堆:指的是一个对象所消耗的内存(不包括内部引用的对象大小)
深堆:指的是一个对象直接访问或者间接访问到的所有对象的浅堆之和,即对象被回收后,可以释放的真实空间。
浅堆、深堆总结:对象的深堆个数总是多于浅堆,实际开发过程中,可能因为编码的习惯不好,导致某些类中,对象的引用链条特别长,层级也很深,搞不清楚那些对象是实际在使用的,假如正好有那么一些对象实际上并没有使用,但是在某些循环中大量创建,尤其是大对象,在这种情况下,很容易造成GC过程的失败最终引发OOM,所以分析这两个参数对于快速定位那些数量较多的对象还是很有帮助的。

对象引用链:
查看引用当前对象的外部对象可以点击这个右键—》show objects by class-》by incomming references
![KJ(783)WBOKYAUZ4VV({~7.png
查看当前对象引用的外部对象可以点击这个右键—》show objects by class-》by outgoing references
![3[1)Q}RY_M]R}LKQ)KaTeX parse error: Expected 'EOF', got '}' at position 190: …问题的一个很好的点。 ![PP}̲EW~X44IIRB7O03)…W.png

Thread Overview组件

Thread Overview展示出当前dump文件中,所有的线程信息,展示出了所有的线程,主线程,以及各个线程中的浅堆和深堆占用的大小,类加载器,是否守护线程等信息。
![9PZQ%HKT$Y2FYPSGMQ84AM.png
以main线程为例,列举出了里面的成员变量的信息,比如在当前的main线程中,有一个成员变量包含了很多对象,并且浅堆和深堆的大小都很大。
@`_300N$3Q7(VTH4{54)77P.png

Domainator Tree组件

Domainator Tree指的是支配树的对象图,支配树体现了对象实例之间的支配关系,理解支配树的目的是,在我们分析dump文件时,可以通过支配树,清楚的知道某个对象引用的对象情况。
N2OV1)MOJLALB0WC@1OD~4B.png
Retained Heap表示这个对象以及它所持有的其它引用(包括直接和间接)所占的总内存,因此从上图中看,前两行的Retained Heap是最大的,我们分析内存泄漏时,内存最大的对象也是最应该去怀疑的。

Leak Suspects组件

点击Leak Suspects查看具体的内存泄露报告,MAT分析工具会根据你导入的dump文件,快速生成一份怀疑报告,将可能出现内存泄露的点展示出来,便于开发或运维人员进行问题定位。
O_B4E(Q0%(FEJ8VPONHD)NY.png
~M8D57C2KA[9]CC.png
在内存泄漏报告中Thread Stack可以清晰的看到内存溢出代码的位置,对于快速定位程序问题非常方便友好
YW69@($6G}KL5VZPSB1WQ.png

常用分析技巧

第一种:通过Thread Overview线程视图,按照Retained Heap深堆大小排序,展开线程查看,从调用栈中找到当前服务的代码。
第二种:通过histogram内存大小直方图,选择group by package按照包路径分组,再按照Retained Heap深堆大小排序,找到当前服务的代码。
第三种:通过Dominator Tree 支配树,按照Retained Heap深堆大小排序,然后查看深堆最大的线程在持有哪些大对象,接着在该对象右键查看线程详细堆栈信息。

;