Java的内存泄露(Memory Leaks)

1.简介

Java 的核心优势之一是在内置垃圾回收器(简称 GC)的帮助下实现自动内存管理。GC 隐式处理分配和释放内存,因此能够处理大多数内存泄漏问题。

虽然 GC 有效地处理了大量内存,但它不能保证内存泄漏的万无一失。GC 相当聪明,但并非完美无瑕。即使有经验的开发人员写的程序,有时也会发生内存泄露。

可能仍然存在应用程序生成大量多余对象的情况,从而消耗关键内存资源,有时会导致整个应用程序出现故障。

内存泄漏是个很严重的问题。在本教程中,我们将了解内存泄漏的潜在原因是什么,如何在运行时识别它们,以及如何在我们的应用程序中处理它们。

2.什么是内存泄漏

内存泄漏是堆中存在不再使用的对象,但垃圾回收器无法从内存中删除它们,因此不必要地维护它们的情况。

内存泄漏是严重的,因为它会阻止内存资源,并随着时间的推移降低系统性能。如果不处理,应用程序最终将耗尽其资源,最终终止一个致命的java.lang.OutOfMemoryError

有两种不同类型的对象驻留在堆内存中 - 引用和未引用。引用的对象是应用程序中仍然具有活动引用的对象,而未引用的对象没有任何活动引用。

垃圾回收器会定期删除未引用的对象,但它永远不会回收仍在引用的对象。这是可能发生内存泄漏的地方:

img

内存泄漏的症状:

  • 当应用程序长时间持续运行时,性能会严重下降
  • 应用程序中的OutOfMemoryError heap error
  • 自发和奇怪的应用程序崩溃
  • 应用程序偶尔耗尽连接对象

让我们仔细看看其中一些方案以及如何处理它们。

3.Java 中内存泄漏的类型

3.1 静态块引起的内存泄露

可能导致潜在内存泄漏的第一个方案是大量使用静态变量。

在 Java 中,静态字段的生存期通常与正在运行的应用程序的整个生存期匹配(除非 ClassLoader 有资格进行垃圾回收)。

请看如下例子:

public class StaticTest {
    public static List<Double> list = new ArrayList<>();
 
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
        Log.info("Debug Point 2");
    }
 
    public static void main(String[] args) {
        Log.info("Debug Point 1");
        new StaticTest().populateList();
        Log.info("Debug Point 3");
    }
}

在程序的执行过程中,我们分析堆内存的时候,我们会发现在运行到points 1和2之间,堆内存增长很快。

但是,当我们将populateList() 方法保留在point 3 时,堆内存尚未被垃圾回收,正如我们在此 VisualVM 响应中看到的:

img

但是,在上述程序中,在第 2 行中,如果我们只是删除static 这个单词,那么它将会给内存使用情况带来急剧的变化,此 Visual VM 响应显示:

img

调试点之前的第一部分几乎与静态时获得的相同。但这次我们离开populateList() 方法后,列表的所有内存都是垃圾回收的,因为我们没有任何对它的任何引用。

因此,我们需要非常密切地注意静态变量的使用。如果集合或大型对象声明为静态,则它们在整个应用程序的生存期内都保留在内存中,从而阻塞了本来可以在其他地方使用的重要内存。

怎么防止它呢?

  • 静态变量的使用最小化
  • 使用单例时,请依赖延迟加载对象的实现,而不是一开始就加载对象。

3.2 未关闭的资源

每当我们建立新连接或打开流时,JVM 都会为这些资源分配内存。几个示例包括database connections, input streams, and session objects.

忘记关闭这些资源可能会阻塞内存,从而使它们无法到达 GC阶段。如果异常阻止程序执行到达处理代码以关闭这些资源的语句,则甚至可能发生这种情况。

在这两种情况下,资源留下的开放连接会消耗内存,如果我们不处理它们,它们可能会降低性能,甚至会导致出用内存错误。

怎么防止它?

  • 使用finally块去释放资源
  • 关闭资源的代码不应该抛异常
  • 使用Java 7+,我们要使用try-with-resources 块

3.3 不恰当的equals()和hashCode()的实现

定义新类时,一个很常见的错误是重equals() 和hashCode() 方法的重写不对。

HashSet 和 HashMap 在许多操作中使用这些方法,如果这些方法未正确重写,则它们可能成为潜在内存泄漏问题的根源。

举个例子:

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
}

我们使用Person对象作为键插入Map中,

测试方法如下:

@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<100; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}

在这里,我们使用人作为Map的键。由于 Map 不允许重复键,因此我们作为键插入的众多重复的 Person 对象不应增加内存。

但是,由于我们还没有定义正确的 equals() 方法,重复的对象堆积起来并增加内存,这就是为什么我们在内存中看到多个对象的原因。此 VisualVM 中的堆内存如下所示:img

如果我们把 *equals()* 和 *hashCode()* 方法正确的重写,在Map里其实只有一个Person对象。

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
     
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Person)) {
            return false;
        }
        Person person = (Person) o;
        return person.name.equals(name);
    }
     
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}
@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Person, Integer> map = new HashMap<>();
    for(int i=0; i<2; i++) {
        map.put(new Person("jon"), 1);
    }
    Assert.assertTrue(map.size() == 1);
}

运行程序,查看堆内存:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ztlYYxjM-1574921649514)(https://www.baeldung.com/wp-content/uploads/2018/11/Afterimplementing_equals_and_hashcode.png)]

另一个示例是使用 ORM 工具(如 Hibernate),该工具使用 equals() and hashCode() 方法来分析对象并将其保存在缓存中。

如果不重写这些方法,内存泄漏的可能性相当高,因为 Hibernate 将无法比较对象,并且会用重复的对象填充其缓存。

怎么防止它呢?

  • 定义新类时,要重写equals()和hashCode()

3.4 内部类

这种情况发生在非静态内部类(匿名类)的情况下。对于初始化,这些内部类始终需要封闭类的实例。

默认情况下,每个非静态内部类都有对其包含类的隐式引用。如果我们在应用程序中使用此内部类的对象,那么即使在包含类的对象超出范围后,也不会进行垃圾回收。

考虑一个类,该类包含对大量笨重对象的引用,并且具有非静态内部类。现在,当我们创建一个仅包含内部类的对象时,内存模型如下所示:

img

如果把内部类声明为static类型的,堆内存的表现:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3VkdLyhq-1574921649518)(https://www.baeldung.com/wp-content/uploads/2018/11/Static_Classes_That_Reference_Outer_Classes.png)]

这是因为内部类对象隐式地保存对外部类对象的引用,从而使其成为垃圾回收的无效候选项。匿名类也是如此。

如何防止呢?

如果内部类不需要访问包含类成员,请考虑将其转换为静态类

3.5 finalize() 方法

finalizers 是潜在内存泄漏问题的另一个来源。每当重写类 finalize() 方法时,该类的对象不会立即进行垃圾回收。相反,GC 会将它们排队以进行终结,这在稍后的时间点发生。

此外,如果用 finalize() 方法编写的代码不是最佳代码,并且终结器队列无法跟上 Java 垃圾回收器,那么我们的应用程序迟早会遇到 OutOfMemoryError。

为了演示这一点,让我们考虑我们有一个类,我们已经重写了finalize() 方法,并且该方法需要一点时间来执行。当此类的大量对象被垃圾回收时,然后在 VisualVM 中,它看起来像:

img

如果我们移除了重写的finalize()方法。

img

怎么防止它呢?

  • 避免 finalizers

3.6 已处理的字符串

当 Java 字符串池从 PermGen 传输到 HeapSpace 时,它经历了 Java 7 中的重大变化。但对于在版本 6 及以下版本上运行的应用程序,在使用大型字符串时应更加小心。

如果我们读取一个巨大的 String 对象,并在该对象上调用 intern(),则它将转到位于 PermGen(永久内存)中的字符串池,只要我们的应用程序运行,它就会一直保留在那里。这会阻塞内存,并在我们的应用程序中创建一个主要的内存泄漏。

JVM 1.6 中此案例的 PermGen 在 VisualVM 中如下所示:

img

对比的是,我们只是从文件中读取一个字符串,不使用intern() 方法,它在永久区的表现:

img

怎么避免它呢?

  • 最简单的方法就是升级Java 版本,在java7+后,String pool从永久区移到堆空间了。
  • 为了避免可能的内存泄漏:-XX:MaxPermSize=512m

3.7 使用 ThreadLocals

ThreadLocal(在 Java 教程中详细讨论线程Local)是一种构造,它使我们能够将状态隔离到特定线程,从而使我们能够实现线程安全。

使用此构造时,每个线程将保存对其 ThreadLocal 变量副本的隐式引用,并将维护其自己的副本,而不是跨多个线程共享资源,只要线程处于活动状态。

尽管它的优点,使用ThreadLocal变量是有争议的,因为它们是臭名昭著的引入内存泄漏,如果使用不当。

线程局部变量应在保持线程不再处于活动状态时进行垃圾回收。但是,当 ThreadLocals 与现代应用程序服务器一起使用时,问题就出现了。

现代应用程序服务器使用线程池来处理请求,而不是创建新的请求(例如 Apache Tomcat 中的执行器)。此外,它们还使用单独的类加载器。

由于应用程序服务器中的线程池采用线程重用的概念,因此它们永远不会被垃圾回收,相反,它们被重用以提供另一个请求。

现在,如果任何类创建 ThreadLocal 变量,但未显式删除它,则即使 Web 应用程序停止,该对象的副本仍将保留在辅助线程中,从而防止该对象被垃圾回收。

如何避免它?

  • 在不再使用 ThreadLocals 时,最好清理它们 - ThreadLocals 提供 remove() 方法,该方法将删除此变量的当前线程值
  • 不要使用 ThreadLocal.set(null) 来清除值 - 它实际上不会清除值,而是查找与当前线程关联的映射,并将键值对分别设置为当前线程和 null
  • 最好将 ThreadLocal 视为需要在最终块中关闭的资源,以确保始终关闭,即使在出现异常时也是如此:
try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}

4.其他处理内存泄漏的方法

尽管在处理内存泄漏时没有一刀切的解决方案,但可以通过一些方法将这些泄漏降至最低。

4.1 Java Profiling

Java profilers是监视和诊断应用程序内存泄漏的工具。他们分析我们应用程序中的内部发生的情况,例如,内存的分配方式。

使用profilers,我们可以比较不同的方法,并找到我们可以最佳地使用资源的区域。

在本教程的第 3 节中,我们使用了 Java VisualVM。请查看我们的 Java 探查器指南,了解不同类型的探查器,如任务控制、JProfiler、YourKit、Java VisualVM 和 Netbeans 探查器。

4.2 Verbose 垃圾回收

通过启用详细的垃圾回收,我们正在跟踪 GC 的详细跟踪。为此,我们需要将以下内容添加到 JVM 配置中:

-verbose:gc

通过添加这个,我们能知道详尽的垃圾回收:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nz5cZEv5-1574921649530)(https://www.baeldung.com/wp-content/uploads/2018/11/verbose-garbage-collection.jpg)]

4.3 使用关联对象来避免内存泄漏

我们还可以使用 Java 中内置的引用对象来处理内存泄漏。使用 java.lang.ref 包,而不是直接引用对象,我们使用对对象的特殊引用,以便轻松进行垃圾回收。

引用队列旨在让我们了解垃圾回收器执行的操作。有关详细信息,请阅读 Java Baeldung 教程中的soft References,特别是第 4 节。

4.4 Benchmarking

我们可以通过执行基准测试来测量和分析 Java 代码的性能。这样,我们可以比较替代方法的性能来执行相同的任务。这有助于我们选择更好的方法,并帮助我们节省内存。

有关基准测试的详细信息,请前往我们的 Java 微基准测试教程。

4.5 代码评审

最后,我们总是有经典的,老派的方式做一个简单的代码演练。

在某些情况下,即使是这种琐碎的查找方法也有助于消除一些常见的内存泄漏问题。

5.结论

用外行的话说,我们可以认为内存泄漏是一种疾病,通过阻塞重要的内存资源来降低应用程序的性能。和所有其他疾病一样,如果不治愈,随着时间的推移,它可能导致致命的应用程序崩溃。

内存泄漏很难解决,找到它们需要复杂的 Java 语言掌握和命令。在处理内存泄漏时,没有一刀切的解决方案,因为泄漏可能通过各种事件发生。

但是,如果我们采用最佳实践并定期执行严格的代码演练和分析,则可以将应用程序中内存泄漏的风险降至最低。

与往常一样,本教程中描述的用于生成 VisualVM 响应的代码段可在 GitHub 上找到。

参考代码

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章