本文概述
经验不足的程序员经常认为Java的自动垃圾收集功能使他们完全不必担心内存管理。这是一个普遍的误解:尽管垃圾收集器会尽力而为, 但即使是最好的程序员也有可能沦为破坏内存泄漏的牺牲品。让我解释。
当不必要的对象引用被不必要地维护时, 就会发生内存泄漏。这些泄漏是不好的。其一, 随着程序消耗越来越多的资源, 它们给你的计算机带来了不必要的压力。更糟的是, 检测这些泄漏可能很困难:静态分析通常很难精确地识别这些冗余引用, 而现有的泄漏检测工具会跟踪并报告有关单个对象的细粒度信息, 从而产生难以解释且缺乏准确性的结果。
换句话说, 泄漏要么很难识别, 要么被识别得过于具体而无用。
实际上, 存在四类症状相似且重叠的内存问题, 但是原因和解决方案各不相同:
-
性能:通常与过多的对象创建和删除, 垃圾回收的长时间延迟, 过多的操作系统页面交换等相关。
-
资源限制:发生在可用内存不足或内存碎片太多而无法分配大对象时发生的资源约束-这可能是本机的, 或更常见的是与Java堆相关的。
-
Java堆泄漏:经典的内存泄漏, 在这种泄漏中, 连续创建Java对象而不释放它们。这通常是由潜在对象引用引起的。
-
本机内存泄漏:与Java堆外部任何持续增长的内存利用率相关联, 例如JNI代码进行的分配, 驱动程序甚至是JVM分配。
在本内存管理教程中, 我将重点介绍Java堆泄漏, 并概述一种基于Java VisualVM报告并利用可视界面在基于Java技术的应用程序运行时对其进行分析的检测泄漏的方法。
但是在防止并发现内存泄漏之前, 你应该了解泄漏的发生方式和原因。 (注意:如果你能很好地处理复杂的内存泄漏, 则可以跳过。)
内存泄漏:入门
首先, 将内存泄漏视为一种疾病, 而将Java的OutOfMemoryError(简称OOM)视为一种症状。但是, 与任何疾病一样, 并非所有OOM都必然暗示内存泄漏:由于大量局部变量或其他此类事件的产生, 可能会发生OOM。另一方面, 并非所有的内存泄漏都必然表现为OOM, 尤其是在桌面应用程序或客户端应用程序(如果不重新启动就无法运行很长时间)的情况下。
将内存泄漏视为一种疾病, 将OutOfMemoryError视为一种症状。但是, 并非所有OutOfMemoryErrors都暗示内存泄漏, 并且并非所有内存泄漏都将自身表现为OutOfMemoryErrors。
为什么这些泄漏如此严重?除其他事项外, 程序执行期间泄漏的内存块通常会随着时间的流逝降低系统性能, 因为一旦系统用尽了可用的物理内存, 已分配但未使用的内存块将必须换掉。最终, 程序甚至可能会耗尽其可用的虚拟地址空间, 从而导致OOM。
解密OutOfMemoryError
如上所述, OOM是内存泄漏的常见指示。本质上, 如果没有足够的空间来分配新对象, 则会引发该错误。尽力尝试, 垃圾收集器找不到必要的空间, 并且堆无法进一步扩展。因此, 出现错误以及堆栈跟踪。
诊断OOM的第一步是确定错误的实际含义。这听起来很明显, 但是答案并不总是那么清晰。例如:是否出现OOM是因为Java堆已满, 还是因为本机堆已满?为了帮助你回答这个问题, 让我们分析一些可能的错误消息:
-
java.lang.OutOfMemoryError:Java堆空间
-
java.lang.OutOfMemoryError:PermGen空间
-
java.lang.OutOfMemoryError:请求的数组大小超出了VM限制
-
java.lang.OutOfMemoryError:向<reason>请求<size>个字节。交换空间不足?
-
java.lang.OutOfMemoryError:<原因> <堆栈跟踪>(本机方法)
” Java堆空间”
此错误消息不一定表示内存泄漏。实际上, 问题可以和配置问题一样简单。
例如, 我负责分析始终产生此类OutOfMemoryError的应用程序。经过一番调查, 我发现罪魁祸首是需要太多内存的数组实例化。在这种情况下, 这不是应用程序的问题, 而是应用程序服务器依赖默认的堆大小, 该大小太小。我通过调整JVM的内存参数解决了这个问题。
在其他情况下, 尤其是对于寿命长的应用程序, 该消息可能表明我们无意中持有对对象的引用, 从而阻止了垃圾收集器清理它们。这与内存泄漏的Java语言等效。 (注意:应用程序调用的API也可能无意中包含对象引用。)
这些” Java堆空间” OOM的另一个潜在来源是终结器的使用。如果类具有finalize方法, 则该类型的对象在垃圾回收时不会回收其空间。取而代之的是, 在进行垃圾回收之后, 将对象排队等待定型, 然后再进行定型。在Sun实现中, 终结器由守护程序线程执行。如果终结器线程无法跟上终结器队列的速度, 则Java堆可能会填满, 并且可能引发OOM。
” PermGen空间”
此错误信息表明永久代已满。永久生成是存储类和方法对象的堆区域。如果应用程序加载大量类, 则可能需要使用-XX:MaxPermSize选项来增加永久代的大小。
中间java.lang.String对象也存储在永久代中。 java.lang.String类维护一个字符串池。调用intern方法时, 该方法检查池以查看是否存在等效字符串。如果是这样, 则通过intern方法返回它;如果不是, 则将字符串添加到池中。更准确地说, java.lang.String.intern方法返回字符串的规范表示形式;结果是对相同类实例的引用, 如果该字符串作为文字出现, 则将返回该实例。如果应用程序插入了大量字符串, 则可能需要增加永久代的大小。
注意:可以使用jmap -permgen命令来打印与永久生成有关的统计信息, 包括有关内部化String实例的信息。
“请求的阵列大小超出了VM限制”
此错误表明该应用程序(或该应用程序使用的API)试图分配一个大于堆大小的数组。例如, 如果应用程序尝试分配512MB的数组, 但最大堆大小为256MB, 则将引发OOM并显示此错误消息。在大多数情况下, 问题可能是配置问题, 也可能是应用程序尝试分配海量阵列时导致的错误。
“为<原因>请求<大小>个字节。交换空间不足?”
此消息似乎是一个OOM。但是, 当来自本机堆的分配失败并且本机堆可能快要用尽时, HotSpot VM会引发此明显的异常。消息中包括失败请求的大小(以字节为单位)和内存请求的原因。在大多数情况下, <reason>是报告分配失败的源模块的名称。
如果引发了这种类型的OOM, 则可能需要在操作系统上使用故障排除实用程序来进一步诊断问题。在某些情况下, 问题甚至可能与应用程序无关。例如, 在以下情况下, 你可能会看到此错误:
-
操作系统配置的交换空间不足。
-
系统上的另一个进程正在消耗所有可用的内存资源。
应用程序也有可能由于本机泄漏而失败(例如, 如果某些应用程序或库代码在不断分配内存, 但无法将其释放给操作系统)。
<原因> <堆栈跟踪>(本机方法)
如果你看到此错误消息, 并且堆栈跟踪的顶部框架是本机方法, 则该本机方法遇到分配失败。该消息与前一条消息的区别在于, 在JNI或本机方法中而不是在Java VM代码中检测到Java内存分配失败。
如果引发了这种类型的OOM, 则可能需要在操作系统上使用实用程序来进一步诊断问题。
没有OOM的应用程序崩溃
有时, 应用程序可能在本机堆分配失败后不久崩溃。如果你运行的本机代码不检查内存分配函数返回的错误, 则会发生这种情况。
例如, 如果没有可用内存, 则malloc系统调用将返回NULL。如果未检查来自malloc的返回, 则应用程序在尝试访问无效的内存位置时可能会崩溃。根据情况, 可能很难找到此类问题。
在某些情况下, 致命错误日志或崩溃转储中的信息就足够了。如果确定崩溃的原因是某些内存分配中缺少错误处理, 那么你必须找出导致分配失败的原因。与其他任何本机堆问题一样, 系统可能配置为交换空间不足, 另一个进程可能正在消耗所有可用的内存资源, 等等。
诊断泄漏
在大多数情况下, 诊断内存泄漏需要非常详细的有关应用程序的知识。警告:该过程可能是漫长且反复的。
我们寻找内存泄漏的策略将相对简单:
-
识别症状
-
启用详细垃圾回收
-
启用分析
-
分析痕迹
1.识别症状
如前所述, 在许多情况下, Java进程最终将抛出OOM运行时异常, 这清楚地表明你的内存资源已用尽。在这种情况下, 你需要区分正常的内存耗尽和泄漏。分析OOM的消息, 并根据上面提供的讨论尝试找出罪魁祸首。
通常, 如果Java应用程序请求的存储空间超过了运行时堆提供的存储空间, 则可能是由于设计不佳所致。例如, 如果应用程序创建图像的多个副本或将文件加载到数组中, 则当图像或文件很大时, 它将耗尽存储空间。这是正常的资源耗尽。该应用程序按设计工作(尽管此设计显然是笨拙的)。
但是, 如果应用程序在处理相同类型的数据时稳步提高其内存利用率, 则可能会发生内存泄漏。
2.启用详细垃圾收集
断言你确实存在内存泄漏的最快方法之一是启用详细垃圾回收。通常可以通过检查verbosegc输出中的模式来识别内存约束问题。
具体来说, -verbosegc参数使你可以在每次垃圾回收(GC)进程开始时生成跟踪。也就是说, 在内存被垃圾回收时, 摘要报告会以标准错误打印, 从而使你了解如何管理内存。
以下是使用–verbosegc选项生成的一些典型输出:
此GC跟踪文件中的每个块(或节)都按升序编号。为了理解此跟踪, 你应该查看连续的”分配失败”节, 并在总内存(此处为19725304)增加的同时, 查看释放的内存(字节和百分比)随着时间的推移而减少的情况。这些是内存耗尽的典型迹象。
3.启用分析
不同的JVM提供了不同的方式来生成跟踪文件以反映堆活动, 这些文件通常包括有关对象类型和大小的详细信息。这称为分析堆。
4.分析痕迹
这篇文章重点介绍Java VisualVM生成的跟踪。跟踪可以采用不同的格式, 因为它们可以由不同的Java内存泄漏检测工具生成, 但是跟踪的思想始终是相同的:在堆中查找不应存在的对象块, 并确定这些对象是否累积而不是释放。特别令人感兴趣的是已知每次在Java应用程序中触发某个事件时都会分配的临时对象。通常应该少量存在的许多对象实例的存在通常表明存在应用程序错误。
最后, 解决内存泄漏要求你彻底检查代码。了解对象泄漏的类型可能非常有帮助, 并且可以大大加快调试速度。
垃圾回收如何在JVM中工作?
在开始分析存在内存泄漏问题的应用程序之前, 首先让我们看一下垃圾回收在JVM中的工作方式。
JVM使用一种称为跟踪收集器的垃圾收集器, 其本质是通过暂停周围的环境, 标记所有根对象(运行线程直接引用的对象)并遵循它们的引用, 标记其沿途看到的每个对象来进行操作。
Java根据世代假设假设实现了一种称为世代垃圾收集器的东西, 该假设指出, 创建的大多数对象都会被迅速丢弃, 而那些未迅速收集的对象可能会存在一段时间。
基于此假设, Java将对象划分为多个世代。这是视觉上的解释:
年轻一代-这是物体开始的地方。它有两个子代:
-
伊甸园空间-对象从此处开始。大多数对象都是在伊甸园空间中创建和销毁的。在这里, GC执行次要GC, 这是优化的垃圾回收。执行次要GC时, 对仍需要的对象的所有引用都将迁移到幸存者空间之一(S0或S1)。
-
幸存者空间(S0和S1)-在伊甸园中幸存的物体最终出现在这里。其中有两种, 并且在任何给定时间都只使用一种(除非发生严重的内存泄漏)。一个被指定为空, 另一个被指定为活动, 与每个GC周期交替。
终身代(tenured generation)-也称为旧一代(图2中的旧空间), 该空间保存着寿命较长的较旧对象(如果幸存时间足够长, 则从幸存者空间移开)。填满此空间后, GC会执行Full GC, 这会导致性能损失更高。如果此空间无限增长, 则JVM将抛出OutOfMemoryError-Java堆空间。
永久代-永久代与第三代密切相关, 永久代是特殊的, 因为它保存虚拟机所需的数据来描述在Java语言级别上不具有等效性的对象。例如, 描述类和方法的对象存储在永久代中。
Java很聪明, 可以将不同的垃圾回收方法应用于每一代。年轻的一代使用称为Parallel New Collector的跟踪复制收集器进行处理。这个收藏家制止了世界, 但是由于年轻一代通常很小, 因此停顿很短。
有关JVM代及其工作原理的更多信息, 请访问Java HotSpot™虚拟机文档中的内存管理。
检测内存泄漏
若要查找并消除内存泄漏, 你需要适当的内存泄漏工具。现在该使用Java VisualVM检测并消除此类泄漏。
使用Java VisualVM远程分析堆
VisualVM是提供可视界面的工具, 可用于在运行基于Java技术的应用程序时查看有关它们的详细信息。
使用VisualVM, 你可以查看与本地应用程序以及在远程主机上运行的应用程序相关的数据。你还可以捕获有关JVM软件实例的数据, 并将数据保存到本地系统。
为了从Java VisualVM的所有功能中受益, 你应该运行Java平台标准版(Java SE)版本6或更高版本。
相关:为什么需要升级到Java 8
为JVM启用远程连接
在生产环境中, 通常很难访问将运行我们的代码的实际计算机。幸运的是, 我们可以远程分析Java应用程序。
首先, 我们需要授予自己对目标计算机的JVM访问权限。为此, 请创建一个名为jstatd.all.policy的文件, 其内容如下:
grant codebase "file:${java.home}/../lib/tools.jar" {
permission java.security.AllPermission;
};
创建文件后, 我们需要使用jstatd-虚拟机jstat守护程序工具启用到目标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 VisualVM, 我们可以对Java堆进行内存监视, 并确定其行为是否表明存在内存泄漏。
初始化后, 这是MemLeak的Java Heap分析器的图形表示(请回想一下我们讨论的各个阶段):
在短短30秒后, “旧一代”几乎已满, 这表明, 即使使用Full GC, “旧一代”仍在不断增长, 这显然是内存泄漏的迹象。
下图显示了一种检测这种泄漏原因的方法(单击放大), 该图是使用Java VisualVM和heapdump生成的。在这里, 我们看到50%的Hashtable $ Entry对象在堆中, 而第二行将我们指向MemLeak类。因此, 内存泄漏是由MemLeak类中使用的哈希表引起的。
最后, 在我们的OutOfMemoryError之后观察Java堆, 其中Young和Old几代人已完全装满。
总结
内存泄漏是最难解决的Java应用程序问题之一, 因为症状多种多样且难以重现。在这里, 我们概述了逐步发现内存泄漏并识别其来源的方法。但最重要的是, 仔细阅读错误消息并注意堆栈跟踪-并非所有的泄漏都像它们看上去的那么简单。
附录
与Java VisualVM一起, 还有其他一些工具可以执行内存泄漏检测。许多泄漏检测器通过拦截对内存管理例程的调用来在库级别进行操作。例如, HPROF是与Java 2 Platform Standard Edition(J2SE)捆绑在一起的用于堆和CPU性能分析的简单命令行工具。 HPROF的输出可以直接进行分析, 也可以用作其他工具(如JHAT)的输入。当我们使用Java 2 Enterprise Edition(J2EE)应用程序时, 有许多更友好的堆转储分析器解决方案, 例如用于Websphere应用程序服务器的IBM Heapdumps。
评论前必须登录!
注册