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 实践场景
以下為演示此类行为的一个简单的代码示例:
public class StaticReferenceLeak { public static List 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 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 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 !