文章目录

  • 引言
  • 什么是内存泄漏?
  • 内存泄漏的原因
  • 1、静态集合类引起内存泄漏
  • 2、监听器
  • 3、各种连接
  • 4、内部类和外部模块的引用
  • 5、单例模式
  • 模拟内存泄漏
  • 1.1 写一段内存泄漏的代码
  • 1.2 打包jar部署到服务器
  • 1.3 请求接口
  • 二、确定频繁Full GC现象
  • 1.1 查看Java进程ID
  • 1.2 查看GC信息
  • 1.3 查看内存中存活的对象情况
  • 1.4、生成堆转储快照dump文件
  • 1.5、可视化分析dump文件
  • 1.5.1 导入dump文件
  • 1.5.2 定位到代码
  • 1.5.3 补充GC回收
  • 三、常用 JVM 调优启动参数


引言

某个业务系统在一段时间突然变慢,我们怀疑是因为出现内存泄露问题导致的,于是踏上排查之路。

什么是内存泄漏?

内存泄露 Memory Leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

Memory Leak会最终会导致Out Of Memory!

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点

  1. 首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;
  2. 其次,这些对象是无用的,即程序以后不会再使用这些对象。

内存泄漏的原因

以下四种场景可能会导致内存泄露

1、静态集合类引起内存泄漏

像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。

参考:模拟场景1

2、监听器

在java 编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如 addXXXListener() 等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

参考:xxx

3、各种连接

比如:数据库连接(dataSourse.getConnection()),网络连接( socket )和io连接,除非其显式的调用了其close()方法将其连接关闭,否则是不会自动被GC 回收的。

参考:xxx

4、内部类和外部模块的引用

内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。

参考:xxx

5、单例模式

不正确使用单例模式是引起内存泄漏的一个常见问题,单例对象在初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部的引用,那么这个对象将不能被JVM正常回收
由于单例的静态特性使得其生命周期跟应用的生命周期一样长,所以如果使用不恰当的话,很容易造成内存泄漏。

参考:模拟场景五

模拟内存泄漏

1.1 写一段内存泄漏的代码

/**
 * <p>
 * 模拟场景1: 静态集合属性内存泄露导致OOM
 * JVM启动参数 -Xms300M -Xmx300M -Xmn200M (设置为300M可以查看内存泄漏,但恰好又不会发生内存溢出)
 * </p>
 */
@RestController
@RequestMapping("/memory")
public class MemoryLeakController {
    //静态集合
    public static List<Double> list = new ArrayList<>();

    @GetMapping("/leak")
    public void testMemoryLeak () throws InterruptedException {
        Thread.sleep(10000);
        System.out.println("Debug Point 1");

        this.populateList();
        System.out.println("Debug Point 3");

        Thread.sleep(10000);
        System.gc(); //显示触发Full GC (若是静态属性,则不起作用; 若是非静态属性,则内存马上回收)
        Thread.sleep(Integer.MAX_VALUE); // 目的:让进程一直存在,不结束。
    }

    // 往list属性中循环添加指定的随机数
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            // Double 占用内存字节240000000(大约240000000/1024/1024 = 228M)
            list.add(Math.random());
        }
        System.out.println("Debug Point 2");
    }
}

总结
MemoryLeakController 类中存在一个静态的 list,代码中循环添加 Double 类,由于静态常量属于强引用不会被GC回收,最终结果就是导致内存一直被占用,即内存泄漏。
解决方法(针对集合)

最好不要定义成静态属性,在代码逻辑运行完后,将大集合对象调用clear()方法清空内容, 如:list.clear();(不要直接设置为null)

/**
 * <p>
 *  模拟场景1: 单例模式内存泄露导致OOM
 *  JVM启动参数 -Xms30M -Xmx30M -Xmn20M (设置为30M可以查看内存泄漏,但恰好又不会发生内存溢出)
 * </p>
 */
@RestController
@RequestMapping("")
public class TrashRecyslingController {

    @Autowired
    FinalClassVariables finalClassVariables;

    /**
     * 重新加载所有缓存
     **/
    @GetMapping("/")
    public void testTrashRecys() {
        finalClassVariables.setFinalVari();
    }
}


@Component
public class FinalClassVariables {
    final Map<String, TestBeanTrash> finalVari = new HashMap<>();

    public void setFinalVari () {
        for (int i = 0; i < 1000000; i++) {
           String uuid = IdWorker.get32UUID();
           finalVari.put(uuid, new TestBeanTrash(uuid));
        }
    }
}

总结
通过Java 中IOC 机制默认注入的对象都是单例的对象,在单例对象中声明成员变量时,如果变量是个能够无限自增扩容的数据结构时,需要注意及时释放这个对象,否则就会导致对象被无限扩容,最终导致内存泄漏;
解决方法

①将Bean生成方式改成非单例模式;

②在Map对象使用完毕之后将对象中的值清除掉;

③将Map对象降级成局部变量;

个人推荐使用第③种方式,写代码的人不同,很有可能漏掉①②步

1.2 打包jar部署到服务器

参数解释

nohup 表示不挂断的运行, 注意并没有后台运行的功能,用 nohup 命令可以使命令永久的执行, 和客户端没有任何关系

& 表示是后台运行

-Xms20M -Xmx20M -Xmn10M

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof 表示为了发生OOM的时候会自动导出Dump文件

  • nohup java -jar -Xms20M -Xmx20M -Xmn10M -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof & 输入此命令运行项目
  • nohup java -jar xxx.jar & 可以让jar包一直后台运行
  • java -jar -Xms300M -Xmx300M -Xmn100M ums-0.0.1-SNAPSHOT.jar 设置jvm参数并运行项目、

java 查内存泄漏问题 java排查内存泄漏_java

1.3 请求接口

部署之后请求一下接口: http://127.0.0.1:8088/memory/leak

二、确定频繁Full GC现象

说明:

  1. window 环境操作的(与 linux 没有区别 )
  2. 进程ID 简称为PID

1.1 查看Java进程ID

  • jps -l 显示当前所有 Java 进程ID 的命令 (进程ID为18441)

1.2 查看GC信息

参数解释:

gcutil 表示[已使用空间]占[总空间]的百分比。

1000 表示每多少毫秒查询一次。

2 表示每多少毫秒查询2次,若不指定,表示一直查。

  • jstat -gcutil 18441 1000 2jstat -gc 18441 1000 2 表示1000毫秒查询2次,监视虚拟机各种运行状态信息。

java 查内存泄漏问题 java排查内存泄漏_jvm_02

1.3 查看内存中存活的对象情况

若存活的数据不正常,十有八九就是泄露

  • jmap -heap 18441 查看进程的JVM占用内存情况
  • jmap -histo:live 18441 显示堆中当前活动的所有对象的统计信息,按实例对象数量从高到低显示。重点关注实例对象数量过多的类。并找到对应程序。
  • jmap -histo 18441| head -n 10 查看前10的对象统计信息(仅用于linux, window执行不了)

java 查内存泄漏问题 java排查内存泄漏_jvm_03

可以看出Dobule的实例对象有100万,占用内存大约22.8M的样子。这肯定不正常。

下一步就是如何定位到代码?

1.4、生成堆转储快照dump文件

format=b 表示输出为二进制;
heap.dump 表示输出的文件名为heap.dump(可指定相对路径或绝对路径);
pid 表示进程ID

注意:使用jmap dump堆信息时,会触发Full GC, 触发Full GC 可能导致线上服务不可用,因此,要慎重使用,所以尽量不要在线上执行此命令。如果想dump堆信息,可以使用gcore命令,比jmap -dump快。

  • jmap -dump:format=b,file=heap.dump 18441 生成堆转储快照dump文件(即:导出堆信息)
  • jmap -dump:format=b,file=heap.hprof 18441 生成堆转储快照dump文件(即:导出堆信息)

java 查内存泄漏问题 java排查内存泄漏_jvm_04

1.5、可视化分析dump文件

本地工具 IBM HeapAnalyzermat , jvisualvm

在线工具 https://fastthread.io/, https://heaphero.io/

只演示利用eclipse插件MAT来分析heap profile。(官网可以下载,解压后直接使用

1.5.1 导入dump文件
  1. 打开mat工具
  2. 点击 file -> Open Heap Dump,选择刚才的文件( 18441.dump )
  3. 等待加载完成,如下图

java 查内存泄漏问题 java排查内存泄漏_内存泄漏_05


java 查内存泄漏问题 java排查内存泄漏_java_06

List 占用了空间的 43.9%,基本可以断定为是 List没有被回收导致的内存泄漏

1.5.2 定位到代码

查看代码中使用到 List 地方的代码即可定位。

java 查内存泄漏问题 java排查内存泄漏_java 查内存泄漏问题_07

1.5.3 补充GC回收

java 查内存泄漏问题 java排查内存泄漏_java 查内存泄漏问题_08

java 查内存泄漏问题 java排查内存泄漏_java 查内存泄漏问题_09

java 查内存泄漏问题 java排查内存泄漏_java_10

内存泄漏的原因分析,总结出来只有一条:存在无效的引用!良好的编码规范以及合理使用设计模式有助于解决此类问题。

三、常用 JVM 调优启动参数

  • -verbose:gc 输出每次 GC 的相关情况
  • -verbose:jni 输出native方法调用的相关情况,一般用于诊断jni调用错误信息
  • -Xms n 指定jvm堆的初始大小,默认为物理内存的1/64,最小为1M;可以指定单位,比如k、m,若不指定,则默认为字节
  • -Xmx n 指定jvm堆的最大值,默认为物理内存的1/4或者1G,最小为2M;单位与-Xms一致
  • -Xss n 设置单个线程栈的大小,一般默认为512k
  • -XX:NewRatio=4 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
  • -Xmn 设置新生代内存大小。整个堆大小=年轻代大小 + 年老代大小 + 持久带大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8
  • -XX:SurvivorRatio=4 设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
  • -XX:MaxTenuringThreshold=0 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率