一文搞懂 Java 中的内存泄漏(Memory Leak)

简介: Hello folks,在今天的这篇文章中,我将讨论 Java 虛擬機生态体系中的一个至为关键內容—— Memory Leak(内存泄漏)。


    Hello folks,
在今天的这篇文章中,我将讨论 Java 虛擬機生态体系的一个至为关键內容—— Memory Leak(内存泄漏)。

   从事 Java 开发的技术人员应该都知道:Java 的核心优势之一是基于其内置的垃圾收集器(或简称 GC)的帮助下能够进行内存自动管理。GC 隐式地负责分配和释放内存,从而使得其能够处理大多数内存泄漏问题。

    诚然,在某种意义上而言,GC 能够有效地处理大部分的内存问题,但它并不是一种保证万无一失的内存泄漏解决方案。的确,GC 生性非常聪明,但它并非完美无缺,因为内存泄漏仍然可能悄悄地发生,仍然可能存在应用程序生成大量多余对象的情况,然后耗尽关键内存资源,从而导致整个应用程序失败,业务故障。

    因此,Memory Leak (内存泄漏)是 Java 虛擬機體系中的一个真正的疑难问题。

   在解析 Memory Leak(内存泄漏)之前,我們先來澄清一下相關概念。Memory Leak 與 OutOfMemoryError(內存溢出):内存泄漏可以视为一种問題, OutOfMemoryError 則视为一种症状。因此,并非所有 OutOfMemoryErrors 都意味着内存泄漏,并且并非所有内存泄漏都表现为 OutOfMemoryErrors。

何为 Java 中的 Memory Leak

    Memory Leak ,即“内存泄漏”,通常是指一个或多个对象不再被使用,但同时又无法被持续工作的垃圾收集器清除的情况。

    我们可以将内存中的对象分为两大类:

    1、引用对象是可以从我们的应用程序代码访问并且正在或将要使用的对象。

    2、未引用的对象是应用程序代码无法访问的对象。

    垃圾收集器最终会从堆中移除未引用的对象,为新对象腾出空间,但它不会移除被引用的对象,因为它们被认为很重要。这样的对象会使 Java 堆内存越来越大,并推动垃圾回收做更多的工作。这将导致所构建的应用程序通过抛出 OutOfMemory 异常而变慢甚至最终崩溃。

    通常而言,内存泄漏是不好的,在實際的業務場景中,无论是基于业务表現还是用户体验,因为它会阻塞内存资源并随着时间的推移導致系统性能下降。如果不加以及時处理,应用程序最终将耗尽其资源,最终以致命的 Java.lang.OutOfMemoryError 异常终止退出。

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

    垃圾收集器定期清除未引用的对象,但它默认情况下不会收集仍在引用的对象。这是可能发生内存泄漏的地方,具體如下所示:


Memory Leak 症状

    在實際的場景中,有一些較為明顯的症状可以让我们怀疑所构建的 Java 应用程序正在遭受内存泄漏之困扰。以下为最常见的场景:

    1、应用程序运行时出现 Java OutOfMemory 错误。

    2、应用程序运行时间较长时性能下降,并且不会在应用程序启动后立即出现。

    3、应用程序运行的时间越长,垃圾收集次数就越多。

    4、连接用完。

Why Memory Leak ?

    这是一个很残酷的现实,Java 中的内存泄漏通常可能是由于代码中无法预料的错误而发生的,这些错误会保留对不需要的对象的引用,除此之外,这些链接会阻止 GC 功能操作。

    在某些特定的場景下,即使指定了 System.gc() 方法也是如此。当内存不足或可用内存不足以支撐程序所需时,垃圾收集器很可能会启动。如果垃圾收集器没有释放足够的内存资源,那麼,應用程序将會使用操作系统的内存。

    与 C++ 和其他编程语言中的内存泄漏相比,Java 内存泄漏通常没有那么严重。根据 IBM developerWorks Jim Patrick 的说法,在考虑内存泄漏时需要考虑两个方面:

    1、泄漏的大小

    2、程序的生命周期

    如果 JVM 有足够的内存来运行所構建的應用程序,那么小型 Java 应用程序中的内存泄漏并不重要。另一方面,如果我們的 Java 应用程序持续运行,内存泄漏将是一个嚴肅的问题,畢竟,无限期运行的软件最终会耗尽内存,從而導致業務故障。

    当應用程序使用大量内存的临时对象时,也会发生内存泄漏。如果不取消引用这些耗费大量内存的对象,程序将很快耗尽可访问的内存。

    不过,幸运的是,在实际的经验总结中有几种类型的 Java 内存泄漏是众所周知的,通过在编写 Java 代码时给予一定程度的关注,我们可以确保它们不会出现在我们的代码中。

Memory Leak 实践场景

1、静态字段持有对象

   可能导致潜在内存泄漏的第一种情况是大量使用静态变量。 Java 内存泄漏的最简单、直接的示例之一便是通过未清除的静态字段引用的对象。例如,一个静态字段包含一组我们永远不会清除或丢弃的对象。

    以下為演示此类行为的一个简单的代码示例:


public class StaticReferenceLeak {
  public static List<Integer> NUMBERS = new ArrayList<>();
  public void addBatch() {
    for (int i = 0; i < 100000; i++) {
      NUMBERS.add(i);
    }
  }
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 1000000; i++) {
        (new StaticReferenceLeak()).addBatch();
        System.gc();
        Thread.sleep(10000);
    }
  }
}

    addBatch 方法将 100000 个整数添加到名为 NUMBERS 的集合中。当然,如果我们需要这些数据,这完全没问题。但在这种情况下,我们永远不会删除它。即使我们在 main 方法中创建了StaticReferenceLeak 对象并且没有持有对它的引用,我们也很容易看出垃圾收集器无法清理内存。相反,它不断增长:


    如果我们看不到 StaticReferenceLeak 类的实现细节,我们会期望对象使用的内存被释放,但事实并非如此,因为 NUMBERS 集合是静态的。如果它不是静态的就没有问题,所以在使用静态变量时要格外小心。

    解决方案:

    为避免并可能防止此类 Java 内存泄漏,因此,应该尽量减少静态变量的使用。如果必须拥有它们,请格外谨慎,当然,在不再需要时从静态集合中删除数据。

    2、未关闭的资源

    访问位于远程服务器上的资源、打开文件并处理它们等等并不少见。此类代码需要在我们的代码中打开流、连接或文件。但我们必须记住,我们不仅要负责打开资源,还要负责关闭资源。否则,我们的代码可能会泄漏内存,最终导致 OutOfMemory 错误。

    为了说明这个问题,让我们看一下下面的例子:


public class UnclosedResources {
  public static void main(String[] args) throws Exception {
    for (int i = 0; i < 1000000; i++) {
      URL url = new URL("http://www.google.com");
      URLConnection conn = url.openConnection();
      InputStream is = conn.getInputStream();
      // rest of the code goes here
    }
  }
}


    上述循环的每次运行都会导致打开和引用 URLConnection 实例,从而导致资源(内存)缓慢耗尽。

    解决方案:

   (1)始终使用 finally 块来关闭资源

   (2)关闭资源的代码(即使在 finally 块中)本身不应有任何异常

   (3)使用 Java 7+ 时,我们可以使用 try -with-resources 块

    3、使用 ThreadLocals

    ThreadLocal 是 Java 世界中的一个结构体,可以让我们将处理范围隔离到当前线程,从而在某些情况下实现线程安全。我们可以保留有关当前用户的信息、绑定到用户的执行上下文或任何需要在线程之间进行隔离的信息。

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

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

    尽管有很多优点,但使用 ThreadLocal 变量是有争议的,因为如果使用不当,它们会因引入内存泄漏而臭名昭著。Joshua Bloch 曾经评论过线程局部使用:

“Sloppy use of thread pools in combination with sloppy use of thread locals can cause unintended object retention, as has been noted in many places. But placing the blame on thread locals is unwarranted.”

    当你开始从更广阔的角度思考时,问题就出现了。现代应用程序服务器或 Servlet 容器使用线程池来控制可以并发运行的线程数,从而一遍又一遍地重用相同的线程。在这种情况下,线程会被重用并且不会被垃圾回收,因为对线程的引用一直保存在池本身中。

    这不是 ThreadLocal 本身的问题,但总的来说,这是现代技术堆栈内部发生的复杂情况。我们应该预料到并记住分配给 ThreadLocal 的值将被保留,因此需要清理,否则内存将在 ThreadLocal 内部使用。

    解决方案: 

  (1)、当我们不再使用 ThreadLocals 时,清理它们是一种很好的做法。ThreadLocals 提供了 remove()方法,该方法删除当前线程为此变量的值。

  (2)、不要使用 ThreadLocal.set(null) 来清除值。它实际上并没有清除该值,而是会查找与当前线程关联的 Map,并将键值对分别设置为当前线程和 Null。

  (3)、最好将 ThreadLocal 视为我们需要在 finally 块中关闭的资源,即使在出现异常的情况下也是如此:


try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}


    4、
引用外部类的内部类

    在我看来,这是一个非常有趣的案例——内部私有类保留对其父类的引用的案例。具體如下场景所示:


public class OuterClass {
  // some large arrays of values
  private InnerClass inner;
  public void create() {
    inner = new InnerClass();
    // do something with inner and keep it
  }
  class InnerClass {
    // some logic of the inner class
  }
}


    假设 OuterClass 包含对大量占用大量内存的对象的引用,即使不再使用它也不会被垃圾收集。那是因为 InnerClass 对象将隐式引用 OuterClass ,这使得它不符合垃圾收集的条件。

    解决方案:

    这是关于内部类的要求,是否应该访问外部类中的数据。如果不是,将内部类变为静态将解决该问题。当然,我们还可以首先考虑内部私有类是否真的需要,也许可以使用不同的架构模式。

    5、 使用不正确 equals() 和 hashCode() 的实现

    Java 内存泄漏的另一个常见示例便是使用具有未正确实现(或根本不存在)的自定义 equals() 和 hashCode() 方法的对象,以及使用哈希检查重复项的集合。这种集合的一个典型代表便是 HashSet。

    为了说明这个问题,让我们看一下下如下的例子:


public class HashAndEqualsNotImplemented {
  public static void main(String[] args) {
    Set<Entry> set = new HashSet<>();
    for (int i = 0; i < 1000; i++) {
      set.add(new Entry("test"));
    }
    System.out.println(set.size());
  }
}
class Entry {
  public String entry;
  public Entry(String entry) {
    this.entry = entry;
  }
}


    在我们深入解释之前,问自己一个简单的问题:代码将使用 System.out.println(set.size()) 调用打印的数字是多少?如果答案是 1000,那么将是是正确的。那是因为我们没有正确实现 equals 方法。这意味着添加到 HashSet 的 Entry 对象的每个实例都会被添加,而不管从我们的角度来看它是否是重复的。这可能会导致 OutOfMemory 异常。

    如果我们用正确的实现来改变我们的代码,代码将导致打印 1 作为我们的 HashSet 的大小。我們以如下場景進行簡單舉例說明,下面是 JetBrains IntelliJ 实现的 equals() 和 hashCode() 方法的代码:


public class HashAndEqualsNotImplemented {
  public static void main(String[] args) {
    Set<Entry> set = new HashSet<>();
    for (int i = 0; i < 1000; i++) {
      set.add(new Entry("test"));
    }
    System.out.println(set.size());
  }
}
class Entry {
  public String entry;
  public Entry(String entry) {
    this.entry = entry;
  }
  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Entry entry1 = (Entry) o;
    return Objects.equals(entry, entry1.entry);
  }
  @Override
  public int hashCode() {
    return Objects.hash(entry);
  }
}

   

    解决方案:

    根据以往的经验,在创建类时应正确实现 equals() 和 hashCode() 方法。大多数现代 IDE 将帮助实现我们进行优化。

    6、使用 finalize() 方法

    使用终结器是潜在内存泄漏问题的另一个来源。每当重写类的 finalize() 方法时,该类的对象不会立即被垃圾回收。取而代之的是,GC 将它们排队等待最终确定,这发生在稍后的时间点。

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

    解决方案:

    很簡單,禁用此方法。

    當然,除了如上所述的場景之外,也存在其他的場景,畢竟,基於不同的環境、不同的場景,便會展示不同的現象。

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

    Memory Leak,作為一種症狀,有的時候的確很难解决,通常需要對 Java 语言以及操作系統相關知識體系有很深的理解與掌握。畢竟,在处理内存泄漏时,没有一种万能的解决方案,因为泄漏可能通过各种不同的事件、場景发生。

   然而,在實際的項目開發活動中,如果我们能夠采用最佳实践并定期执行严格的代码評審和分析,那麼,我们可以将应用程序中内存泄漏的风险降至最低,從而減少損失。

    Adiós !


相关文章
|
14天前
|
算法 安全 Java
Java内存管理:深入理解垃圾收集器
在Java的世界里,内存管理是一块基石,它支撑着应用程序的稳定运行。本文将带你走进Java的垃圾收集器(GC),探索它是如何默默守护着我们的内存安全。我们将从垃圾收集的基本概念出发,逐步深入到不同垃圾收集器的工作机制,并通过实例分析它们在实际应用中的表现。文章不仅旨在提升你对Java内存管理的认识,更希望你能通过这些知识优化你的代码,让程序运行更加高效。
34 3
|
20天前
|
Kubernetes Cloud Native Java
云原生之旅:从容器到微服务的演进之路Java 内存管理:垃圾收集器与性能调优
【8月更文挑战第30天】在数字化时代的浪潮中,企业如何乘风破浪?云原生技术提供了一个强有力的桨。本文将带你从容器技术的基石出发,探索微服务架构的奥秘,最终实现在云端自由翱翔的梦想。我们将一起见证代码如何转化为业务的翅膀,让你的应用在云海中高飞。
|
9天前
|
存储 网络协议 大数据
一文读懂RDMA: Remote Direct Memory Access(远程直接内存访问)
该文档详细介绍了RDMA(远程直接内存访问)技术的基本原理、主要特点及其编程接口。RDMA通过硬件直接在应用程序间搬移数据,绕过操作系统协议栈,显著提升网络通信效率,尤其适用于高性能计算和大数据处理等场景。文档还提供了RDMA编程接口的概述及示例代码,帮助开发者更好地理解和应用这一技术。
|
5天前
|
监控 算法 Java
Java中的内存管理:理解垃圾回收机制的深度剖析
在Java编程语言中,内存管理是一个核心概念。本文将深入探讨Java的垃圾回收(GC)机制,解析其工作原理、重要性以及优化方法。通过本文,您不仅会了解到基础的GC知识,还将掌握如何在实际开发中高效利用这一机制。
|
5天前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理策略和垃圾回收机制。首先介绍了Java内存模型的基本概念,包括堆、栈以及方法区的划分和各自的功能。进一步详细阐述了垃圾回收的基本原理、常见算法(如标记-清除、复制、标记-整理等),以及如何通过JVM参数调优垃圾回收器的性能。此外,还讨论了Java 9引入的接口变化对垃圾回收的影响,以及如何通过Shenandoah等现代垃圾回收器提升应用性能。最后,提供了一些编写高效Java代码的实践建议,帮助开发者更好地理解和管理Java应用的内存使用。
|
13天前
|
安全 Java API
【性能与安全的双重飞跃】JDK 22外部函数与内存API:JNI的继任者,引领Java新潮流!
【9月更文挑战第7天】JDK 22外部函数与内存API的发布,标志着Java在性能与安全性方面实现了双重飞跃。作为JNI的继任者,这一新特性不仅简化了Java与本地代码的交互过程,还提升了程序的性能和安全性。我们有理由相信,在外部函数与内存API的引领下,Java将开启一个全新的编程时代,为开发者们带来更加高效、更加安全的编程体验。让我们共同期待Java在未来的辉煌成就!
41 11
|
14天前
|
安全 Java API
【本地与Java无缝对接】JDK 22外部函数和内存API:JNI终结者,性能与安全双提升!
【9月更文挑战第6天】JDK 22的外部函数和内存API无疑是Java编程语言发展史上的一个重要里程碑。它不仅解决了JNI的诸多局限和挑战,还为Java与本地代码的互操作提供了更加高效、安全和简洁的解决方案。随着FFM API的逐渐成熟和完善,我们有理由相信,Java将在更多领域展现出其强大的生命力和竞争力。让我们共同期待Java编程新纪元的到来!
37 11
|
11天前
|
监控 Java 大数据
【Java内存管理新突破】JDK 22:细粒度内存管理API,精准控制每一块内存!
【9月更文挑战第9天】虽然目前JDK 22的确切内容尚未公布,但我们可以根据Java语言的发展趋势和社区的需求,预测细粒度内存管理API可能成为未来Java内存管理领域的新突破。这套API将为开发者提供前所未有的内存控制能力,助力Java应用在更多领域发挥更大作用。我们期待JDK 22的发布,期待Java语言在内存管理领域的持续创新和发展。
|
7天前
|
存储 缓存 算法
Java中的内存管理:理解垃圾回收机制
本文将深入探讨Java中的内存管理,特别是垃圾回收机制。我们将从基本的内存分配开始,逐步解析垃圾回收的原理和过程,以及它对Java应用程序性能的影响。通过实例演示,我们会展示如何在Java中有效地管理和优化内存使用。最后,我们将讨论一些常见的内存泄漏问题及其解决方案。
|
22天前
|
缓存 Java
Java内存管理秘籍:掌握强软弱幻四大引用,让代码效率翻倍!
【8月更文挑战第29天】在Java中,引用是连接对象与内存的桥梁,主要分为强引用、软引用、弱引用和幻象引用。强引用确保对象生命周期由引用控制,适用于普通对象;软引用在内存不足时可被回收,适合用于内存敏感的缓存;弱引用在无强引用时即可被回收,适用于弱关联如监听器列表;幻象引用需与引用队列配合使用,用于跟踪对象回收状态,适用于执行清理工作。合理使用不同类型的引用车可以提升程序性能和资源管理效率。
37 4

热门文章

最新文章