没有经验的程序员经常认为Java的自动垃圾回收完全使他们免于担心内存管理。这是一个常见的误解:虽然垃圾收集器做得很好,但即使是最好的程序员也完全有可能成为严重破坏内存泄漏的牺牲品。让我解释一下。
当不必要地维护不再需要的对象引用时,会发生内存泄漏。这些泄漏很糟糕。首先,当程序消耗越来越多的资源时,它们会对计算机施加不必要的压力。更糟糕的是,检测这些泄漏可能很困难:静态分析通常很难精确识别这些冗余引用,现有的泄漏检测工具会跟踪和报告有关单个对象的细粒度信息,产生难以解释且缺乏精确度的结果。
换句话说,泄漏要么太难以识别,要么用太具体而无法用术语来识别。
实际上有四类内存问题具有相似和重叠的特征,但原因和解决方案各不相同:
在这个内存管理教程中,我将专注于Java堆漏洞,并概述一种基于Java VisualVM报告检测此类泄漏的方法,并利用可视化界面在运行时分析基于Java技术的应用程序。
但在您可以预防和发现内存泄漏之前,您应该了解它们的发生方式和原因。 (注意:如果你能很好地处理错综复杂的内存泄漏,你可以跳过。)
对于初学者来说,将内存泄漏视为一种疾病,将Java的OutOfMemoryError(简称OOM)视为一种症状。但与任何疾病一样,并非所有OOM都意味着内存泄漏:由于生成大量局部变量或其他此类事件,OOM可能会发生。另一方面,并非所有内存泄漏都必然表现为OOM,特别是在桌面应用程序或客户端应用程序(没有重新启动时运行很长时间)的情况下。
将内存泄漏视为疾病,将OutOfMemoryError视为症状。但并非所有OutOfMemoryErrors都意味着内存泄漏,并非所有内存泄漏都表现为OutOfMemoryErrors。
为什么这些泄漏如此糟糕?除此之外,程序执行期间泄漏的内存块通常会降低系统性能,因为分配但未使用的内存块必须在系统耗尽空闲物理内存时进行换出。最终,程序甚至可能耗尽其可用的虚拟地址空间,从而导致OOM。
如上所述,OOM是内存泄漏的常见指示。实质上,当没有足够的空间来分配新对象时,会抛出错误。当垃圾收集器找不到必要的空间,并且堆不能进一步扩展,会多次尝试。因此,会出现错误以及堆栈跟踪。
诊断OOM的第一步是确定错误的实际含义。这听起来很清除,但答案并不总是那么清晰。例如:OOM是否是因为Java堆已满而出现,还是因为本机堆已满?为了帮助您回答这个问题,让我们分析一些可能的错误消息:
此错误消息不一定意味着内存泄漏。实际上,问题可能与配置问题一样简单。
例如,我负责分析一直产生这种类型的OutOfMemoryError的应用程序。经过一番调查后,我发现罪魁祸首是阵列实例化,因为需要太多的内存;在这种情况下,并不是应用程序的错,而是应用程序服务器依赖于默认的堆太小了。我通过调整JVM的内存参数解决了这个问题。
在其他情况下,特别是对于长期存在的应用程序,该消息可能表明我们无意中持有对象的引用,从而阻止垃圾收集器清理它们。这时Java语言等同于内存泄漏。 (注意:应用程序调用的API也可能无意中持有对象引用。)
这些“Java堆空间”OOM的另一个潜在来源是使用finalizers。如果类具有finalize方法,则在垃圾收集时该类型的对象不会被回收。而是在垃圾收集之后,稍后对象将排队等待最终确定。在Sun实现中,finalizers由守护线程执行。如果finalizers线程无法跟上finalization队列,那么Java堆可能会填满并且可能抛出OOM。
此错误消息表明永久代已满。永久代是存储类和方法对象的堆的区域。如果应用程序加载了大量类,则可能需要使用-XX:MaxPermSize选项增加永久代的大小。
Interned java.lang.String对象也存储在永久代中。 java.lang.String类维护一个字符串池。调用实习方法时,该方法检查池以查看是否存在等效字符串。如果是这样,它由实习方法返回;如果没有,则将字符串添加到池中。更准确地说,java.lang.String.intern方法返回一个字符串的规范表示;结果是对该字符串显示为文字时将返回的同一个类实例的引用。如果应用程序实例化大量字符串,则可能需要增加永久代的大小。
注意:您可以使用jmap -permgen命令打印与永久生成相关的统计信息,包括有关内部化String实例的信息。
此错误表示应用程序(或该应用程序使用的API)尝试分配大于堆大小的数组。例如,如果应用程序尝试分配512MB的数组但最大堆大小为256MB,则将抛出此错误消息的OOM。在大多数情况下,问题是配置问题或应用程序尝试分配海量数组时导致的错误。
此消息似乎是一个OOM。但是,当本机堆的分配失败并且本机堆可能将被耗尽时,HotSpot VM会抛出此异常。消息中包括失败请求的大小(以字节为单位)以及内存请求的原因。在大多数情况下,是报告分配失败的源模块的名称。
如果抛出此类型的OOM,则可能需要在操作系统上使用故障排除实用程序来进一步诊断问题。在某些情况下,问题甚至可能与应用程序无关。例如,您可能会在以下情况下看到此错误:
由于本机泄漏,应用程序也可能失败(例如,如果某些应用程序或库代码不断分配内存但无法将其释放到操作系统)。
如果您看到此错误消息并且堆栈跟踪的顶部框架是本机方法,则该本机方法遇到分配失败。此消息与上一个消息之间的区别在于,在JNI或本机方法中检测到Java内存分配失败,而不是在Java VM代码中检测到。
如果抛出此类型的OOM,您可能需要在操作系统上使用实用程序来进一步诊断问题。
有时,应用程序可能会在从本机堆分配失败后很快崩溃。如果您运行的本机代码不检查内存分配函数返回的错误,则会发生这种情况。
例如,如果没有可用内存,malloc系统调用将返回NULL。如果未检查malloc的返回,则应用程序在尝试访问无效的内存位置时可能会崩溃。根据具体情况,可能很难定位此类问题。
在某些情况下,致命错误日志或崩溃转储的信息就足以诊断问题。如果确定崩溃的原因是某些内存分配中缺少错误处理,那么您必须找到所述分配失败的原因。与任何其他本机堆问题一样,系统可能配置了但交换空间不足,另一个进程可能正在消耗所有可用内存资源等。
在大多数情况下,诊断内存泄漏需要非常详细地了解相关应用程序。警告:该过程可能很长并且是迭代的。
我们寻找内存泄漏的策略将相对简单:
正如所讨论的,在许多情况下,Java进程最终会抛出一个OOM运行时异常,这是一个明确的指示,表明您的内存资源已经耗尽。在这种情况下,您需要区分正常的内存耗尽和泄漏。分析OOM的消息并尝试根据上面提供的讨论找到罪魁祸首。
通常,如果Java应用程序请求的存储空间超过运行时堆提供的存储空间,则可能是由于设计不佳导致的。例如,如果应用程序创建映像的多个副本或将文件加载到数组中,则当映像或文件非常大时,它将耗尽存储空间。这是正常的资源耗尽。该应用程序按设计工作(虽然这种设计显然是愚蠢的)。
但是,如果应用程序在处理相同类型的数据时稳定地增加其内存利用率,则可能会发生内存泄漏。
断言确实存在内存泄漏的最快方法之一是启用详细垃圾回收。通常可以通过检查verbosegc输出中的模式来识别内存约束问题。
具体来说,-verbosegc参数允许您在每次垃圾收集(GC)过程开始时生成跟踪。也就是说,当内存被垃圾收集时,摘要报告会打印到标准错误,让您了解内存的管理方式。
这是使用-verbosegc选项生成的一些典型输出:
此GC跟踪文件中的每个块(或节)按递增顺序编号。要理解这种跟踪,您应该查看连续的分配失败节,并查找随着时间的推移而减少的释放内存(字节和百分比),同时总内存(此处,19725304)正在增加。这些是内存耗尽的典型迹象。
不同的JVM提供了生成跟踪文件以反映堆活动的不同方法,这些方法通常包括有关对象类型和大小的详细信息。这称为分析堆。
本文重点介绍Java VisualVM生成的跟踪。跟踪可以有不同的格式,因为它们可以由不同的Java内存泄漏检测工具生成,但它们背后的想法总是相同的:在堆中找到不应该存在的对象块,并确定这些对象是否累积而不是释放。特别感兴趣的是每次在Java应用程序中触发某个事件时已知的临时对象。应该仅存少量,但存在许多对象实例,通常表示应用程序出现错误。
最后,解决内存泄漏需要您彻底检查代码。了解对象泄漏的类型可能对此非常有用,并且可以大大加快调试速度。
在我们开始分析具有内存泄漏问题的应用程序之前,让我们首先看看垃圾收集在JVM中的工作原理。
JVM使用一种称为跟踪收集器的垃圾收集器,它基本上通过暂停它周围的世界来操作,标记所有根对象(由运行线程直接引用的对象),并遵循它们的引用,标记它沿途看到的每个对象。
Java基于分代假设-实现了一种称为分代垃圾收集器的东西,该假设表明创建的大多数对象被快速丢弃,而未快速收集的对象可能会存在一段时间。
基于此假设,[Java将对象分为多代]( www.oracle.com/technetwork… . Generations|outline)。这是一个视觉解释:
Java足够聪明,可以为每一代应用不同的垃圾收集方法。使用名为Parallel New Collector的跟踪复制收集器处理年轻代。这个收集器阻止了这个世界,但由于年轻一代通常很小,所以暂停很短暂。
有关JVM代及其工作原理的更多信息,请查阅 Memory Management in the Java HotSpot™ Virtual Machine 。
要查找内存泄漏并消除它们,您需要合适的内存泄漏工具。是时候使用Java VisualVM检测并删除此类泄漏。
VisualVM是一种工具,它提供了一个可视化界面,用于查看有关基于Java技术的应用程序运行时的详细信息。
使用VisualVM,您可以查看与本地应用程序和远程主机上运行的应用程序相关的数据。您还可以捕获有关JVM软件实例的数据,并将数据保存到本地系统。
为了从Java VisualVM的所有功能中受益,您应该运行Java平台标准版(Java SE)版本6或更高版本。
Related: Why You Need to Upgrade to Java 8 Already
在生产环境中,通常很难访问运行代码的实际机器。幸运的是,我们可以远程分析我们的Java应用程序。
首先,我们需要在目标机器上授予自己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 复制代码
通过在目标VM中启动jstatd,我们能够连接到目标计算机并远程分析应用程序的内存泄漏问题。
在客户端计算机中,打开提示并键入jvisualvm以打开VisualVM工具。
接下来,我们必须在VisualVM中添加远程主机。当目标JVM启用以允许来自具有J2SE 6或更高版本的另一台计算机的远程连接时,我们启动Java VisualVM工具并连接到远程主机。如果与远程主机的连接成功,我们将看到在目标JVM中运行的Java应用程序,如下所示:
要在应用程序上运行内存分析器,我们只需在侧面板中双击其名称即可。
现在我们已经设置了内存分析器,让我们研究一个内存泄漏问题的应用程序,我们称之为MemLeak。
当然,有很多方法可以在Java中创建内存泄漏。为简单起见,我们将一个类定义为HashMap中的键,但我们不会定义 equals()和hashcode() 方法。
HashMap是Map接口的哈希表实现,因此它定义了键和值的基本概念:每个值都与唯一键相关,因此如果给定键值对的键已经存在于HashMap,它的当前值被替换。
我们的密钥类必须提供equals()和hashcode()方法的正确实现。没有它们,就无法保证会生成一个好的密钥。
通过不定义equals()和hashcode()方法,我们一遍又一遍地向HashMap添加相同的键,而不是按原样替换键,HashMap不断增长,无法识别这些相同的键并抛出OutOfMemoryError 。
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(;;) { map.put(new MemLeak("key"), "value"); } } catch(Exception e) { e.printStackTrace(); } } } 复制代码
注意:内存泄漏不是由于第14行的无限循环:无限循环可能导致资源耗尽,但不会导致内存泄漏。如果我们已经正确实现了equals()和hashcode()方法,那么即使使用无限循环,代码也能正常运行,因为我们在HashMap中只有一个元素。
(对于那些感兴趣的人,这里有一些(故意)产生泄漏的替代方法。)
使用Java VisualVM,我们可以对Java Heap进行内存监视,并确定其行为是否存在内存泄漏。
这是刚刚初始化后MemLeak的Java堆分析器的图形表示(回想一下我们对各代的讨论):
仅仅30秒之后,老年代几乎已满,表明即使使用Full GC,老年代也在不断增长,这是内存泄漏的明显迹象。
检测此泄漏原因的一种方法如下图所示(单击放大),使用带有heapdump的Java VisualVM生成。在这里,我们看到50%的Hashtable $ Entry对象在堆中,而第二行指向MemLeak类。因此,内存泄漏是由MemLeak类中使用的哈希表引起的。
最后,在OutOfMemoryError之后观察Java Heap,其中Young和Old代完全填满。
内存泄漏是最难解决的Java应用程序问题之一,因为症状多种多样且难以重现。在这里,我们概述了一种逐步发现内存泄漏并确定其来源的方法。但最重要的是,仔细阅读您的错误消息并注意堆栈跟踪 - 并非所有泄漏都像它们出现的那样简单。