Java 内存管理——避免内存泄漏

77 阅读15分钟

在上一章中,我们讨论了如何在 JVM 中配置与监控内存管理。这需要掌握与 JVM 调优相关的各项指标。我们讨论了如何获取这些指标,以及据此如何对 JVM 进行调优。我们还演示了如何通过性能分析来洞察调优的效果。

本章聚焦于内存泄漏。我们将从以下几个方面展开:

  • 认识内存泄漏
  • 发现(识别)内存泄漏
  • 避免内存泄漏

先从理解内存泄漏开始。随后我们会学习如何在代码中定位它们,并看看如何避免与解决。

技术要求

本章代码可在 GitHub 获取:github.com/PacktPublis…

认识内存泄漏

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

拥有更快的服务器或把应用部署到云端,并不能让你摆脱糟糕内存管理(内存泄漏)的影响。前面说过,内存是有限的,即使是性能强劲的服务器也会耗尽内存。在云端部署时,人们容易用“横向/纵向扩容”来掩盖内存泄漏问题;但这会让实例规模远超所需,从而推高成本,甚至带来高昂的云账单。

你多久会耗尽内存,取决于泄漏发生在代码的哪个位置。如果泄漏位于很少执行的路径,耗尽内存可能很慢;而如果位于高频路径,则会快得多。

造成内存泄漏的原因很多,其中一个高概率元凶是代码缺陷。这就引出了下一个主题:如何发现内存泄漏。

发现内存泄漏

也许你已经遇到过:应用运行一段时间后响应变慢。系统管理员可能会偶尔重启应用以释放不必要累积的内存——这种“需要不时重启”的现象,就是内存泄漏的典型症状。

随着内存由于泄漏而逐渐被占满,应用会变慢,甚至崩溃。虽然应用变慢不一定都是内存泄漏所致,但它往往就是罪魁祸首。当你怀疑代码存在内存泄漏时,以下指标对诊断非常有帮助:

  • 堆内存占用(heap memory footprint)
  • 垃圾回收活动(GC activity)
  • 堆转储(heap dump)

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

image.png

图 7.1 – 存在内存泄漏的程序
在图 7.1 中,从第 15 行开始我们进入一个无限循环,不断创建 Person 对象并将其加入一个 ArrayList 中。由于每次都会重新给引用 p 赋新值,你可能会以为先前 p 指向的 Person 对象已经可以被 GC 回收。然而事实并非如此:这些 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,表示一旦发生堆内存 OOM,JVM 会把堆转储到文件。第二个通过 HeapDumpPath 指定转储文件的路径与名称。

现在我们启动了这个带泄漏的应用,接下来用 VisualVM 监控相关指标。VisualVM 过去随 JDK 一起发布,现在需要单独下载:visualvm.github.io/download.xh…(写作时该链接有效)。首先从堆内存占用开始诊断。

堆内存占用(Heap memory footprint)

我们关注的不是堆的总大小,而是已用堆的变化,尤其关心 GC 是否能回收已用堆。图 7.2 显示了图 7.1 所示应用的堆占用:

image.png

图 7.2 – 堆内存占用
如图所示,已用堆(x 轴与曲线之间的面积)很快就占满了可用堆空间。GC 确实回收了一点内存(左侧的小凹陷),但那并不是我们程序分配的内存。随后程序因为 OutOfMemoryError 崩溃,所以已用堆回落到 0。

接着看看这段时间内的垃圾回收活动

垃圾回收活动(GC activity)

上节我们看到了内存泄漏对堆占用的影响。观察同一时期的 GC 活动更有意思。图 7.3 反映如下:

image.png

图 7.3 – 垃圾回收活动
图 7.3 显示 GC 在程序运行期间非常忙。然而,结合图 7.2 我们知道,这对释放(由我们程序分配的)堆空间没有任何效果。也就是说,尽管 GC 很忙,堆依然保持高位——这正是内存泄漏的经典特征。

至此我们确认程序存在内存泄漏。下一步是找出泄漏原因。在这个例子里其实不难,但为了更好地学习,我们继续深入。接下来查看 JVM 在程序崩溃时创建的堆转储

堆转储(Heap dump)

运行应用时我们已经指定了发生 OOM 时创建堆转储。借此我们可以进一步调试为何首先会 OOM。图 7.4 是堆转储概要:

image.png

图 7.4 – 堆转储摘要
有两个数值立刻引人注意。其一是实例数量(第一处箭头)。205,591,192 显然离谱。我们需要知道到底是哪种类型的实例导致了泄漏。第二个箭头指出 ch7.Person 是“问题儿童”,因为它有 205,544,625 个实例。

堆转储还允许我们进一步下钻。我们希望看到:是什么阻止了这些 Person 对象被回收。图 7.5 帮助我们说明:

image.png

图 7.5 – 堆转储下钻
在该截图中,我们从摘要级下钻到了对象级。既然知道 Person 对象很多,就任选其一继续下钻,查看谁在引用它。如蓝色高亮所示,被引用者是一个 ArrayList 对象。

现在就清楚多了:我们不断把 Person 对象加入一个作用域始终不结束ArrayList。因此 GC 无法移除这些 Person,最终触发 OutOfMemoryError

小结:本节我们诊断了一个存在内存泄漏的程序。通过观察堆占用GC 活动,确认了泄漏。随后分析堆转储,定位到导致问题的集合(ArrayList)与类型(Person)。下一节将讨论如何从源头避免内存泄漏。

避免内存泄漏

避免内存泄漏的最佳方式,是一开始就写没有泄漏的代码。换句话说,不再需要的对象不应再与栈保持连接;否则垃圾回收器(GC)无法回收它们。在介绍帮助你规避泄漏的技巧之前,先把图 7.1 中的泄漏修好。图 7.6 给出了无泄漏的代码:

image.png

图 7.6 – 无泄漏程序

在图 7.6 中,无限循环仍然存在。不过第 19~23 行是新增的。在这段新代码中,每次把 Person 引用加入 ArrayList 后,我们都会递增一个本地变量 i。当累计到 1000 次时,我们重新初始化列表引用。关键就在于这一步:它让垃圾回收器能回收旧的 ArrayList 对象以及其中那 1000 个 Person 对象。此外,我们把 i 重置为 0。这样即可修复泄漏。(如果你真能为这个具体示例找到实际用例,请给我们发邮件,我们会在下一版书中补充。尽管如此,它非常适合用来呈现前面的图表。)

我们按之前相同的命令行参数运行程序。该程序不会再抛出 OutOfMemoryError。接下来用 VisualVM 观察代码表现。图 7.7 是新的、无泄漏代码的堆内存占用:

image.png

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

如图所示,已用堆(x 轴与曲线之间的面积)呈现上下起伏。“向下”的部分表示 GC 回收了内存。这种类似锯齿的形态是健康程序的标志。图尾部我们停止了程序。

再看这一段时间的 GC 活动。图 7.8 展示如下:

image.png

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

在图 7.3(带泄漏代码的图)中,GC 占用超过 5%。而在图 7.8 中,GC 几乎不可见、与 x 轴几乎重合——这同样是健康程序的标志。由于程序没有耗尽堆空间,因此无需生成堆转储。

常见陷阱与规避方法

现在我们已经解决了内存泄漏问题,下面回顾一些代码中常见的问题以及如何避免。我们将介绍一些技巧,帮助你写出无泄漏、且不浪费资源、内存使用更优的代码。
有些建议很直观,不必展开:比如在可能的情况下,为程序分配一个合理的堆大小;不要创建不必要的对象;能复用对象时尽量复用。另一些则需要多解释,下面详述。

栈上的不必要引用与将引用设为 null

有时栈上会残留其实不再需要的引用。在前面的示例中正是如此。
本节用到的修复方式就是重新初始化引用(或设为 null)。二者都会切断与栈的连接,使 GC 能回收堆上的对象。但要当心:只有在确实用完对象后再这么做,否则会得到 NullPointerException。例如:

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

此例中,我们把对象引用存放在 personObj 中;当不再需要它时,将其设为 null。这样(假设没有把该引用赋给其他变量)堆上的 Person 对象在该行之后即可成为 GC 的候选。
这种做法在当今软件中是否仍有意义见仁见智——对多数现代应用而言未必是首选,但也可能有合理场景。

资源泄漏与及时关闭资源

打开文件、数据库、流等资源会占用内存及系统资源。如果不关闭,可能导致资源泄漏。在某些场景下,还会严重消耗可用资源、影响性能(例如缓冲区被占满)。如果你在写出数据(文件写入、数据库提交),不关闭资源甚至会导致持久化不正确——数据可能无法到达目标位置(文件或数据库)。

因此,在用完后关闭资源(如文件与数据库连接)是防止问题的关键。finally 块或 try-with-resources 非常有用:finally 无论是否抛异常都会执行;try-with-resources 内置等价的“自动关闭”,会在 try 结束时调用对象的 close() 方法。

常规 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();
}

上面打开了 FileReaderBufferedReader,并在 catch 中处理了受检异常,但从未关闭它们,使其无法被 GC 回收。请务必在 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();
    }
}

自 Java 7 起,更常用 try-with-resources。在 try 声明的资源(实现了 AutoCloseable)会在 try 结束时自动 close()

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 strIntToChar = "";
for(int i = 97; i < 123; i++) {
    strIntToChar += i + ": " + (char)i + "\n";
}
System.out.println(strIntToChar);

输出为:

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

这里创建了很多对象,每次中间拼接都会生成新对象。比如前两次迭代后:

97: a
98: b

第三次迭代后:

97: a
98: b
99: c

这些中间值还会进入字符串常量池。由于 String 不可变,常量池的优化在此场景下反而“对我们不利”。

解决方案是使用 StringBuilderStringBuilder 可变,不会为每个中间值都新建对象。改写如下:

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

拼接时,JVM 修改的是原有StringBuilder,不会新建 StringBuilder。代码改动不大,但显著改善内存使用。因此当需要大量字符串拼接时,请使用 StringBuilder

使用原始类型而非包装类型以管理内存

包装类型比原始类型更占内存。有时你必须用包装类型;在可选场景下,尽量选择原始类型。比如,把局部变量声明为 int 而不是 Integer
原始类型占用更小;若是方法的局部变量,还会存在上(访问速度也比堆快)。包装类型则是对象,总是在上分配。此外,如无必要,请优先使用 longdouble 而不是 BigIntegerBigDecimal。尤其 BigDecimal 常因高精度而受欢迎,但这以更高内存与更慢计算为代价——仅在确实需要该精度时才使用。
注意:这并非防止“真正的内存泄漏”,而是优化内存使用,避免为实现目标占用超出必要的内存。

警惕静态集合(static collections),以及为何应避免

在纯 Java SE 场景下,有时为了“暂存对象”,会想在类里放一个静态集合。这对健康的内存占用是危险的。例如:

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

这样很快就会失控。由于静态集合一直存活,其中的对象无法被 GC 回收。更好的方式是:如果你真需要长期保存对象,考虑使用数据库

若你使用的是 HashMap 作为静态集合,可以考虑(Java 8+)用 WeakHashMap 代替。它对使用弱引用(注意:不是值;值仍是强引用)。WeakHashMap 中的键是弱引用,这不会阻止 GC 回收堆中的对象;当键不再被应用其他部分引用时,WeakHashMap 中对应的条目会被移除。
这意味着:如果你可以接受当键在其他地方不再被引用时,条目消失WeakHashMap 才适用;如果你的目标是维护一个不会丢失的 HashMap 内容,那就不要用 WeakHashMap。一如既往,在实现前请认真评估是否满足你的需求。

总结

本章我们学习了如何在代码中避免内存泄漏。第一步是认识到:当对象不再需要却仍与栈保持链接时,就会发生内存泄漏,这会阻止垃圾回收器回收它们。鉴于内存是有限资源,这种情况始终不可取。随着这些对象的累积,你的应用会变慢,最终崩溃。

内存泄漏的一个常见来源是代码中的缺陷。不过我们有办法对泄漏进行调试。为演示如何调试存在泄漏的代码,我们给出了一个包含内存泄漏的程序。VisualVM 能帮助我们监控关键指标——堆内存占用、垃圾回收活动,以及(在堆空间耗尽时的)堆转储。

堆内存占用图验证了内存泄漏的存在:已用堆空间完全占满了可用堆空间。换言之,堆上的对象没有被回收。与此同时,垃圾回收器白忙活——在徒劳地尝试释放堆空间。为找出造成问题的类型,我们检查了堆转储,定位到一个 ArrayList 对象引用了海量的 Person 实例。

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

然而,避免内存泄漏的最佳方式是不要把它们写进代码——所谓“预防胜于治疗”。基于此,我们讨论了几种在一开始就避免内存泄漏的常见技巧。

本章到此结束。简言之,我们先介绍了内存泄漏产生的原因与方式,接着诊断并修复了带泄漏的代码,最后讨论了如何从源头防止写出有泄漏的代码,以及如何优化内存使用。

这不仅是本章的小结,也是全书的收尾。我们从内存概览入手,逐步聚焦到各个方面;随后深入探讨了垃圾回收;最后几章聚焦于如何提升性能:如何调优 JVM,以及如何避免内存泄漏。

如果你还想进一步了解 JVM 如何管理内存,请参考 JVM 官方文档(最新版本见):docs.oracle.com/javase/spec…