JVM虚拟机(2):JVM 内存泄漏与内存溢出及问题排查

Java 的一个很重要的优点就是提供了垃圾回收器(Garbage Collection,GC)来自动管理内存,不用向 C 语言样需要开发人员手动来释放内存。但也不等于完全不管内存的使用,系统在线上长期运行后,可能出现内存泄漏耗尽内存,系统卡死或崩溃。

若剩余内存满足不了申请的需要,或申请不到连续内存,就会存在内存溢出问题(OOM),系统就会抛出 Error,导致业务逻辑无法正常执行。

对开发人员来说应该能分析 内存泄漏内存溢出 问题的根源,彻底解决问题,而不仅仅是重启应用或扩大内存。

内存泄漏

定义

内存泄漏(Memory Leak):是指动态分配的堆内存不再使用,但又永完被占用不能被回收(GC)释放,导致对该段内存失去控制,造成内存浪费。

内存泄漏大多是代码层面设计不严谨,或设计错误(编程错误),导致程序未能释放已不再使用的内存。对于 Java 应用,堆中不再使用的对象一直被持有,对象无法被 GC 回收,内存不能释放。

内存泄漏具有隐蔽性,积累性的特征,属于遗漏型缺陷,而不是过错型缺陷,不会直接产生可观察的错误症状,而是逐渐堆积,降低应用的整体性能。

后果

内存泄漏会因为减少可用内存的数量从而降低计算机(或 JVM)的性能,可用内存不足会导致频繁 Full GC,CPU 使用率达到 100%,严重降低应用的处理速度。在极端情况下,可能导致内存耗尽,应用程序崩溃。

少量的内存泄漏带来的后果可能不严重,需要注意的是该段业务逻辑随着应用程序长时间运行产生内存泄漏堆积,就可能导致严重的后果,应用程序的处理速度越来越慢,直到内存耗尽,最终导致内存溢出(OOM),系统崩溃。

原因

在 Java 中,通过关键字new创建对象并申请内存空间,所有的对象都在堆(Heap)中分配空间。对象的释放和内存回收由 GC 决定和执行。

若存在对象到 GC Roots 是通路的,且对象是无用的,不会再被使用,这些对象就可以判断为 Java 中的内存泄漏,一直占着内存,不会被 GC 回收。

或简单描述:即长生命周期对象持有短生命周期对象的引用,尽管短生命周期对象不再使用,但是因为长生命周期对象持有它的引用(短生命周期对象到 GC Roots 是通路的)而导致不能被回收。

以下几种情况可能会导致内存泄漏:

  • 静态集合类:如 HashMap,Vector,静态容器的生命周期与应用程序一致。如果容器内的对象不再使用,则存在内存泄漏。

    预防:尽量不使用静态变量。

  • 各种连接,流:如 数据库连接,网络连接,IO连接,流等。例如,创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。

    预防:使用 finally块关闭资源。

  • 监听器:应用可能会用到多个监听器,但在释放目标对象的同时往往没有删除相应的监听器。

  • 变量不合理的作用域:一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。

    例如,只在方法内使用的对象,定义为了成员变量,方法内使用完后因被外部对象导致不能及时地被回收,就存在内存泄漏。

  • 引用了外部类的非静态内部类:非静态内部类(或匿名类)的初始化总是需要依赖外部类的实例。默认情况下,每个非静态内部类都包含对其包含类的隐式引用,若在程序中使用这个内部类对象,那么即使在包含类对象超出范围之后,也不会被回收(内部类对象隐式地持有外部类对象的引用,使其成不能被回收)。

    预防:如果内部类不需要访问对其包含类的成员,应将其转换为静态类。

  • 单例模式:单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。

    预防:尽可能使用懒加载,而不是立即加载。

  • 改变对象哈希值:当对象存储在一个 Hash 容器中(HashSet,HashMap),就不能修改这个对象中那些参与计算哈希值的字段。

    否则,对象修改后的哈希值与之前存储进容器的哈希值就不同,在此情况下,即使在contains方法使用该对象的当前引用作为的参数去容器中检索对象也是找不到的,这会导致无法从容器中单独删除当前对象,造成内存泄露。

  • 子类重写finalize():每当重写类的 finalize()方法,该类的对象不会立即被垃圾收集。相反,GC 将它们排队等待最终确定,回收将在稍后的时间点发生。如果用 finalize()方法编写的代码不是最佳的,并且终结器队列无法跟上Java垃圾收集器,那么迟早将产生 OutOfMemoryError

    预防:应该总是避免重写 finalize

  • ThreadLocal 造成的内存泄漏:ThreadLocal 可以实现变量的线程隔离,但若使用不当,就可能会引入内存泄漏问题。

    一旦线程结束不再存在,ThreadLocals 应该被垃圾回收。但是当 ThreadLocals 与 Tomcat 一起使用则可能出现问题。

    Tomcat 使用线程池中的线程来处理请求,而不是每个请求都创建新的线程。线程复用情况下,线程中的 ThreadLocals 并不会被回收,即 ThreadLocal 仍被线程对象持有。ThreadLocal 的生命周期不等于 Request 的生命周期,而是与线程生命周期绑定。

    预防:在不使用 ThreadLocals 中的变量时调用 remove() 方法删除当前线程的变量值。

    不能使用 ThreadLocal.set(null) 来清除该值。此操作实际是查找当前线程的 ThreadLocalMap,键是当前线程对象,将值设置 null(这段内容需要看源码比较好量解)。

    最好将 ThreadLocal 视为 finally 块中关闭的资源,以确保它始终会被关闭。

    1
    2
    3
    4
    5
    6
    try {
    threadLocal.set("value");
    //...some processing......
    } finally {
    threadLocal.remove();
    }

排查

处理内存泄漏没有一个通用的或标准的解决方案,但可以根据应用的一些症状表现来分析定位内存泄漏,使用一些方法以最大限度地减少内存泄漏。

内存泄漏可能的症状表现:

  • 应用程序长时间连续运行时性能严重下降
  • CPU 使用率飙升,甚至到 100%
  • 频繁 Full GC,各种报警,例如接口超时报警等
  • 应用程序抛出 OutOfMemoryError 错误
  • 应用程序偶尔会耗尽连接对象

严重内存泄漏往往伴随频繁的 Full GC,所以分析排查内存泄漏问题首先还得从查看 Full GC 入手。主要有以下操作步骤:

  1. 使用 jps 查看运行的 Java 进程 ID

  2. 使用top -p [pid] 查看进程使用 CPU 和 MEM 的情况

  3. 使用 top -Hp [pid] 查看进程下的所有线程占 CPU 和 MEM 的情况

  4. 将线程 ID 转换为 16 进制:printf "%x\n" [pid],输出的值就是线程栈信息中的 nid

    例如:# printf "%x\n" 29471,换行输出 731f

  5. 抓取线程栈:# jstack 29452 > 29452.txt,可以多抓几次做个对比。

    在线程栈信息中找到对应线程号的 16 进制值,如下是 731f 线程的信息。线程栈分析可使用 Visualvm 插件 TDA

    1
    2
    "Service Thread" #7 daemon prio=9 os_prio=0 tid=0x00007fbe2c164000 nid=0x731f runnable [0x0000000000000000]
    java.lang.Thread.State: RUNNABLE
  6. 使用jstat -gcutil [pid] 5000 10 每隔 5 秒输出 GC 信息,输出 10 次,查看 YGCFull GC 次数。通常会出现 YGC 不增加或增加缓慢,而 Full GC 增加很快。

    或使用 jstat -gccause [pid] 5000 ,同样是输出 GC 摘要信息。

    或使用 jmap -heap [pid] 查看堆的摘要信息,关注老年代内存使用是否达到阀值,若达到阀值就会执行 Full GC。

  7. 如果发现 Full GC 次数太多,就很大概率存在内存泄漏了

  8. 使用 jmap -histo:live [pid] 输出每个类的对象数量,内存大小(字节单位)及全限定类名。

  9. 生成 dump 文件,借助工具分析哪 个对象非常多,基本就能定位到问题在那了

    使用 jmap 生成 dump 文件:

    1
    2
    3
    # jmap -dump:live,format=b,file=29471.dump 29471
    Dumping heap to /root/dump ...
    Heap dump file created

    或使用 JDK 自带的 jvisualvm.exe生成 dump 文件。

  10. dump 文件分析

    可以使用 jhat 命令分析:jhat -port 8000 29471.dump,浏览器访问 jhat 服务,端口是 8000。

    通常使用图形化工具分析,如 JDK 自带的 jvisualvm,从菜单 > 文件 > 装入 dump 文件。

    或使用第三方式具分析的,如 JProfiler 也是个图形化工具,GCViewer 工具。Eclipse 或以使用 MAT 工具查看。或使用在线分析平台 GCEasy

    注意:如果 dump 文件较大的话,分析会占比较大的内存。

  11. 在 dump 文析结果中查找存在大量的对象,再查对其的引用。

    基本上就可以定位到代码层的逻辑了。

内存溢出

定义

内存溢出(Out Of Memory):通常出现在某一块内存空间块耗尽的时候。常见的几种内存溢出有 堆溢出,直接内存溢出,永久区溢出( jdk 8 没有永久区也就没有此类内存溢出)

原因及处理

内存泄漏 是造成 内存溢出 的原因之一。参考上面 内存泄漏 小节。

堆溢出

堆是 Java 程序中最为得要的内存空间,大量的对象都直接分配在堆上,因此,堆也是最有可能发生溢出的空间,绝大部分内存溢出属性堆溢出。其原因是大量对象占据了堆空间,而这些对象都持有强引用,导致无法回收。

对象大小之和大于由 Xmx 参数指定的堆空间大小时,就会发生溢出错误。

1
-Xmx20m -Xms20m -XX:+HeapDumpOnOutOfMemoryError

上面配置是将堆内存最大值与最小值都设置为 20 MB,在出现内存溢出时 Dump 出当前的内存堆转储快照以便事后分析。

为缓解堆溢出,可以使用 -Xmx 参数指定一个更大的堆空间。但因堆空间不可能无限增长,需要对占用堆空间的大量对象进行分析优化。参考上面 内存泄漏 小节。

如果不存在内存泄漏,说明内存中的对象确实必须都存活,那就应当检查虚拟机的堆参数(-Xmx 和 -Xms),基于物理内存看是否可以调大。

直接内存溢出

Java 的 NIO(New IO)支持直接内存的使用,即通过 Java 代码直接向操作系统申请内存空间,申请速度比堆内存慢,但访问的速度更快,直接内存属于堆外内存。

直接申请堆外内存:

1
2
3
4
5
6
for (int i = 0; i < 1024; i++) {
ByteBuffer.allocateDirect(1024 * 1024);
System.out.println(i);
//显示调用 GC 回收
// System.gc();
}

直接内存不一定会触发自动 GC(除非直接内存使用量达到了 -XX:MaxDirectMemorySize 的设置),所以保证直接内存不溢出的方法是合理地进行 Full GC 的执行,或者设定一个系统实际可达的 -XX:MaxDirectMemorySize 值(默认等于最大堆 -Xmx 的设置)。

如果系统的堆内存小于 GC 发生,而直接内存申请频繁,会比较容易导致内存溢出(在 32 位虚拟机上尤为明显,因为在 32 位系统中,可用最大内存空间为 4GB,其中 2GB 为用户空间,2GB 为系统空间)

或在 32 位系统中,设置一个较小的堆,在不指定 -XX:MaxDirectMemorySize 的情况下,最大可用直接内存等于 -Xmx 的值。

1
-Xmx512m -XX:+PrintGCDetails

为避免直接内存溢出,在确保空间不浪费的基础上,合理执行显式 GC,可以降低直接内存溢出的概率。设置合理的 -XX:MaxDirectMemorySize 也可以避免意外的内存溢出发生。在 32 位虚拟机上设置一个较小的堆可以使得更多的内存用于直接内存。

过多线程导致OOM

每开启一个线程都要占用系统内存,当线程数量太多时,也有可能导致 OOM

线程的栈空间也是在堆外分配的,与直接内存相似,如果想让系统支持更多的线程,应使用一个较小的堆空间。

预防此问题,可以从以下两方面着手:

  • 减小最大堆空间,操作系统就可以预留更多的内存用于线程创建。

    1
    -Xmx512m
  • 减少每一个线程所占的内存空间,使用 -Xss 参数指定线程的栈空间。

    1
    -Xmx1g -Xss128k

    上面示例使用 1G 堆空间,每个线程栈空间减少到 128k。

    注意:如果减少了线程栈空间,那么栈空间溢出的风险就会相应地上升。

处理这类的 OOM 思路,可以考虑减少线程总数,减少最大堆空间,减少线程栈空间等方式。

GC效率低下引起OOM

Java 应用,GC 是内存回收的关键。如果 GC 效率低下,就可能会严重影响系统的性能。

如果系统的堆空间太小,会就执行更多的 GC,GC 占用的时间就会较多,并且回收释放的内存就会较少。虚拟机会根据 GC 占用的系统时间及释放内存的大小来评估 GC 的效率,如果认为 GC 效率过低,就可能直接抛出 OOM 错误。

一般情况下,虚拟机会检查以下几种情况:

  • 花在 GC 上的时间是否超过了 98%
  • 老年代释放的内存是否小于 2%
  • eden 区释放的内存是否小于 2%
  • 是否连续最近 5 次 GC 都出现上述情况(同时出现)。

只有满足所有条件,虚拟机可能抛出如下 OOM:

1
java.lang.OutOfMemoryError:GC overhead limit exceeded

这个 OOM 只是起辅助作用,帮助提示系统分配的堆可能太小,可以使用如下配置关闭这个错误提示:

1
-XX:-UseGCOverheadLimit

永久区溢出

永久区(Perm)是存放(class)元数据的区域。如果一个系统定了太多的类,那么永久区是有可能溢出的。

在 JDK 1.8 中,永久区被元数据区代替,元数据区在 Java 堆中。

JDK 1.6 1.7 设置永久区(方法区)大小:

1
2
3
-XX:PermSize=5m -XX:MaxPermSize=5m

永久区 OOM 异常:Caused by:java.lang.OutOfMemoryError: PermGen space

要解决永久区溢出可以从下面几个方面考虑:

  • 增加 MaxPermSize的值
  • 减少系统需要的类的数量
  • 使用 ClassLoader 合理地装载各个类,并定期进行回收

相关参考

  1. Java的内存泄漏
  2. 了解Java中的内存泄漏
  3. 有趣易懂的内存泄漏分析与实战
  4. 一次堆外内存泄露的排查过程
  5. ThreadLocal可能引起的内存泄露
  6. 关于ThreadLocal内存泄露的备忘,看看评论
  7. JVisualVM简介与内存泄漏实战分析
  8. 记录一次jvm内存泄露的问题
  9. jvm内存泄漏问题分析过程
  10. Java内存泄漏的排查总结
  11. JVM_内存泄漏和内存溢出
  12. Java面试总结之Full GC
  13. SRE重案调查组 第四集 | JVM元数据区的内存泄漏之谜
  14. 知呼-内存泄漏和内存溢出有啥区别?
  15. Java死锁排查和Java CPU 100% 排查的步骤整理
  16. java命令分析线程死锁以及内存泄漏
  17. 使用 Eclipse Memory Analyzer 进行堆转储文件分析

JVM虚拟机(2):JVM 内存泄漏与内存溢出及问题排查

http://blog.gxitsky.com/2020/02/25/JVM-02-memory-leak-out/

作者

光星

发布于

2020-02-25

更新于

2022-06-17

许可协议

评论