寻找 Java 内存泄漏

已发表: 2022-03-11

没有经验的程序员通常认为 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 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 堆空间”

此错误消息并不一定意味着内存泄漏。 事实上,问题可以像配置问题一样简单。

例如,我负责分析一个始终产生这种类型的OutOfMemoryError的应用程序。 经过一番调查,我发现罪魁祸首是一个需要太多内存的数组实例化; 在这种情况下,这不是应用程序的错,而是应用程序服务器依赖于默认的堆大小,它太小了。 我通过调整JVM的内存参数解决了这个问题。

在其他情况下,特别是对于长期存在的应用程序,该消息可能表明我们无意中持有对对象的引用,从而阻止垃圾收集器清理它们。 这是内存泄漏的 Java 语言等价物。 (注意:应用程序调用的 API 也可能无意中持有对象引用。

这些“Java 堆空间”OOM 的另一个潜在来源是使用终结器。 如果一个类有一个finalize方法,那么该类型的对象在垃圾回收时不会回收它们的空间。 相反,在垃圾回收之后,对象会排队等待最终确定,这将在稍后发生。 在 Sun 实现中,终结器由守护线程执行。 如果终结器线程无法跟上终结队列,则 Java 堆可能会填满并可能引发 OOM。

“永久代空间”

此错误消息表明永久代已满。 永久代是堆中存储类和方法对象的区域。 如果应用程序加载大量类,则可能需要使用-XX:MaxPermSize选项增加永久代的大小。

内部的java.lang.String对象也存储在永久代中。 java.lang.String类维护一个字符串池。 调用 intern 方法时,该方法会检查池以查看是否存在等效字符串。 如果是,则由 intern 方法返回; 如果不是,则将该字符串添加到池中。 更准确地说, java.lang.String.intern方法返回字符串的规范表示; 结果是对同一类实例的引用,如果该字符串以文字形式出现,则会返回该类实例。 如果应用程序实习生大量字符串,您可能需要增加永久代的大小。

注意:您可以使用jmap -permgen命令打印与永久代相关的统计信息,包括有关内部化 String 实例的信息。

“请求的阵列大小超出 VM 限制”

此错误表明应用程序(或该应用程序使用的 API)试图分配一个大于堆大小的数组。 例如,如果应用程序尝试分配 512MB 的数组,但最大堆大小为 256MB,则将引发 OOM 并显示此错误消息。 在大多数情况下,问题要么是配置问题,要么是应用程序尝试分配大量数组时产生的错误。

“为 <reason> 请求 <size> 个字节。 交换空间不足?”

此消息似乎是 OOM。 但是,当从本机堆分配失败并且本机堆可能接近耗尽时,HotSpot VM 会抛出这个明显的异常。 消息中包括失败请求的大小(以字节为单位)和内存请求的原因。 在大多数情况下,<reason> 是报告分配失败的源模块的名称。

如果抛出这种类型的 OOM,您可能需要使用操作系统上的故障排除实用程序来进一步诊断问题。 在某些情况下,问题甚至可能与应用程序无关。 例如,如果出现以下情况,您可能会看到此错误:

  • 操作系统配置的交换空间不足。

  • 系统上的另一个进程正在消耗所有可用的内存资源。

应用程序也可能由于本机泄漏而失败(例如,如果某些应用程序或库代码不断分配内存但未能将其释放到操作系统)。

<原因> <堆栈跟踪>(本机方法)

如果您看到此错误消息并且堆栈跟踪的顶部框架是本机方法,则该本机方法遇到分配失败。 此消息与上一条消息的不同之处在于,Java 内存分配失败是在 JNI 或本机方法中检测到的,而不是在 Java VM 代码中检测到的。

如果抛出这种类型的 OOM,您可能需要使用操作系统上的实用程序来进一步诊断问题。

没有 OOM 的应用程序崩溃

有时,应用程序可能会在本机堆分配失败后很快崩溃。 如果您运行的本机代码不检查内存分配函数返回的错误,则会发生这种情况。

例如,如果没有可用内存, malloc系统调用将返回NULL 。 如果未检查malloc的返回,则应用程序在尝试访问无效内存位置时可能会崩溃。 根据具体情况,此类问题可能难以定位。

在某些情况下,来自致命错误日志或故障转储的信息就足够了。 如果确定崩溃的原因是在某些内存分配中缺乏错误处理,那么您必须找出所述分配失败的原因。 与任何其他本机堆问题一样,系统可能配置为交换空间不足,另一个进程可能正在消耗所有可用内存资源等。

诊断泄漏

在大多数情况下,诊断内存泄漏需要非常详细的应用程序知识。 警告:该过程可能会很漫长且反复。

我们寻找内存泄漏的策略将相对简单:

  1. 识别症状

  2. 启用详细垃圾收集

  3. 启用分析

  4. 分析轨迹

1. 识别症状

正如所讨论的,在许多情况下,Java 进程最终会抛出 OOM 运行时异常,这清楚地表明您的内存资源已经耗尽。 在这种情况下,您需要区分正常的内存耗尽和泄漏。 分析 OOM 的消息,并根据上面提供的讨论尝试找出罪魁祸首。

通常,如果 Java 应用程序请求的存储空间比运行时堆提供的更多,可能是由于设计不佳。 例如,如果应用程序创建图像的多个副本或将文件加载到数组中,则当图像或文件非常大时,它将耗尽存储空间。 这是正常的资源耗尽。 该应用程序正在按设计工作(尽管这种设计显然是愚蠢的)。

但是,如果应用程序在处理相同类型的数据时稳步增加其内存利用率,则可能会出现内存泄漏。

2.启用详细垃圾收集

断言您确实存在内存泄漏的最快方法之一是启用详细垃圾收集。 内存约束问题通常可以通过检查verbosegc输出中的模式来识别。

具体来说, -verbosegc参数允许您在每次垃圾收集 (GC) 进程开始时生成跟踪。 也就是说,当内存被垃圾收集时,摘要报告会打印为标准错误,让您了解内存的管理方式。

以下是使用–verbosegc选项生成的一些典型输出:

详细的垃圾收集输出

此 GC 跟踪文件中的每个块(或节)按递增顺序编号。 要理解此跟踪,您应该查看连续的分配失败节,并查找释放的内存(字节和百分比)随着时间的推移而减少,而总内存(此处为 19725304)正在增加。 这些是内存耗尽的典型迹象。

3.启用分析

不同的 JVM 提供了不同的方法来生成跟踪文件以反映堆活动,这些跟踪文件通常包括有关对象类型和大小的详细信息。 这称为分析堆

4. 分析痕迹

本文重点介绍 Java VisualVM 生成的跟踪。 跟踪可以有不同的格式,因为它们可以由不同的 Java 内存泄漏检测工具生成,但它们背后的想法总是相同的:在堆中找到一个不应该存在的对象块,并确定这些对象是否累积而不是释放。 特别感兴趣的是已知每次在 Java 应用程序中触发某个事件时分配的瞬态对象。 许多本应仅以少量存在的对象实例的存在通常表明存在应用程序错误。

最后,解决内存泄漏需要您彻底检查您的代码。 了解对象泄漏的类型非常有帮助,并且可以大大加快调试速度。

垃圾回收在 JVM 中是如何工作的?

在开始分析存在内存泄漏问题的应用程序之前,让我们先看看垃圾收集在 JVM 中是如何工作的。

JVM 使用一种称为跟踪收集器的垃圾收集器,它的工作原理是暂停周围的世界,标记所有根对象(正在运行的线程直接引用的对象),并跟随它们的引用,标记沿途看到的每个对象。

Java 基于世代假设假设实现了一种称为世代垃圾收集器的东西,该假设指出,大多数创建的对象会很快被丢弃,而没有快速收集的对象可能会存在一段时间

基于这个假设,Java 将对象划分为多代。 这是一个视觉解释:

Java 分成多代

  • 年轻一代- 这是对象开始的地方。 它有两个子代:

    • Eden Space - 对象从这里开始。 大多数对象都是在伊甸园空间中创建和销毁的。 在这里,GC 执行Minor GC ,这是优化的垃圾收集。 执行 Minor GC 时,对仍然需要的对象的任何引用都将迁移到幸存者空间之一(S0 或 S1)。

    • 幸存者空间(S0 和 S1) - 在伊甸园中幸存下来的物体最终会出现在这里。 其中有两个,并且在任何给定时间只有一个在使用(除非我们有严重的内存泄漏)。 一个被指定为empty ,另一个被指定为live ,与每个 GC 周期交替。

  • Tenured Generation - 也称为老年代(图 2 中的旧空间),该空间保存具有更长生命周期的旧对象(如果它们存活时间足够长,则从幸存者空间移出)。 当这个空间被填满时,GC 会执行一次Full GC ,这在性能方面会花费更多。 如果这个空间无限增长,JVM 将抛出OutOfMemoryError - Java heap space

  • 永久代- 与终身代密切相关的第三代,永久代的特殊之处在于它保存了虚拟机所需的数据来描述在 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 - 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 应用程序,如下所示:

在目标 jvm 中运行

要在应用程序上运行内存分析器,我们只需在侧面板中双击其名称。

现在我们都设置了内存分析器,让我们调查一个存在内存泄漏问题的应用程序,我们将其称为MemLeak

内存泄漏

当然,有很多方法可以在 Java 中创建内存泄漏。 为简单起见,我们将定义一个类作为HashMap中的键,但我们不会定义 equals() 和 hashcode() 方法。

HashMap 是 Map 接口的哈希表实现,因此它定义了键和值的基本概念:每个值都与唯一的键相关,因此如果给定键值对的键已经存在于HashMap,它的当前值被替换。

我们的 key 类必须提供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 堆分析器的图形表示(回想一下我们对各代的讨论):

使用 java visualvm 监控内存泄漏

仅仅 30 秒后,Old Generation 就快满了,这表明即使使用 Full GC,Old Generation 也在不断增长,这是内存泄漏的明显迹象。

下图显示了一种检测此泄漏原因的方法(单击放大),它是使用带有heapdump的 Java VisualVM 生成的。 在这里,我们看到50% 的 Hashtable$Entry 对象在堆中,而第二行将我们指向MemLeak类。 因此,内存泄漏是由MemLeak类中使用的哈希表引起的。

哈希表内存泄漏

最后,观察OutOfMemoryError之后的 Java Heap,其中Young 和 Old 代都已满

内存不足错误

结论

内存泄漏是最难解决的 Java 应用程序问题之一,因为其症状多种多样且难以重现。 在这里,我们概述了发现内存泄漏和识别其来源的逐步方法。 但最重要的是,仔细阅读您的错误消息并注意您的堆栈跟踪——并非所有泄漏都像看起来那么简单。

附录

除了 Java VisualVM,还有其他几个可以执行内存泄漏检测的工具。 许多泄漏检测器通过拦截对内存管理例程的调用在库级别运行。 例如, HPROF是一个与 Java 2 平台标准版 (J2SE) 捆绑在一起的简单命令行工具,用于堆和 CPU 分析。 HPROF的输出可以直接分析或用作其他工具(如JHAT )的输入。 当我们使用 Java 2 Enterprise Edition (J2EE) 应用程序时,有许多更友好的堆转储分析器解决方案,例如用于 Websphere 应用程序服务器的 IBM Heapdumps。