第7章:避免内存泄漏

在上一章中,我们探讨了如何在JVM中配置和监控内存管理。这涉及与JVM调优相关的指标知识。我们讨论了如何获取这些指标,以及如何相应地调优JVM。我们还研究了如何使用性能分析来深入了解调优的效果。

本章重点介绍内存泄漏。我们将从以下几个方面探讨内存泄漏:

  • 理解内存泄漏
  • 发现内存泄漏
  • 避免内存泄漏

让我们从理解内存泄漏开始。之后,我们将学习如何在代码中发现它们,并了解如何避免和解决它们。

技术要求

本章的代码可在GitHub上获取: https://github.com/PacktPublishing/B18762_Java-Memory-Management

理解内存泄漏

当不再需要的对象没有被释放时,就会发生内存泄漏。这导致这些对象在内存中累积。由于内存是有限资源,最终可能导致你的应用程序变慢甚至崩溃(出现内存溢出(OOM)错误)。

拥有快速服务器或将应用程序托管在云端,并不能使你免受内存管理不善(内存泄漏)的影响。

如前所述,内存是有限资源,即使快速服务器也可能耗尽内存。如果在云端部署,为了应对内存泄漏问题而简单地扩展资源是很有诱惑力的;但这会导致部署一个比实际所需更大的实例,从而增加成本,甚至可能导致高昂的云服务账单。

内存耗尽的速度取决于内存泄漏发生在代码的哪个位置。如果这是一段很少执行的代码,那么内存完全填满需要很长时间。但如果这是一段频繁执行的代码,则可能会快得多。

尽管内存泄漏的原因可能各不相同,但一个可能的罪魁祸首是你的代码中的bug。这引出了我们的下一个主题:发现内存泄漏。

发现内存泄漏

你可能会想,当你的应用程序运行一段时间后开始响应变慢时,典型情况是怎样的。系统管理员可能只是时不时重启应用程序,以释放不必要累积的内存。这种重启需求是内存泄漏的典型症状。

由于内存泄漏导致内存被填满,应用程序会变慢甚至崩溃。虽然应用程序变慢不一定是由内存泄漏引起的,但情况往往如此。当遇到怀疑包含内存泄漏的代码时,以下指标对诊断应用程序非常有帮助:

  • 堆内存占用
  • 垃圾回收活动
  • 堆转储

为了演示如何监控这些指标,我们需要一个包含内存泄漏的应用程序。图7.1展示了这样一个程序:

// 伪代码表示,实际代码在书中
// 图7.1 – 存在内存泄漏的程序
public class OutOfMemoryExample {
    public static void main(String[] args) {
        List<Person> list = new ArrayList<>();
        while (true) {
            Person p = new Person();
            list.add(p);
        }
    }
}

图7.1 – 存在内存泄漏的程序

在图7.1中,我们处于一个从第15行开始的无限循环中,创建Person对象并将其添加到ArrayList对象中。由于每个Person引用p都被重新初始化,很容易认为该引用之前指向的每个Person对象现在都有资格进行垃圾回收。然而,情况并非如此,因为这些Person对象正被ArrayList对象引用,因此垃圾回收器无法回收它们。因此,虽然无限循环最终导致程序内存耗尽,但内存泄漏本身是因为垃圾回收器无法回收这些Person对象。让我们研究如何诊断正在运行的代码,以帮助我们得出这个结论。

我们将使用命令行运行此程序,因为我们可以轻松指定:如果堆内存耗尽,则将堆转储到文件中。当前目录是:

C:\Users\skennedy\eclipse-workspace\MemoryMgtBook\src\

以下命令行(为清晰起见分成多行)实现了这一点:

java
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=C:\Users\skennedy\eclipse-workspace\MemoryMgtBook\src\ch7
ch7.OutOfMemoryExample

这里有趣的部分是指定的-XX选项。首先,我们启用HeapDumpOnOutOfMemoryError选项。这意味着如果堆内存耗尽,JVM会将堆转储到一个文件中。我们现在需要做的就是指定该文件的位置和名称。这就是第二个-XX选项所做的,使用HeapDumpPath标志。

现在我们已经启动了受内存泄漏影响的应用程序,我们将使用VisualVM应用程序来监控感兴趣的指标。VisualVM曾经随Java SDK一起提供,但现在你必须从https://visualvm.github.io/download.html(注意:这是撰写本文时的有效链接)单独下载。让我们从堆内存占用开始诊断。

堆内存占用

我们在这里寻找的不是堆本身的大小,而是使用的堆内存量。我们也很感兴趣垃圾回收器是否回收了使用的堆内存。图7.2展示了图7.1中所述应用程序的堆占用情况:

图7.2 – 堆内存占用

从上图可以看出,已用堆内存(x轴与图形线之间的区域)迅速占用了所有可用的堆空间。垃圾回收器确实回收了一些内存(左侧的下降),但这并非由我们的应用程序分配的内存。程序因OutOfMemoryError错误而耗尽内存并崩溃。这就是已用堆内存回到0的原因。

让我们检查这段期间的垃圾回收活动。

垃圾回收活动

在上一节中,我们看到了包含内存泄漏的应用程序对堆占用的影响。检查该期间垃圾回收器的活动也很有趣。图7.3反映了这一点:

图7.3 – 垃圾回收活动

图7.3显示,在程序运行期间,垃圾回收器非常繁忙。然而,根据图7.2,我们知道这对释放堆上(由我们的应用程序分配的)空间没有影响。因此,尽管垃圾回收器繁忙,堆仍然保持已满状态。这是内存泄漏的典型标志。

现在,我们已经验证了程序中存在内存泄漏。下一步是找出导致泄漏的原因。在我们的例子中,这相当明显,但为了帮助更好地理解,让我们进一步调查。下一步是查看程序崩溃时JVM创建的堆转储。

堆转储

当我们运行应用程序时,我们指定了如果应用程序内存耗尽,则创建堆转储。这将使我们能够进一步调试首先耗尽内存的原因。图7.4展示了生成的堆转储摘要:

图7.4 – 堆转储摘要

图7.4中有两个值立即引人注目。第一个是实例数量(第一个箭头)。205,591,192个实例,这太多了。现在,我们需要知道是什么类型的实例导致了内存泄漏。第二个红色箭头将ch7.Person标记为有问题的类型,因为仅该类型就有205,544,625个实例。

堆转储还允许我们进一步深入。在本例中,我们确实会这样做,因为我们想看看是什么阻止了这些Person对象被垃圾回收。图7.5将帮助我们讨论这一点:

图7.5 – 堆转储深入

在上图中,我们从摘要层级深入到了对象层级。我们知道有很多Person对象。通过深入任何一个Person对象,我们可以看到引用它的类型。如其中一个Person对象(以蓝色高亮)所示,我们可以看到它是一个ArrayList对象。

现在,我们对正在发生的事情有了更清晰的认识。我们将Person对象添加到一个ArrayList对象中,而该ArrayList对象的引用永远不会超出作用域。因此,垃圾回收器无法从堆中移除任何这些Person对象,我们最终得到了一个OutOfMemoryError错误。

总而言之,在本节中,我们诊断了一个包含内存泄漏的程序。使用堆内存占用和垃圾回收活动,我们确认了内存泄漏的存在。然后我们分析了堆转储,以确定有问题的集合(ArrayList)和类型(Person)。下一节将处理如何首先避免内存泄漏。

避免内存泄漏

避免内存泄漏的最好方法首先是编写不包含任何泄漏的代码。换句话说,我们不再需要的对象不应与栈有连接,因为这会阻止垃圾回收器回收它们。在介绍帮助你避免代码泄漏的技术之前,让我们先修复图7.1中展示的泄漏。图7.6展示了无泄漏的代码:

// 伪代码表示,实际代码在书中
// 图7.6 – 无泄漏程序
public class LeakFreeExample {
    public static void main(String[] args) {
        List<Person> list = new ArrayList<>();
        int i = 0;
        while (true) {
            Person p = new Person();
            list.add(p);
            i++;
            if (i == 1000) {
                list = new ArrayList<>();
                i = 0;
            }
        }
    }
}

图7.6 – 无泄漏程序

在图7.6中,无限循环仍然存在。然而,第19至23行是新增的。在这个新部分中,每次向ArrayList对象添加Person引用时,我们都会递增一个局部变量i。一旦完成1000次,我们就重新初始化列表引用。这至关重要,因为它使垃圾回收器能够回收旧的ArrayList对象以及从该ArrayList对象引用的1000个Person对象。此外,我们将i重置为0。这将解决泄漏。(如果你为此特定示例找到了用例,请给我们发邮件,我们会将其加入本书的下一版。不过,它确实很好地展示了示例图表。)

现在我们将使用与之前相同的命令行参数运行该程序。程序不会生成OutOfMemoryError错误。我们现在将使用VisualVM检查代码的性能。图7.7反映了新的无泄漏代码的堆内存占用:

图7.7 – 堆内存占用(无泄漏代码)

如上图所示,已用堆空间(x轴与图形之间的区域)上下波动。下降区域反映了垃圾回收器回收内存的位置。这种模式类似于锯齿,是健康程序的标志。在接近末尾时,我们停止了程序的运行。

接下来,我们将查看该期间的垃圾回收活动。

第7章:避免内存泄漏

探讨内存泄漏的识别、预防和避免方法。

(图表的)内存占用上下波动。下降的区域反映了垃圾回收器回收内存的时刻。这种模式类似于锯齿状,是程序健康的标志。在最后,我们停止了程序的运行。

接下来,我们将查看这段时间内垃圾回收器的活动。图7.8反映了这一情况:

图7.8 – 垃圾回收器活动(无泄漏代码)

在图7.3(表示存在内存泄漏的代码的图表)中,垃圾回收器的运行时间超过5%。然而,在图7.8中,垃圾回收器的活动几乎无法察觉,几乎与X轴重合。再次强调,这是程序健康的标志。由于此程序不会耗尽堆空间,因此无需进行堆转储。

常见陷阱及如何避免

既然我们已经解决了内存泄漏问题,接下来将回顾代码中一些常见问题以及如何避免它们。我们将讨论一些技术,这些技术使我们能够编写无泄漏的代码,并以最优方式使用内存,而不浪费我们实际上不需要使用的资源。

有些技巧比较明显,不需要大量示例,例如:如果系统允许,为程序分配适量的堆空间;不要创建不需要的对象;尽可能重用对象。而有些技巧则需要更多解释,我们接下来将详细阐述。

堆栈上不必要的引用以及将引用设为null

堆栈上可能存在实际上不再需要的引用。在我们前面的示例中,就是这种情况。

重新初始化引用(或将其设为null)是本部分用于修复内存泄漏的方法。这两种方法都断开了指向堆栈的链接,使垃圾回收器能够回收堆内存。但请注意,只有在应用程序使用完这些对象后才应执行此操作;否则,您将遇到 NullPointerException。请参见以下示例:

Person personObj = new Person();
// 使用 personObj
personObj = null;

在此示例中,我们将一个对象引用存储在 personObj 中;当我们不再需要它时,将其设为 null。这样,堆上的 Person 对象在我们将其设为 null 的那一行之后(假设我们没有将该引用赋值给其他变量)就有资格进行垃圾回收了。

这种方法是否仍适用于当今的软件是值得商榷的;对于大多数现代应用程序,这种方法不太受欢迎,但当然,也可能存在合理的用例。

资源泄漏与关闭资源

当您打开文件、数据库、流等资源时,它们会占用内存。如果这些资源未被关闭,可能导致资源泄漏。在某些情况下,甚至可能导致可用资源的严重枯竭,并影响应用程序的性能——例如,缓冲区可能被填满。如果您正在产生输出(例如,写入文件或提交到数据库),未关闭资源实际上可能导致数据持久化或写入不正确,数据可能无法到达预期的目的地,如输出文件或数据库。

在使用完资源(如文件和数据库连接)后关闭它们是防止这种情况发生的一种方法。使用 finally 块或 try-with-resources 在这里非常有帮助。finally 块无论如何都会执行,无论是否发生异常。try-with-resources 内置了一个 finally 块,用于关闭在 try 部分打开的任何资源。使用 finally 块或 try-with-resources 可以确保资源将被关闭。

考虑以下常规 try-catch 块的代码:

String path = "some path";
FileReader fr = null;
BufferedReader br = null;
try {
    fr = new FileReader(path);
    br = new BufferedReader(fr);
    System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}

在这段代码片段中,我们打开了一个 FileReader 和一个 BufferedReader 类,并在 catch 块中处理了受检异常。但是,我们从未关闭它们。这样,它们就没有资格进行垃圾回收。请确保关闭它们。这可以在 finally 块中完成,如下所示:

String path = "some path";
FileReader fr = null;
BufferedReader br = null;
try {
    fr = new FileReader(path);
    br = new BufferedReader(fr);
    System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}
finally {
    if(br != null) {
        br.close();
    }
    if(fr != null) {
        fr.close();
    }
}

finally 块无论是否发生异常都会执行。这样,我们可以确保资源被关闭。

从 Java 7 开始,更常见的是使用 try-with-resources。在 try 块结束时,它会调用在 try 语句中初始化的对象的 close() 方法(这些对象必须实现 AutoCloseable 接口)。以下是前面示例使用 try-with-resources 的样子:

String path = "some path";
try (FileReader fr = new FileReader(path);
    BufferedReader br = new BufferedReader(fr)) {
                        System.out.println(br.readLine());
} catch(IOException e) {
    e.printStackTrace();
}

如您所见,这要简洁得多,并且可以防止您忘记关闭资源。因此,建议尽可能使用 try-with-resources

使用 StringBuilder 避免不必要的 String 对象

String 对象是不可变的,因此创建后无法更改。在后台,您请求的更改会导致创建一个新的 String 对象(反映了您的更改),而原始 String 对象保持不变。

例如,当您将一个 String 对象连接到另一个 String 对象时,实际上会在内存中产生三个不同的对象:原始的 String 对象、您想要连接的 String 对象,以及反映连接结果的新生成的 String 对象。

将字符串连接代码放入循环中,会在后台创建许多不必要的对象。考虑以下示例:

String strIntToChar = "";
for(int i = 97; i < 123; i++) {
    strIntToChar += i + ": " + (char)i + "\n";
}
System.out.println(strIntToChar);

这是循环结束后输出的 String 对象的样子。我们省略了中间部分,以免使代码片段过长:

97: a
98: b
99: c
... 省略中间 ...
120: x
121: y
122: z

在此示例中,我们创建了大量对象,每次中间连接步骤都会创建一个新对象。例如,在头两次迭代之后,strIntToChar 的值是:

97: a
98: b

经过三次迭代之后,它是:

97: a
98: b
99: c

所有这些中间值都存储在字符串池中。这是因为 String 对象是不可变的,并且字符串池被用作一种优化,但在这里却起到了反作用。

这个问题的解决方案是使用 StringBuilderStringBuilder 对象是可变的。如果我们使用 StringBuilder 重写前面的代码,创建的对象会少得多,因为我们不会为每个中间值创建一个单独的 String 对象。使用 StringBuilder 的代码如下所示:

StringBuilder sbIntToChar  = new StringBuilder("");
for(int i = 97; i < 123; i++) {
    sbIntToChar.append(i + ": " + (char)i + "\n");
}
System.out.println(sbIntToChar);

连接时,JVM 会操作原始的 StringBuilder 对象,而不会创建新的 StringBuilder 对象。如您所见,不需要对代码进行大幅更改,但内存管理却得到了很大改善。因此,当需要大量连接 String 对象时,请使用 StringBuilder

使用基本类型代替包装类来管理内存使用

包装类需要的内存比基本类型多得多。有时,您必须使用包装类——这是无法选择的。在其他情况下,使用基本类型代替包装类型是一个选项。例如,创建一个 int 类型的局部变量,而不是 Integer

基本变量占用少量内存,并且如果基本变量是方法内部的局部变量,它会存储在栈上(访问速度比堆快)。另一方面,包装器是类类型,并且总是在堆上创建一个对象。此外,如果可能,您应该使用 longdouble 基本类型,而不是 BigIntegerBigDecimalBigDecimal 尤其因其计算精度而受欢迎。然而,这种精度是以需要更多内存和更慢的计算为代价的,因此只有在真正需要精度时才使用此类。

请注意,这并非要防止实际的内存泄漏,而是通过不占用超出实现应用程序目标所需的内存量来优化内存使用。

静态集合的问题以及为什么要避免

在某些情况下,在类中使用静态集合来保存应用程序中的对象可能很诱人,尤其是在您使用纯 Java SE 环境并且希望存储对象时。这对于健康的内存占用来说是相当危险的。此类示例如下所示:

public class AvoidingStaticCollections {
    public static List<Person> personList = new
        ArrayList<>();
    public static void addPerson(Person p) {
        personList.add(p);
    }
    // 其他代码省略
}

这可能会很快失控。创建的对象无法被垃圾回收,因为静态集合使它们保持活动状态。有几种更好的方法可以解决这个问题。如果您确实觉得需要这样做,那么您可能可以使用数据库来代替。

如果您使用 HashMap 类作为静态集合,则可以使用 WeakHashMap(自Java 8起)来代替。这将为键使用弱引用(请注意,不是值——值由强引用持有)。这些键引用在 WeakHashMap 中存储为弱引用,但这不会阻止垃圾回收器从堆中移除对象。如果键不再被应用程序的其他部分使用,WeakHashMap 中的条目将被移除。这意味着,丢失其他地方不再引用的信息应该是可以的。因此,如果您的意图是在 HashMap 中维护信息,则不应改用 WeakHashMap。但是,如果您的 HashMap 的键仅被 HashMap 本身引用时,您不需要它们在堆上保持存在,那么使用 WeakHashMap 可能是优化堆使用的一种方式。与往常一样,在实施之前,请仔细研究它是否符合您的要求。

总结

在本章中,我们学习了如何避免代码中的内存泄漏。第一步是理解内存泄漏发生在当不再需要的对象仍然保持与堆栈的链接时。这阻止了垃圾回收器回收它们。鉴于内存是一种有限资源,这绝对是不可取的。随着这些对象的累积,您的应用程序速度会变慢,并最终崩溃。

内存泄漏的一个常见来源是代码中的错误。但是,有一些方法可以调试内存泄漏。为了演示如何调试存在泄漏的代码,我们展示了一个包含内存泄漏的程序。VisualVM 是一个工具,使我们能够监控感兴趣的指标——堆内存占用、垃圾回收活动以及堆转储(当我们耗尽堆空间时)。

堆占用验证了内存泄漏的存在,因为它显示已使用的堆空间完全占用了可用的堆空间。换句话说,堆上的对象未被回收。与此同时,垃圾回收器徒劳地、极其忙碌地试图释放堆空间。为了找出是哪种类型导致了问题,我们检查了堆转储。这让我们找到了一个引用大量 Person 实例的 ArrayList 对象。

我们修复了有泄漏的代码,并再次使用 VisualVM 检查了堆占用和垃圾回收活动指标。这两个指标都健康得多。

然而,避免内存泄漏的最佳方法不是事后修复,而是要…(文本在此处截断,但原文应继续阐述预防的重要性等后续内容)。

本章总结

  • 预防胜于治疗:从一开始就避免编写导致内存泄漏的代码。
  • 讨论了一些从根源上避免内存泄漏的常用技术。
  • 通过诊断并修复包含内存泄漏的代码,展示了识别和解决问题的过程。
  • 强调了编写代码时需注意的事项,以及如何从一开始优化内存使用。

首先,这类似于“预防胜于治疗”的原则。基于此,我们讨论了一些从一开始就能避免内存泄漏的常用技术。

本章到此结束。简而言之,我们首先介绍了内存泄漏发生的原因和方式,然后诊断并修复了包含内存泄漏的代码,最后讨论了在编写代码时需要注意什么以防止写出有泄漏的代码,以及如何从一开始就优化内存使用。

这不仅仅是本章的结束,也是本书的结束。我们首先概述了内存,然后深入探讨了不同的方面;之后,我们深入研究了垃圾回收;本书的最后几章重点讨论了如何提高性能:如何调优 JVM 以及如何避免内存泄漏。

如果您想了解更多关于 JVM 如何管理内存的信息,JVM 的官方文档随时为您服务。您可以在此处找到最新版本:https://docs.oracle.com/javase/specs/index.html。

7.0 第7章:避免内存泄漏

  • 图片920(第148页)
  • 图片925(第150页)
  • 图片928(第152页)
  • 图片930(第153页)
  • 图片932(第154页)
  • 图片935(第156页)
  • 图片937(第157页)
  • 图片939(第158页)

7.0 第7章:避免内存泄漏

图片说明

本章包含多张图片,但视觉模型未能提取到有效信息。以下列出各图片的位置及分析结果,供参考。

图片内容不可见,无法分析。

图片内容无法识别,请重新提供有效的图片。

图片无法加载,根据上下文推测为内存泄漏相关的图表或示意图。

图片未能加载或显示,无法分析其具体内容。根据上下文,该图片可能属于“内存泄漏”相关章节的插图,但图像数据不可用。请重新提供可支持的图片格式以便分析。

图片内容无法识别,无法分析。

图片无法加载,无法分析具体内容。根据上下文,该图片可能展示内存泄漏的相关概念或步骤。

图片内容为不支持显示的类型,无法提取有效信息进行分析。

无法分析图片,因为当前未提供有效的图片数据(显示为“Unsupported Image”)。