定位Java内存泄漏

翻译:叩丁狼教育吴嘉俊

经验不足的开发人员经常会认为Java的自动垃圾回收机制会让他们彻底的摆脱内存管理的困扰。这是一个常见的错觉,即使垃圾收集器尽了最大的努力,即使是最好的程序员,也可能成为内存泄漏的牺牲品。容我慢慢道来。

内存泄漏出现在当对象已经不需要了,但是对象仍然被异常的引用。这种泄漏会带来严重后果,随意举一例,你的应用会持续的要求更多的资源,而导致对你的服务器造成不必要的压力。更糟糕的是,检测这种溢出是非常困难的:静态分析常常难以精确的识别这些冗余的引用,现有的内存诊断工具产生的针对独立对象的细粒度的诊断报告,也难以理解,并且缺乏精度。

换言之,内存泄漏要么太难识别,要么使用起来过于专业。

内存的问题可以分为4种类型,这四种类型的错误很相似,并且症状也有相似点,但是产生的原因和解决的方式完全不一样。

  • 性能相关: 通常出现在大量的对象创建和删除,长时间的垃圾回收延迟,大量的操作系统内存页交换等情况。
  • 资源限制: 常常出现在内存不足或内存碎片太大而无法分配大对象时,常常发生在native memory,或者heap memory中;
  • Java堆泄漏: 最经典的内存泄漏场景,出现在Java对象持续被创建,但是并没有被及时释放。常常因为潜在的对象引用导致。
  • Native memory泄漏: 与Java Heap memory外的持续增长的内存利用率相关,例如JNI代码、驱动程序或JVM分配所分配的。

在这篇文章中,我主要会聚焦在Java堆泄漏,并给出一种可行的方法去诊断这类内存问题,这种方法基于JVM报告,并利用可视化工具在应用运行中进行分析。

在介绍如何避免和排查内存泄漏之前,你必须先理解为什么内存泄漏会发生。

内存泄漏:入门

对于初学者来说,可以这样理解,内存泄漏你可以理解为一种疾病,而类似Java的OutOfMemoryError(一般简称为OOM)看成一种症状。但是不是所有的OOM都意味着内存泄漏:假如创建了巨大量级的本地变量,也会导致OOM,但这并不是内存泄漏。另一方面,并不是所有的内存泄漏都会导致OOM异常,特别是在桌面应用或者客户端应用中(运行时间不会很长,就会重启,所以可能出现内存泄漏,但是不会表现为OOM)。

为什么内存泄漏如此惹人厌恶?抛开其他的不说,内存泄漏会让系统随着时间的延长,性能直线降低。因为系统的物理内存一旦使用耗尽,就会导致物理内存交换。最终,应用可能耗尽分配的虚拟内存,导致OOM产生。

破解 OutOfMemoryError

上面已经说到,OOM是一个常见的内存泄漏的表现。大部分情况下,该错误是由于没有足够的空间分配给一个新的对象的时候抛出的,Java会尽力去尝试,但是垃圾回收器仍然无法清理出足够的空间,堆也没法继续扩展,错误就抛出了,并给你一个stack trace。

要理解OOM,第一步是明白OOM异常真正的意思。好像是句废话,但实际上,没有多少人能说出OOM到底有多少种。举个例子:一个OOM产生,是因为heap memory满了,还是因为native heap memory满了?为了帮助你回答类似的问题,我们先来看一下OOM可能出现的几种错误信息:

  • java.lang.OutOfMemoryError: Java heap space
  • java.lang.OutOfMemoryError: PermGen space
  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit
  • java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
  • java.lang.OutOfMemoryError: <reason> <stack trace> (Native method)

Java heap space

这个错误信息不一定意味着就是内存泄漏,事实上,这个错误类似配置错误,是很简答的问题。

举个例子,有一个应用经常出现这种类型的OOM异常,要我来分析这个问题。经过检查,我发现出问题的原因在于应用中有段代码会实例化一个占用内存空间较大的数组;在这种情况下,这并不是应用程序的错误,而是在应用服务器配置中,使用了默认的heap memory大小,这太小了。我仅仅只是通过调整Xms就解决了。

另外一种情况,特别是针对运行了较长时间的应用,这个信息可能就意味着应用中有可能存在未释放引用的对象,导致垃圾回收器无法及时回收空间。这是Java语言中最标准的内存泄漏(在API中的定义描述为:unintentionally holding object references)。

另一个潜在的导致Java heap space OOM的原因是使用了finalizer。如果一个类有finalize方法,那么这个类型的对象不会在垃圾回收时间收回对应的空间,而会等到垃圾回收结束之后,对象进入finalize队列排队,等待finalize执行。在Sun公司的JVM实现中,finalizer是由一个守护线程执行的。如果这个finalizer线程被终止了,而等待finalize的对象仍然在finalize队列中,那么这些对象就无法被正常回收,这个时候OOM就有可能发生了。

PermGen space

这个错误信息意味着永久代空间被占满了[注:Java8中已经去掉永久代]。永久代空间是用来存储class对象和method对象的堆空间。如果一个应用需要加载大量的类,则需要通过调整-XX:MaxPermSize参数来扩大永久代空间大小。

驻留的字符串对象(Intered String Object)也是存储在永久代中的。由java.lang.String类持有一个string对象的字符串常量池。当String的intern方法被调用,这个方法会首先在字符串常量池中检查是否有相等的字符串存在,如果是,这个字符串常量池中的string对象会被intern方法返回,如果不存在,这个string会被添加到字符串常量池中。使用专业术语来说,java.lang.String.intern方法返回一个字符串的不可变形式。如果一个应用中有大量的驻留字符串,你也需要增加永久代空间的大小。

提示:你可以使用jmap -permgen命令输出针对永久代的统计信息,包含驻留字符串相关的信息。[注:Java8中没有这个选项,直接通过jmap -heap pid就能看到intern字符串信息]

Requested array size exceeds VM limit

这个错误出现在应用(或者应用调用的API)尝试在堆上分配一个大于堆空间的数组。举个例子,如果应用尝试分配一个512MB的数组,但是最大的heap大小设置为256MB,于是一个Requested array size exceeds VM limit的OOM错误就会被抛出。在大部分情况下,这个问题就是一个配置问题,或者检查应用中是否有尝试分配巨大的数组的情况。

Request <size> bytes for <reason>. Out of swap space?

这个错误也是OOM的一种。在HotSpot VM中,当native heap分配空间失败,并且native heap空间趋近于衰竭的时候,会抛出这个异常。这个错误信息中包含了请求分配失败的空间大小(size)和失败的原因(reason)。大部分情况下,原因会显示出尝试分配空间失败的代码名称。

如果这个类型的OOM异常抛出,你可能需要使用针对你的操作系统的专门的诊断工具来分析问题。分析这个问题的时候,不仅仅只是去检查你的程序那么简单。比如,在下面几种情况下,你也可以看到这个问题:

  • 操作系统分配的内存交换空间不足。
  • 操作系统中的另一个进程占满了系统所有的内存资源。

这个错误也有可能是本地内存泄漏(native leak)造成的(比如一个应用或者代码库持续的要求分配空间,但是在操作系统回收内存的时候失败了)

<reason> <stack trace> (Native method)

如果你看到了这个错误信息,并且在当前栈顶显示的是一个native方法,那么意味着这个native方法在申请一个内存分配的时候失败了。这个错误和上一个错误的区别在于,内存分配错误是发生在JNI或者native方法上,还是Java VM代码上,如果是前者,抛出的Native method错误信息,如果是后者,抛出的是上面那个错误。

同样,如果出现了这种OOM,你需要使用针对你的操作系统的专门的诊断工具来分析问题。

非OOM导致的应用崩溃

偶然的,如果出现native heap空间分配失败,会立刻导致应用的崩溃。这种情况出现在native方法的代码没有检查和处理内存分配的异常。比如,当系统使用malloc 命令请求分配内存,但是返回NULL 代表无空间可分配。如果调用malloc 方法但是没有检查和处理这个NULL结果,这个应用会尝试访问不存在的内存地址,这会立刻导致应用崩溃。类似这样的问题,是非常难以定位和处理的。

在某些情况下,可以借助系统的致命错误日志(fatal error log)或者故障快照(crash dump)来分析。如果经过分析,确实是因为未处理内存分配异常导致的应用崩溃,你必须要找到分配失败的原因。导致分配失败的原因,和native heap空间分配失败的原因相似,可能是因为系统设置的内存交换空间不足,也有可能是另一个进程消耗完了系统的内存资源。

诊断泄漏

在大多数情况下,要想正确诊断出内存泄漏的原因,需要对应用有非常细节的了解。这个过程可能非常漫长,而且往往是通过迭代完成。

我们诊断内存泄漏的策略非常简洁明了:

  1. 确定征兆
  2. 开启垃圾回收器日志
  3. 开启profile
  4. 分析跟踪信息

1. 确定征兆

如上讨论,在大部分时候,Java进程抛出一个OOM异常,这本身就是一个非常名确的信号,内存资源已经被耗尽。在这种情况下,你必须首先要区别这是正常的内存资源耗尽还是内存泄漏。我们需要分析OOM抛出的信息,根据上面我们讨论的信息含义来确定大致的问题可能的方向。

常常的,如果一个java应用需要的存储空间超过运行时堆所有提供的,这往往归结于不合理的设计。举个例子,如果一个应用会创建一个图片的多个备份,然后放到一个数组里面,或者加载多个文件放到一个数组中,在这种情况下,如果一个图片或者这个文件本身就很大,就很容易造成资源的不足,这是一种常见的资源耗尽的的情况。但是,如果程序是正常的在处理相同的数据,但是内存的消耗却在稳步的增加,这就极有可能是内存溢出了。

2. 开启垃圾回收器日志

确定是否出现内存泄漏的一个最快的方式,就是开启垃圾回收器日志信息。内存约束问题通常可以通过-verbose:gc 的输出模式来快速确定。

在启动应用的时候,使用-verbose:gc参数,允许你在每次开始执行GC的时候,生成跟踪信息。意思是,当内存被回收的时候,在标准控制台会输出摘要信息,给出一个明确的数据,展示当前内存的管理情况。

下面是一个典型的使用-verbose:gc参数的输出:

image.png

在这个GC跟踪中,每一个块按照标记的数字升序排列。要明白这个跟踪日志的意思,主要要关注标记为连续分配失效的行(Allocation Failure[AF]),然后关注释放的内存(释放的空间大小和释放的比例),可以看到,每次释放的大小在减少,但是总的内存空间在增长,这就是一个典型的内存耗减的信号。

3. 开启Profiling

不同的JVM提供了不同的方法去生成trace文件来反应堆内存活动的情况,这些文件中一般都包含了堆中对象的详细类型和数量。这种方式就叫做heap profiling,比如jmap -histo[:live]

4. 分析跟踪文件

这篇文章关注点在JVM生成的trace上。trace信息可以由不同的内存泄漏分析工具生成,生成的展示格式也不一样,但是在这些数据之后的道理是一致的:在堆中找到一组本不应该存在的对象,并持续检查对象是否是在持续增长而非释放。特别关注的是那些在应用中一个定时的时间会触发相关事件,然后创建一定量的瞬时对象的情况。如果这种在内存中理应少量存在的对象却出现了较多的实例,那么可能就意味着bug的存在了。

最后,处理内存泄漏,需要整体的对代码进行复查。熟悉内存泄漏的类型对于加快debug是很有帮助的。

GC的是如何工作的?

在我们正式开始分析应用的内存泄漏问题之前,我们先来看看在JVM中,GC是如何工作的。

JVM使用跟踪搜集器来完成垃圾的回收。这种回收器标记出所有的root对象(即直接被活动线程引用的而对象),并跟随对象的引用,把路径上所有的对象标记出来,这些对象就是活动对象。

Java基于代际假设(generational hypothesis assumption)实现了分代垃圾回收器。分代垃圾收集器假设大部分对象都会在短时间内成为垃圾,而经过了一定时间依然存活的对象往往拥有较长的寿命。寿命长的对象更容易存活下来,寿命短的对象则会被很快的废弃。所以,为了缩短垃圾回收的时间,只需要重点扫描新创建的对象。

基于这个假设,Java把不同的对象放到不同的分代空间中进行管理。下面是一个模拟图。

image.png
  • 新生代 - 这是对象开始的地方。它有两个子代:
    • Eden Space:对象从这里开始。大部分的对象在Eden空间中创建并销毁。在这里面,GC执行小回收(Minor GC),来优化垃圾回收。当执行小回收的时候,如果对象仍然需要(即存活),那么这些对象会被移动到任何一个survivors空间(S0或者S1)。
    • Survivor Space (S0 and S1):从Eden Space存活下的对象会移动到这里。有两个Survivor Space,在一个给定时间,只会有一个处于激活状态(除非发生严重的内存泄漏)。一个被设置为空,一个是存活的,每一次GC周期会执行轮换。
  • 年老代(Tenured Generation):也叫做old generation (old space), 这个区域存放存活较久的对象(从survivor空间移动过来,已经存活了足够久的时间)。当这个空间被填满,会触发一次完整GC(full GC),完整GC会小号较多的性能。如果这个空间持续的增长,JVM就会抛出一个OOM-Java heap space。
  • 永久代(Permanent Generation):靠近老年代的第三个代,永久代是一个特殊的空间,在这里面存储的是虚拟机需要使用的类的描述数据。比如,对象的类描述信息和方法描述信息,都是存在永久代中。

Java针对不同的代空间采用了足够聪明的垃圾回收方法。在新生代中,使用了跟踪,拷贝回收算法,叫做并行GC(Parallel New Collector)。所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。

想要对JVM各个代空间有更细致的理解,请访问:Memory Management in the Java HotSpot™ Virtual Machine

检测内存溢出

为了找到内存泄漏并排除错误,你需要使用合适的内存泄漏检查工具。是时候介绍Java VisualVM。

使用Java VisualVM远程profiling堆信息

VisualVM提供了可视化界面,能够提供对基于java的运行着的应用的数据进行细节的数据展示。

使用VisualVM,你可以监控本地的应用,也可以监控远端服务器上的应用。你可以获取JVM实例的相关数据,并且将数据保存到本地系统。

为了获取Java VisualVM的所有功能,请运行在JavaSE6以上。

连接远端JVM

在生产环境,直接连接到远程服务器是不安全的,也不方便,远程连接并profile是一个不错的选择。

首先,我们需要授权我们的JVM能够连接上远端的目标机器。创建一个文件叫做jstatd.all.policy,加入以下内容:

grant codebase "file:${java.home}/../lib/tools.jar" {

   permission java.security.AllPermission;

};

一旦这个文件创建好,我们需要使用jstatd-Virtual Machine jstat Daemon工具连接远端VM,使用以下命令:

jstatd -p <PORT_NUMBER> -J-Djava.security.policy=<PATH_TO_POLICY_FILE>

比如:

jstatd -p 1234 -J-Djava.security.policy=D:\jstatd.all.policy

当jstatd在目标VM上运行之后,我们就可以连接上目标机器,开启远端profile,排查内存泄漏问题。

连接远端服务

在本地打开一个命令窗口,并输入jvisualvm开启VisualVM工具。

下一步,我们需要在VisualVM中添加一个远程主机,前提是我们已经在远程主机上允许从另一台机器远程连接。我们启动Java VisualVM工具,并连接这台服务器,一旦连接成功,我们就能看到远端运行的那台JVM了。

image.png

要开启内存profiler,我们需要在左边的面板中双击目标服务器。

到这里,我们已经为我们的内存分析做好了基本的准备。

内存泄漏

在Java中要模拟一个内存泄漏有多重方法,最简单的方式是我们定义一个类,但不要覆盖equals()和hashcode()方法,并将这个类作为一个HashMap的key。HashMap要求作为key的类需要实现equals()和hashcode()方法。没有这两个方法,就无法产生一个正确的key。如果没有定义equals()和hashcode()方法,当我们将相同的key重复的添加到HashMap中,我们本意是执行替换操作,但实际上不会,因为无法正确对比key值,HashMap会持续的增长,直到抛出一个OOM。

下面是定义的MemLeak类:

package com.post.memory.leak;
import java.util.Map;

public class MemLeak {

    public final String key;
    public MemLeak(String key) {
        this.key =key;
    }

    public static void main(String args[]) {
        try {
            Map map = System.getProperties();
            for(;;) { // line 14
                map.put(new MemLeak("key"), "value");
            }
        } catch(Exception e) {
            e.printStackTrace();
        }
    }
}

注意,内存泄漏不是出现在14行的无限循环;无限循环可能会导致资源的耗竭,但是不会出现内存溢出。如果我们正确提供了equals()和hashcode()方法,就算使用无限循环,map中也只可能出现一个元素。

可以访问:http://stackoverflow.com/questions/6470651/creating-a-memory-leak-with-java 找到更多的内存泄漏模拟方法。

使用Java VisualVM

使用Java VisualVM,我们可以监控Java 堆信息,并且识别出是否存在内存泄漏。

下面是初始化完成之后,我们的MemLeak的Java 堆信息示意图:

image.png

差不多30秒之后,老年代基本上被占满,注意,即使是执行了一次Full GC,老年代仍然在持续增长,这就是明显的内存泄漏的标志。

为了排查这次内存溢出的原因,一种方式就是使用Java VisualVM生成heap dump信息。在图中我们可以看到,heap中50%的对象都是Hashtable%Entry,第二行指向了我们的MemLeak类。那么可以得出结论,这次的内存泄漏在于MemLeak类作为了hash table的key。

image.png

最后,可以注意到,当抛出OOM的时候,新生代和老年代全部都满了。

image.png

小节

内存泄漏算JAVA应用中最难解决的一个问题了,症状变化多端,并且难以重现。这篇文章中,我们一步一步的介绍了内存泄漏的症状,产生的原因,如何定位和识别。最后,注意识别OOM的错误信息,仔细的分析错误栈,不是所有的内存溢出问题,都如我们这篇文章中举得MemLeak例子这么明显。

原文:https://www.toptal.com/java/hunting-memory-leaks-in-java

想获取更多技术视频,请前往叩丁狼官网:http://www.wolfcode.cn/openClassWeb_listDetail.html

推荐阅读更多精彩内容

  • 在 Java 中,内存的分配是由程序完成的,而内存的释放则是由 Garbage Collecation(GC) 完...
    Shawn_Dut阅读 4,399评论 3 28
  • 内存管理的目的就是让我们在开发中怎么有效的避免我们的应用出现内存泄漏的问题。内存泄漏大家都不陌生了,简单粗俗的讲,...
    宇宙只有巴掌大阅读 1,625评论 0 11
  • 我用了近半年的时间,从上海来到西安,改变我前进的方向。不想甘于平庸,因为我还没有为了梦想热血沸腾过;不想循规蹈矩,...
    Emotion_C阅读 110评论 1 0
  • 我有故事,只是故事里的人与物都走了 我便不称之为故事,沉淀下的,在深夜容易被水波漾起,散开来的是一圈圈悔意与自责。...
    凤凰和梧桐阅读 144评论 1 2
  • 刚刚踏出社会,享受生活,自己想要买什么自己都能够得着,想去哪里可以请假走人,想通宵通宵,想逛街逛街,可偏偏,这时候...
    宝ophelia阅读 77评论 0 3