如何避免 JVM 内存泄漏?

前言

本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和参考文献请见100个问题搞定Java虚拟机


简介

Java 的核心优势之一是 JVM,这是一种开箱即用的内存管理。

从本质上讲,我们可以创建对象,Java 垃圾收集器将负责为我们分配和释放内存。

尽管如此,Java 应用程序中仍然可能发生内存泄漏。

在本文中,我们将讨论最常见的内存泄漏,了解其原因,并研究一些检测/避免它们的技术。

我们还将在整篇文章中使用 Java YourKit 分析器来分析我们运行时的内存状态。


1. Java 中的内存泄漏是什么?

内存泄漏的标准定义是当应用程序不再使用对象,但垃圾收集器无法从工作内存中删除它们时发生的场景——因为它们仍在被引用。

因此,该应用程序消耗了越来越多的资源——这最终导致致命的OutOfMemoryError

为了更好地理解这个概念,这里有一个简单的可视化表示Java中内存泄漏是如何发生的:

在这里插入图片描述

正如我们所看到的,我们有两种类型的对象——已引用的对象和未引用的对象;垃圾收集器可以删除未引用的对象,已引用的对象不会被收集,即使应用程序实际上不再使用它们。

检测内存泄漏可能很困难,许多工具执行静态分析以确定潜在的泄漏,但这些技术并不完美,因为最重要的方面是运行系统的实际运行时行为。

因此,让我们通过分析一些常见场景,重点看看防止内存泄漏的一些标准做法。


2. Java 堆内存泄漏

在本部分中,我们将重点关注经典的内存泄漏场景——即不断创建 Java 对象而不被释放

了解这些情况的有利技术是通过为堆设置更低的大小来更容易重现内存泄漏。

这就是为什么在启动应用程序时,我们可以调整 JVM 以满足我们的内存需求:

-Xms<size>
-Xmx<size>

这些参数指定初始 Java 堆大小以及最大堆大小。


2.1 保持对象引用的静态字段

第一个可能导致 Java 内存泄漏的场景是引用带有静态字段的重对象。

让我们来看看一个简短的例子:

private Random random = new Random();
public static final ArrayList<Double> list = new ArrayList<Double>(1000000);

@Test
public void givenStaticField_whenLotsOfOperations_thenMemoryLeak() throws InterruptedException {
    for (int i = 0; i < 1000000; i++) {
        list.add(random.nextDouble());
    }
    
    System.gc()Thread.sleep(10000); // 允许 GC 完成其工作
}

我们创建了 ArrayList 作为静态字段——在 JVM 流程的生命周期内,JVM 垃圾收集器永远不会收集该字段,即使在计算完成后也是如此。

我们还调用 Thread.sleep(10000) 来允许 GC 执行完整,并尝试回收所有可以回收的东西。

让我们运行测试,并使用我们的分析器分析 JVM:

在这里插入图片描述

注意,在一开始,所有内存当然都是空闲的。

然后,在短短 2 秒内,迭代过程就会运行并完成——将所有内容加载到列表中(这自然取决于你运行测试的机器)。

之后,触发一个完整的垃圾收集周期,测试继续执行,以允许这个循环时间运行和完成。

如你所见,列表没有回收,内存消耗也不会下降。

现在让我们看看完全相同的示例,只是这次,ArrayList没有被静态变量引用。

相反,这是一个被创建、使用然后丢弃的本地变量:

@Test
public void givenNormalField_whenLotsOfOperations_thenGCWorksFine() throws InterruptedException {
    addElementsToTheList()System.gc()Thread.sleep(10000); // 允许GC完成其工作
}
    
private void addElementsToTheList(){
    ArrayList<Double> list = new ArrayList<Double>(1000000);
    for (int i = 0; i < 1000000; i++) {
        list.add(random.nextDouble());
    }
}

一旦该方法完成工作,我们将在下图中观察 GC 的主要收集,大约第50秒:

在这里插入图片描述


怎么避免这个问题?

现在你已经了解了这个场景,当然还有一些方法可以防止它发生。

首先,我们需要 密切关注static的使用;声明任何集合或重对象为static,需要将它的生命周期与 JVM 本身的生命周期联系起来,这会使得整个对象血缘图无法收集。

我们还需要警惕集合的使用——这可能无意中保留引用的时间超过我们需要的时间。


2.2 在长字符串上调用 String.intern()

第二组经常导致内存泄漏的场景涉及 String 操作——特别是 String.intern() API。

关于 String.intern() 请参考我的博客——

让我们来看看一个简短的例子:

@Test
public void givenLengthString_whenIntern_thenOutOfMemory()
  throws IOException, InterruptedException {
    Thread.sleep(15000);
    
    String str = new Scanner(new File("src/test/resources/large.txt"), "UTF-8").useDelimiter("\\A").next();
    str.intern();
    
    System.gc(); 
    Thread.sleep(15000);
}

在这里,我们只需尝试将一个大文本文件加载到运行内存中,然后使用 .intern() 返回规范的形式。

intern API 将把 str 字符串放在 JVM 内存池中——在那里无法收集它——同样,这将导致 GC 无法释放足够的内存:

在这里插入图片描述

我们可以清楚地看到,在第 15 秒,JVM 是稳定的,然后我们加载文件,JVM 执行垃圾收集(第 20 秒)。

最后,调用 str.intern(),这会导致内存泄漏——一条表示高堆内存使用率的稳定线,并且永远不会被释放。


怎么避免这个问题?

请记住,内部字符串对象存储在 PermGen 空间中——如果我们的应用程序打算对大字符串执行大量操作,我们可能需要增加永久代的大小:

-XX:MaxPermSize=<size>

第二个解决方案是使用 Java 8——其中 PermGen 空间被元空间取代——这在字符串上使用 intern 时不会导致任何的 OutOfMemoryError:

最后,还有几种避免字符串上的.intern() API的选项。


2.3 未关闭的流

忘记关闭流是一个非常常见的场景,当然,大多数开发人员都可以与之相关。

当在 try-with-resource 子句中引入自动关闭所有类型流的功能时,这个问题在 Java 7 中被部分消除。

为什么是部分原因?由于资源尝试语法是可选的:

@Test(expected = OutOfMemoryError.class)
public void givenURL_whenUnclosedStream_thenOutOfMemory()
  throws IOException, URISyntaxException {
    String str = "";
    URLConnection conn 
      = new URL("http://norvig.com/big.txt").openConnection();
    BufferedReader br = new BufferedReader(
      new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8));
    
    while (br.readLine() != null) {
        str += br.readLine();
    } 
}

让我们看看从 URL 加载大文件时应用程序的内存如何:

在这里插入图片描述

正如我们所看到的,堆的使用随着时间的推移逐渐增加——这是不关闭流导致的内存泄漏的直接影响。

让我们更深入地研究这个场景,因为它不像其他场景那么清晰。

从技术上讲,未关闭的流将导致两种类型的泄漏——低级资源泄漏和内存泄漏。

低级资源泄漏只是操作系统级资源的泄漏——例如文件描述符、开放连接等,这些资源也可能泄漏,就像内存一样。

当然,JVM 也使用内存来跟踪这些底层资源,这也是为什么这也会导致内存泄漏的原因。


怎么避免这个问题?

我们总是需要记住手动关闭流,或使用 Java 7 中引入的自动关闭功能:

try (BufferedReader br = new BufferedReader(
  new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
    // further implementation
} catch (IOException e) {
    e.printStackTrace();
}

在这种情况下,BufferedReader 将在 try 语句结束时自动关闭,而无需在显式 finally 块中关闭它。


2.4 未关闭的连接

这种情况与前一个场景非常相似,主要区别在于处理未关闭的连接(例如与数据库、FTP 服务器等)。

同样,不当实现会造成很多伤害,导致内存问题。

让我们来看看一个简短的例子:

@Test(expected = OutOfMemoryError.class)
public void givenConnection_whenUnclosed_thenOutOfMemory()
  throws IOException, URISyntaxException {
    
    URL url = new URL("ftp://speedtest.tele2.net");
    URLConnection urlc = url.openConnection();
    InputStream is = urlc.getInputStream();
    String str = "";
}

URLConnection 保持打开状态,可以预见,结果是内存泄漏:

在这里插入图片描述

请注意,垃圾收集器无法做任何事情来释放未使用但引用的内存。第 1 分钟后,情况就很明显了——GC 操作的数量迅速减少,导致堆内存使用量增加,这导致 OutOfMemoryError。


怎么避免这个问题?

这里的答案很简单——我们需要始终以规范的方式建立连接


2.5 在 HashSet 中添加没有重写 hashCode() 和 equals() 的对象

一个可能导致内存泄漏的简单但非常常见的例子是将 HashSet 与缺少hashCode() 或 equals() 实现的对象一起使用。

具体来说,当我们开始将重复的对象添加到集合中时,这只会增长,而不是理应忽略重复的对象。一旦添加,我们也无法删除这些对象。

让我们创建一个没有重写 hashCode() 和 equals() 的对象:

public class Key {
    public String key;
    
    public Key(String key) {
        Key.key = key;
    }
}

现在,让我们看看使用场景:

@Test(expected = OutOfMemoryError.class)
public void givenMap_whenNoEqualsNoHashCodeMethods_thenOutOfMemory()
  throws IOException, URISyntaxException {
    Map<Object, Object> map = System.getProperties();
    while (true) {
        map.put(new Key("key"), "value");
    }
}

这个简单的实现将在运行时导致以下场景:

在这里插入图片描述

注意垃圾收集器如何在 1:40 左右停止回收内存,并注意内存泄漏;GC 收集的数量紧接着下降了近四倍。


怎么避免这个问题?

在这种情况下,解决方案很简单——提供 hashCode() 和 equals() 的实现至关重要。

这里值得一提的一个工具是 Project Lombok——它通过注释提供了许多默认实现,例如 @EqualsAndHashCode。


3. 如何在应用程序中找到泄露的来源

诊断内存泄漏是一个漫长的过程,需要大量的实践经验、调试技能和详细的应用程序知识。

让我们看看除了标准分析外,哪些技术可以帮助你。


3.1 详细的垃圾收集打印

识别内存泄漏的最快方法之一是启用详细的垃圾收集打印。

通过将 -verbose:gc 参数添加到我们应用程序的 JVM 配置中,我们将启用非常详细的 GC 跟踪。

摘要报告显示在默认错误输出文件中,这应该可以帮助我们了解内存是如何管理的。


3.2 Profiling

第二种技术是我们在整个文章中使用的技术——那就是Profiling。最受欢迎的分析器是 Visual VM ——这是一个开始超越命令行 JDK 工具并进入轻量级分析的好地方。

在本文中,我们使用了另一个分析器——YourKit——与Visual VM相比,它具有一些额外的、更高级的功能。


3.3 review your code

最后,这与其说是处理内存泄漏的具体技术,不如说是一种良好的编程习惯。

简而言之-彻底审视你的代码,练习定期的代码审查,并充分利用静态分析工具来帮助你了解代码和系统。


总结

本文中,我们实际研究了 JVM 上内存泄漏是如何发生的。

了解这些情景是如何发生的,是处理这些情景过程中的第一步。

然后,随着内存泄漏的发生,拥有真正了解运行时正在发生的事情的技术和工具也至关重要。

静态分析和仔细的代码评审只能做这么多,归根结底,运行时会向我们展示代码中无法立即识别的更复杂的内存泄漏。

最后,内存泄漏可能很难发现和复制,因为其中许多内存泄漏只发生在密集的负载下,这通常发生在生产环境中。

在这里,我们需要超越代码级分析,并致力于两个主要方面——复现和早期检测

复现内存泄漏的最佳和最可靠的方法是借助一套良好的性能测试,尽可能近距离模拟生产环境的使用模式。

早期检测是可靠的性能管理解决方案,甚至早期检测可以产生比较大的影响,因为它可能是对生产环境中应用程序运行时有着必要见解的唯一手段。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值