Mat内存泄漏分析
1、分析背景
1.1、什么是内存泄漏
内存泄漏是我们经常听见的一个词,其定义是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢设置系统崩溃的严重后果。那么在JAVA中也是同样适用的,但是对于JAVA的内存泄漏通常是指堆区域的内存泄漏,因为Java的GC回收机制只是针对堆内存和方法区而言。相信图1-1大家肯定不会陌生,通常来说Java的内存泄漏是指程序在申请内存后,无法释放已经申请的内存空间,无用对象持续占有内存或无用对象的内存得不到及时释放,从而造成内存空间的浪费,内存泄漏的最终结果就是导致OOM(Out Of Memory)。我们可以将内存泄漏视为疾病,将OutOfMemoryError视为症状,但是并非所有的OOM都意味着内存泄漏,也并非是所有的内存泄漏都表现为OOM【1】。
内存泄漏是非常糟糕的事情,因为程序执行期间泄漏的内存块不仅会降低系统的性能,更糟糕的后果是导致JVM crash,甚至是系统的崩溃。因为分配但未使用的内存块必须在系统耗尽空闲物理内存时进行换出。最终,程序甚至可能耗尽其可用的虚拟地址空间。
图1-1 JVM布局图
1.2、内存泄漏的危害
1.2.1、频繁GC
我们知道JVM的内存管理实际上就是对象的分配和释放问题,在Java中通过关键词new的每个对象申请空间(除开基本类型),所有对象都在Heap中分配空间。而对象的释放是由GC(garbage collection)决定和执行。由于内存的分配和释放都是由程序完成,因此极大的简化了Java程序员的工作,但是却加重了JVM的工作。因为为了正确的释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用和赋值等,GC都需要进行监控。
了解JVM GC的同学肯定听过Minor GC和Full GC,在我们的Eden区不够new新对象的时候就会触发Minor GC,而在老年代剩余空间不足以存放升级到老年代对象的时候就会触发Full GC,或者被设置的HandlePromotionFailure强制执行Full GC。因为不管是什么GC,怎么触发GC的,我们始终要记住一个单词:stop-the-word,该动作会在任何一种GC算法中发生。stop-the-word以为这JVM因为需要执行GC而停止了应用程序的执行,那么频繁的GC必定会使用户感受到明显的卡顿,影响用户的体验。
1.2.2、OOM(Out Of Memory)
前面我们已经说了内存泄漏是病症,OOM是引发的症状,如果说在上面频繁GC的情况下程序还能保证正常的执行,那么在更加糟糕的情况下是GC已经无法清理出更多的空间了,那么对于任何内存资源申请的操作(包括new一个对象)都会导致失败,而且还可能导致维持JVM运行的必要内存大小被吃满,最终导致JVM的Crash(JVM退出不一定导致JVM Crash)。在该种情况下可能会拖累服务器,导致服务器性能降低甚至崩溃。
1.3、谁是“垃圾”
要分析Java的内存泄漏首先要明确我们分析的目标,在刚开始接触Java的时候都听说Java相较于C++由强大的垃圾回收机制,我们申请的对象都不用自己管理,GC会替我们在不需要的时候自动回收该对象的资源。那么GC都是怎么回收的?怎么判断谁是“垃圾”?怎么判断在何时回收?
1.3.1、谁是“垃圾”
目前常用的两种确定谁是“垃圾”的算法有引用计数和根可达性分析算法两种。引用计数算法对于任意都想都记录一个其引用数,当引用数为0的时候就回收该对象的资源,该算法实现简单但是面临着循环引用的问题,如图1-2所示。那么出于该考虑Java采用的是根可达性分析算法实现的GC,如图1-3所示,在JVM中所有对象之间的引用关系会
图1-2、循环引用示意图
图1-3、可达性分析示例
组织成一棵引用树,我们可以从根节点对象出发,将所有的可达性节点都打上标记,那么在进行GC的时候我们就将没有标记的对象GC掉。从图1-3也可以看出可达性分析算法可以有效的解决循环引用的“垃圾”,也能有效的解决普通的无用对象。
1.3.2、如何产生的内存泄漏
既然素质教育(GC)这么优秀,谁是“垃圾”一下就找出来了,然后直接给回炉重造,那么为什么还会有漏网之鱼呢(就像我们这些素质教育的漏网之鱼)?其实问题就出现在我们程序猿本身,如图1-3所示,假设我们有的人想当海王(我就不念身份证了),O对象明明不需要M对象或者只是在最开始的地方需要使用一下,但是它却偏偏要引用着对象M,吊着别人然后又不和别人在一起。那么这个蜜汁操作就会迷惑到我们的GC,它就认为对象M和对象N是可达的,那么就不会对其进行回收,久而久之就造成了对象积压,内存的泄漏。当然上面说的只是一种例子,有得对象长期持有不需要或者明明只需要短期使用的对象不释放,如图1-4[2,3]展示了常见的内存泄漏。
图1-4、常见内存泄
下面是就上图中的某些内存泄漏的解释:
a)、静态集合类,如将HashMap和ArrayList等申请成静态变量,那么它们将与程序结拜,不求同年同月同日生,但是肯定会同年同月死。那么容器中的所有对象都跟着不能被释放,从而导致内存的泄漏。(长生命周期持有短生命周期的引用)。
b)、各种连接,如数据库连接,网络连接,IO连接等,如果没有显示的调用关闭函数,那么会导致大量的对象无法被回收,从而引起内存的泄漏。
c)、变量的作用与大于本身的使用范围。
d)、改变hash值,因为修改了hash的字段,导致无法找到之前存储的对象引用,无法回收。
e)、过期引用未及时清空。
f)、缓存泄漏,当对象被引入缓存的时候忘记清除。
g)、内部非静态类,内部非静态类默认持有外部类的强引用,因此当内部类被长期饮用的时候,外部类对象将无法回收。
2、如何排查内存泄漏
2.1、工具
使用强大的工具进行内存泄漏的分析是必不可少的,我们不可能逐行代码的查看哪里产生了内存泄漏,也不可能从海量的日志文件中快速定位内存泄漏的位置,下面我将介绍几种内存泄漏的分析工具。
a)、JProfiler,JProfiler 是一个商业授权的 Java剖析工具,用于分析Java EE和Java SE应用程序,IDEA上面有对应的插件。JProfiler是一款非常强大的Java性能分析工具,不仅可以监控Java的内存使用情况,还可以监控CPU负载、实时内存、线程运行情况等,并且具备友好的可视化界面,JProfiler操作界面展示[4]。
b)、jconsole,jconsole是从Java5引入的内置性能分析器,可以从命令行或在HUI shell中运行,操作简单,有可视化界面相对而言比较友好(虽然不是很精致)[5]。
c)、Java visualVM,Java visualVM也是JDK自带的性能分析工具,其集成了多个 JDK 命令行工具的可视化工具,可以提供强大的分析能力,对 Java 应用程序做性能分析和调优[6]。
d)、JRockit,需要使用的JVM是JRockitVM才行,虽然功能还不错,但是已经很少被使用了。
e)、MAT(memory Analyzer tool),MAT是一款非常强大的内存分析工具,在Eclipse中有相应的插件,同时也有单独的安装包。在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。MAT包含强大的统计功能,可以将内存中对象、类、堆栈、GCRoot到对象的引用路径和线程信息等统计归纳并用可视化的方式呈现在我们面前,这样,整个内存信息就一览无余地显示在了我们的面前[7]。
2.2、MAT使用
2.2.1、MAT安装
MAT的安装可以参考下面的文章:MAT安装教程
2.2.2、hprof和dump文件
MAT能打开的文件包括hprof文件(Java内存镜像文件,其中包含了内存堆栈的详细使用信息)和dump文件,hprof文件我们可以在启动程序的命令行中添加运行参数:
-XX:OnOutOfMemoryError=/bin/kill -9 %p
-Djava.io.tmpdir=/your_application/var/tmp
只要添加了上面的两个参数,当有OOM异常出现的时候,JVM就会将当前的虚拟机的堆栈信息以及内存中对象的信息放入hprof文件中,名字是大概java_pid加上进程号,比如:java_pid11656.hprof。dump文件是进程的内存镜像,可以把程序的执行状态、堆栈信息和对象的信息通过调试器保存到dump文件中,可以使用jmap命令导出dump文件,命令示例:
jmap -dump:live,format=b,file=xxxx.bin 进程ID
2.2.3、MAT基础使用
利用上面的方式我们可以获取到hprof和dump文件,那么我们现在可以通过MAT将文件导入,首先按照图2-1打开文件,如果是dump文件记得在打开文件的时候选择打开文件的种类为All Files。
图2-1、如何打开hprof和dump文件
用MAT打开一个hprof文件后一般会进入如图2-1的overview界面,或者和这个界面类似的leak suspect界面,overview界面会以饼图的方式显示当前消耗内存最多的几类对象,可以使我们对当前内存消耗有一个直观的印象。但是,除非你的程序内存泄漏特别明显或者你正好在生成hprof文件之前复现了程序的内存泄漏场景,你才可能通过这个界面猜到程序出问题的地方。如图2-1所示肯定出现了问题,有个对象占据了1.5G的内存空间!下面我们来看看MAT的其他基本功能如,图2-3所示,其可以用不同的统计图和不同的维度展示堆栈的使用情况。
图2-2、MAT的Overview界面
图2-3、MAT主界面基本功能
在展示后续功能的时候需要掌握两个基本概念,shallo heap和retained heap,其实简单来说就是前者是对象自己占据的存储空间,而后者是对象及其引用的对象占据的空间总和(其实不太正确这么说,但是基本上是这个意思,除了图2-4所示的情况),那么当该对象被回收的时候retained heap就是我们能够回收到的空间,MAT是通过分析引用树实现的retained heap的计算。图2-4展示了一棵引用树,我们现在来计算对象C的retained heap,那么应该是包含对象D、F、E、G、H,因此retained heap就是C加上这些对象,但是如果我们的C不指向D,那么retained heap就是:E、G,特别注意没有H。
图2-4 特殊的retained heap案例
图2-5 Histogram功能展示
图2-5展示了Histogram界面显示的信息,其中包含了对象数量,shallow heap和retained heap,除此之外我们如果双击某个类还能展现出额外的功能,List Object可以展示引用和被引用的情况,Merge shortest Paths to GC Roots可以展示对象到GC Root的最短路径,可以用于帮助我们发现聚集的垃圾对象即内存泄漏的地方。树状结构的界面展示和使用类似Histogram,在此就不赘述了。OQL的学习可以参考如下的博客:MAT基础使用教程。
3、利用Mat实现内存泄漏分析
3.1、OOM案例模拟
图2-3展示了有个功能是内存泄漏分析,那么到底是怎么分析的?下面我们来看一个实际案例,我们将IDEA的堆内存调成64MB,然后让OOM的时候自动导出内存的快照,可以在IDEA中添加如图3-1的参数:
-Xmx64m:修改堆的最大内存为64M
-XX:+HeapDumpOnOutOfMemoryError:出现OOM的时候自动生成dump文件
图3-1 IDEA模拟OOM的准备工作
然后我们写一段劣质代码来撑爆内存:
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class OOMTest {
public static void main(String[] args) {
oom();
}
private static void oom(){
Map<String,Pilot> map = new HashMap<>();
Object[] array = new Object[1000000];
for(int i = 0;i < 1000000;i++){
String d = new Date().toString();
Pilot p = new Pilot(d,i);
map.put(i+"_RuanCong",p);
array[i] = p;
}
}
}
运行这段程序一会就能看见OOM错误,在左侧就可以看见dump出来的dump文件,随后我们用MAT打开,可以看见下面的Problem Suspect分析,其可以明确的指出在哪个线程的哪个变量持有了多大的内存,是被哪个加载器加载的,然后还可以点击detail显示具体的对象泄漏情况,
图3-2 OOM的内存泄漏分析报告
图3-3 OOM的detail
从图3-4中可以很轻松的看出来,在线程java.lang.Thread @ 0x7bdbc4a30 main中有个HashMap对象的shallow heap和Retained Heap完全不成比例,这时候我们就要思考了,这个hashMap肯定是长期存在并且持有了大量的对象,导致大量的对象得不到释放,从而造成的内存泄漏,然后我们再回头看代码确实也是这样,当然这只是一个简单的流程分析示例,在实际的场景中会遇见比这个复杂很多的案例,下面我们来分析一个实际案例。
3.2、知识产权服务内存泄漏实际案例分析
case是分析知识产权服务是否存在内存泄漏,首先从服务器下载堆栈快照dump文件到本地,然后将dump文件倒入MAT进行分析,可得到如图3-4所示的Overview界面:
图3-4 0728dump文件分析Overview展示
其实从这个饼图我们就能看见有个超级大的对象,正常是不可能有这么大的对象(毕竟足足占据了1.5G),我们先点击泄漏分析,看看泄漏的报告是什么。点击detail后可以获取到如图3-5的界面,从描述来看我们可以定位到org.apache.http.impl.conn.PoolingClientConnectionManager类对象被大量持有,接着看下面的
Common Path To the Accumulation Point(到达聚集点的路径),我们可以看出这个聚集对象是存储在一个ArrayList中的,因此初步怀疑是有人在不停的new对象然后add到了ArrayList中,但是ArrayList生命周期肯定很长,导致短期的org.apache.http.impl.conn.PoolingClientConnectionManager对象没有及时释放导致的内存泄漏,因为正常是不可能出现这么大的list。
3-5 leak suspects界面
然后是问强哥确定是哪个服务,拉去下来进行代码的分析,直接根据关键类com.aliyun.oss.common.comm.IdleConnectionReaper 全局搜索,然后定位到该类如图3-6所示,其中果然有个ClientConnectionManager的ArrayList,然后再继续往自己的代码定位,直接用IDEA的find usages发现没有直接引用的代码,那么我们直接看我们自己的哪些代码中包含了这个类所在的jar包。
图3-6 IdleConnectionReaper类
搜索的时候如图3-7所示,直接搜索使用这个包的地方,从最长的名字开始搜索,没有就少搜索一个层级,最后是在com.aliyun.oss搜索到了我么你自己的业务类OssProxy。整个业务类不是很复杂,我就把整个代码贴上来。
图3-7 定位引用该包的业务类
package com.jindidata.cloud.intellectual.property.proxy;
// 直接通过使用的这两个类定位问题
import com.aliyun.oss.OSSClient;
import com.aliyun.oss.model.OSSObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
@Service
@Slf4j
public class OssProxy {
private static final String endpoint = “http://oss-cn-beijing.aliyuncs.com”;
private static final String accessKeyId = "LTAI4FhGuYSS7TFQZmXuMWue";
private static final String secretAccessKey = "4rOxVxfOa88GxgYyosPZ1ejtkmrNvJ";
private static final String bucketName = "jindi-oss-wangsu";
/**
* 读取oss中内容
*
* @param key
* @author lfy
* @date 2021-07-07 18:14
*/
public String getContent(String key) {
if (StringUtils.isBlank(key)){
return null;
}
// 每次new了需要释放资源才行
OSSClient ossClient = new OSSClient(endpoint, accessKeyId, secretAccessKey);
if (!OSSClient.doesObjectExist(bucketName, key)) {
// 知道这里就发现了个内存泄漏点,return前ossClient没有释放连接!!
return null;
}
OSSObject ossObject = ossClient.getObject(bucketName, key);
BufferedReader reader = new BufferedReader(new InputStreamReader(ossObject.getObjectContent()));
StringBuilder sb = new StringBuilder();
try {
while (true) {
String line = reader.readLine();
if (line == null) {
break;
}
sb.append(line);
}
String oriStr = sb.toString();
if (oriStr.length() >= 2 && oriStr.startsWith("\"")){
oriStr = oriStr.substring(1);
}
if (oriStr.length() >= 2 && oriStr.endsWith("\"")) {
oriStr = oriStr.substring(0, oriStr.length()-1);
}
if (StringUtils.isBlank(key)) {
return null;
}
oriStr = oriStr.replaceAll("\\\\s","")
.replaceAll("\\\\n", "")
.replaceAll("\\\\", "");
return oriStr;
} catch (IOException e) {
log.error("OssProxy.getContent oss,key={},error", key, e);
} finally {
try {
// 内存泄漏点2,直接是用完了ossClient没有释放连接!!
reader.close();
} catch (IOException e) {
log.error("OssProxy,getContent oss,key={},error", key, e);
}
}
return null;
}
}
至此有很大概率怀疑是这里出现了问题,为了严谨起见,我查阅了OSS的官方文档【8】:OSS官方文档, 发现在官方文档中别人说了在使用资源后需要显式的释放资源,调用shutdown()函数实现,那么至此确定出来了内存泄漏的具体位置,就在于这个链接的地方,然后利用IDEA的annote就可以看出是哪个小伙伴写的内存泄漏了(哈哈哈,犯罪侠现场)。为了搜集证据,我们继续深入源代码会发现我们的OSSClient每次在new的时候都会注册到IdleConnectionReaper类中,并且加入ArrayList,该类是一个独立的线程,但是只有在调用了Shutdown函数的时候添加进入的ClientConnectionManager才会从arrayList中删除,否则就会一直被持有,那么最后久而久之就会造成内存泄漏。因为跟踪源代码发现OSSClient底层是封装的httpClient,虽然这个客户端会超时释放,但是OSSclient还持有很多其他的资源,并没有超时释放的操作。
4、总结
上面的实际案例最终的原因分析其实就是资源的使用完成之后没有被释放,其实除了OSSClient还有数据库连接、IO链接、网络连接我们使用完成之后都要记得及时释放,还有个陷进就是我们创建成功后,哪怕进行了某些判空的操作提前返回了,在返回之前一定记得要释放资源!!!!还有就是使用集合的时候记得及时释放已经使用过并且使用不到的资源,下面用一个实际案例说明,看看你能不能发现问题:
import java.util.Arrays;
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
5、参考文献
【1】、 什么是内存泄漏
【2】、 Notzuonotdied–内存泄漏分类–CSDN
【3】、ratelfu–Java中内存泄漏8种情况的总结
【4】、Java性能分析工具–JProfiler
【5】、Java性能分析工具–JConsole
【6】、Java性能分析工具–VisualVM
【7】、利用MAT进行内存泄漏分析
【8】、OSS官方操作文档