Java 中的内存泄漏剖析-Java快速入门教程
1. 简介
Java的核心优势之一是在内置垃圾收集器(或简称GC)的帮助下进行自动内存管理。GC 隐式负责分配和释放内存,因此能够处理大多数内存泄漏问题。
虽然 GC 可以有效地处理大部分内存,但它不能保证为内存泄漏提供万无一失的解决方案。GC非常聪明,但并非完美无缺。内存泄漏仍然可能悄悄发生,即使在尽职尽责的开发人员的应用程序中也是如此。
可能仍然存在应用程序生成大量冗余对象的情况,从而耗尽关键的内存资源,有时还会导致整个应用程序失败。
内存泄漏是Java中真正的问题。在本教程中,我们将了解内存泄漏的潜在原因是什么,如何在运行时识别它们,以及如何在我们的应用程序中处理它们。
2. 什么是内存泄漏
内存泄漏是指堆中存在不再使用的对象,但垃圾回收器无法从内存中删除它们。因此,它们被不必要地维护。
内存泄漏是不好的,因为它会阻塞内存资源并随着时间的推移降低系统性能。如果不处理,应用程序最终将耗尽其资源,最终以致命的java.lang.OutOfMemoryError终止。
有两种不同类型的对象驻留在堆内存中,引用和未引用。引用的对象是指在应用程序中仍具有活动引用的对象,而未引用的对象没有任何活动引用的对象。
垃圾回收器会定期删除未引用的对象,但它从不收集仍在引用的对象。这是可能发生内存泄漏的地方:
内存泄漏的症状
- 当应用程序长时间连续运行时,性能严重下降
- 应用程序中的内存不足错误、堆错误
- 自发和奇怪的应用程序崩溃
- 应用程序偶尔会用完连接对象。
让我们仔细看看其中一些场景以及如何处理它们。
3. Java 中内存泄漏的类型
在任何应用程序中,内存泄漏都可能由于多种原因而发生。在本节中,我们将讨论最常见的。
3.1. 静态字段的内存泄漏
可能导致潜在内存泄漏的第一种情况是大量使用静态变量。
在 Java 中,静态字段的生存期通常与正在运行的应用程序的整个生存期相匹配(除非 ClassLoader 符合垃圾回收的条件)。
让我们创建一个简单的 Java 程序来填充静态列表:
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"); } }
如果我们在此程序执行期间分析堆内存,那么我们将看到在调试点 1 和 2 之间,堆内存按预期增加。
但是,当我们将 populateList() 方法保留在调试点 3 时,堆内存尚未被垃圾回收,正如我们在此 VisualVM 响应中看到的那样:
但是,如果我们只是在上述程序的第 2 行中删除关键字 static,那么它将给内存使用量带来巨大变化,如以下 Visual VM 响应所示:
直到调试点的第一部分几乎与我们在静态情况下获得的部分相同。 但是这一次,在我们离开 populateList() 方法之后,列表的所有内存都被垃圾回收,因为我们没有任何对它的引用。
因此,我们需要非常密切地关注静态变量的使用。如果集合或大型对象声明为静态,则它们在应用程序的整个生存期内保留在内存中,从而阻止原本可以在其他地方使用的重要内存。
如何预防?
- 尽量减少静态变量的使用。
- 使用单一实例时,依赖于延迟加载对象的实现,而不是急切加载。
3.2. 未关闭的资源
每当我们建立一个新连接或打开一个流时,JVM都会为这些资源分配内存。这方面的一些例子包括数据库连接、输入流和会话对象。
忘记关闭这些资源会阻塞内存,从而使GC无法接触到它们。这种情况甚至会发生在防止程序执行到达处理代码以关闭这些资源的语句的异常情况下。
在任何一种情况下,资源留下的打开连接都会消耗内存,如果我们不处理它们,它们会降低性能,甚至导致OutOfMemoryError错误。
如何预防?
- 始终使用 finally 块来关闭资源。
- 关闭资源的代码(即使在 finally 块中)本身也不应该有任何异常。
- 使用 Java 7+ 时,我们可以利用 try-with-resources 块。
3.3. 不正确的 equals() 和 hashCode() 实现
在定义新类时,一个非常常见的疏忽是没有为 equals() 和 hashCode() 方法编写正确的重写方法。
HashSet 和 HashMap 在许多操作中使用这些方法,如果它们未被正确覆盖,它们可能会成为潜在内存泄漏问题的根源。
让我们举一个普通的 Person 类的例子,并将其用作 HashMap 中的键:
public class Person { public String name; public Person(String name) { this.name = name; } }
现在,我们将重复的 Person 对象插入到使用此键的 Map 中。
请记住,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); }
在这里,我们使用 Person 作为键。由于 Map 不允许重复键,因此我们作为键插入的大量重复 Person 对象不应增加内存。
但是由于我们还没有定义正确的 equals() 方法,重复的对象会堆积起来并增加内存,这就是为什么我们在内存中看到多个对象的原因。VisualVM 中的堆内存如下所示:
但是,如果我们正确地覆盖了 equals() 和 hashCode() 方法,那么这个 Map 中将只存在一个 Person 对象。
让我们来看看 Person 类的 equals() 和 hashCode() 的正确实现:
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); }
正确覆盖 equals() 和 hashCode() 后,同一程序的堆内存如下所示:
另一种选择是使用像Hibernate这样的ORM工具,它使用equals()和hashCode()方法来分析对象并将它们保存在缓存中。
如果不覆盖这些方法,内存泄漏的可能性非常高,因为 Hibernate 将无法比较对象,并且会用重复的对象填充其缓存。
如何预防?
- 根据经验,在定义新实体时,始终覆盖 equals() 和 hashCode() 方法。
- 仅仅覆盖是不够的,还必须以最佳方式覆盖这些方法。
3.4. 引用外部类的内部类
这发生在非静态内部类(匿名类)的情况下。对于初始化,这些内部类始终需要封闭类的实例。
默认情况下,每个非静态内部类都有一个对其包含类的隐式引用。如果我们在应用程序中使用此内部类的对象,那么即使我们的包含类对象超出范围,它也不会被垃圾回收。
考虑一个类,该类包含对大量大型对象的引用,并具有非静态内部类。当我们创建一个只包含内部类的对象时,内存模型如下所示:
但是,如果我们只是将内部类声明为静态,那么相同的内存模型如下所示:
发生这种情况是因为内部类对象隐式保存对外部类对象的引用,从而使其成为垃圾回收的无效候选项。匿名类也会发生同样的情况。
如何预防?
- 如果内部类不需要访问包含的类成员,请考虑将其转换为静态类。
3.5. 通过 finalize() 方法
finalize() 方法称为终结器。使用终结器是潜在内存泄漏问题的另一个来源。每当类的 finalize() 方法被覆盖时,该类的对象都不会立即被垃圾回收。相反,GC 将它们排队等待完成,这将在稍后的时间点发生。
此外,如果用 finalize() 方法编写的代码不是最佳的,并且如果终结器队列跟不上 Java 垃圾回收器,那么我们的应用程序迟早会遇到 OutOfMemoryError。
为了证明这一点,让我们假设我们有一个类,我们已经覆盖了 finalize() 方法,并且该方法需要一点时间来执行。当此类的大量对象被垃圾回收时,在 VisualVM 中如下所示:
但是,如果我们只是删除被覆盖的 finalize() 方法,那么同一个程序会给出以下响应:
如何预防?
- 我们应该始终避免终结器。
有关 finalize() 的更多详细信息,我们可以参考 Java 中 finalize 方法指南中的第 3 节(避免终结器)。
3.6. 暂禁字符串的intern方法
Java String 池在 Java 7 中经历了一次重大变化,当它从 PermGen 转移到 HeapSpace 时。但是,对于在版本 6 及更低版本上运行的应用程序,我们在处理大型字符串时需要更加小心。
如果我们读取一个巨大的 String 对象,并在该对象上调用 intern(),它会进入位于 PermGen(永久内存)中的字符串池,只要我们的应用程序运行,它就会留在那里。这会阻塞内存并在我们的应用程序中造成重大内存泄漏。
JVM 1.6 中这种情况的 PermGen 在 VisualVM 中如下所示:
相反,如果我们只是从方法中的文件中读取一个字符串,而不是intern 它,那么 PermGen 看起来像:
如何预防?
1)解决此问题的最简单方法是升级到最新的 Java 版本,因为字符串池从 Java 版本 7 开始移动到 HeapSpace。
2)如果我们处理的是大型字符串,我们可以增加 PermGen 空间的大小,以避免任何潜在的 OutOfMemoryErrors:
-XX:MaxPermSize=512m
3.7. 使用线程本地
ThreadLocal是一种结构,它使我们能够将状态隔离到特定线程,从而允许我们实现线程安全。
使用此构造时,只要线程处于活动状态,每个线程将保留对其 ThreadLocal 变量副本的隐式引用,并维护自己的副本,而不是在多个线程之间共享资源。
尽管有其优点,但ThreadLocal变量的使用是有争议的,因为如果使用不当,它们会因引入内存泄漏而臭名昭著。Joshua Bloch 曾经评论过线程本地用法:
“草率地使用线程池与草率地使用线程局部变量相结合,可能会导致意外的对象保留,正如在许多地方所指出的那样。但把责任归咎ThreadLocal
是没有根据的。
ThreadLocal的内存泄漏
一旦保持线程不再活动,ThreadLocals 应该被垃圾回收。但是,当我们使用ThreadLocals以及现代应用程序服务器时,问题就出现了。
现代应用程序服务器使用线程池来处理请求,而不是创建新的请求(例如,Apache Tomcat 中的执行器)。此外,他们还使用单独的类加载器。
由于应用程序服务器中的线程池基于线程重用的概念工作,因此它们永远不会被垃圾回收;相反,它们被重新用于处理另一个请求。
如果任何类创建了一个 ThreadLocal 变量,但没有显式删除它,那么即使在 Web 应用程序停止后,该对象的副本也会保留在工作线程中,从而阻止该对象被垃圾回收。
如何预防?
- 当我们不再使用ThreadLocals时,清理它们是一种很好的做法。ThreadLocals 提供了 remove() 方法,该方法删除此变量的当前线程值。
- 不要使用 ThreadLocal.set(null) 来清除该值。它实际上不会清除该值,而是会查找与当前线程关联的 Map,并将键值对分别设置为当前线程和 null。
- 最好将 ThreadLocal 视为我们需要在 finally 块中关闭的资源,即使在异常的情况下也是如此:
try { threadLocal.set(System.nanoTime()); //... further processing } finally { threadLocal.remove(); }
4. 处理内存泄漏的其他策略
尽管在处理内存泄漏时没有一刀切的解决方案,但有一些方法可以最大限度地减少这些泄漏。
4.1. 启用分析
Java 探查器是通过应用程序监视和诊断内存泄漏的工具。他们分析我们应用程序内部发生的事情,比如我们如何分配内存。
使用分析器,我们可以比较不同的方法,并找到可以最佳地利用资源的领域。
在本教程的第 3 节中,我们使用了 Java VisualVM。请查看 Java Profilers 指南,了解不同类型的 Profilers,如 Mission Control、JProfiler、YourKit、Java VisualVM 和 Netbeans Profiler。
4.2. 详细垃圾回收
通过启用详细垃圾回收,我们可以跟踪 GC 的详细跟踪。要启用此功能,我们需要将以下内容添加到我们的 JVM 配置中:
-verbose:gc
通过添加此参数,我们可以看到 GC 内部发生的情况的详细信息:
4.3. 使用引用对象避免内存泄漏
我们还可以使用 java 中内置的 java 对象来处理内存泄漏。使用 java.lang.ref 包,我们不是直接引用对象,而是使用对对象的特殊引用,以便轻松地对它们进行垃圾回收。
引用队列使我们知道垃圾回收器执行的操作。有关更多信息,可以阅读 Java 中的软引用教程,特别是第 4 节。
4.4. Eclipse 内存泄漏警告
对于 JDK 1.5 及更高版本的项目,每当遇到明显的内存泄漏情况时,Eclipse 都会显示警告和错误。因此,在 Eclipse 中进行开发时,我们可以定期访问“问题”选项卡,并对内存泄漏警告(如果有的话)更加警惕:
4.5. 基准测试
我们可以通过执行基准来衡量和分析 Java 代码的性能。这样,我们可以比较执行相同任务的替代方法的性能。这可以帮助我们选择最佳方法,并可以帮助我们保存内存。
有关基准测试的更多信息,请转到我们的 Java 微基准测试教程。
4.6. 代码审查
最后,我们总是有经典的、老式的方式来做一个简单的代码演练。
在某些情况下,即使是这种看起来微不足道的方法也有助于消除一些常见的内存泄漏问题。
5. 结论
通俗地说,我们可以将内存泄漏视为一种通过阻塞重要内存资源来降低应用程序性能的疾病。像所有其他疾病一样,如果不治愈,随着时间的推移,它可能会导致致命的应用程序崩溃。
内存泄漏很难解决,找到它们需要对 Java 语言进行复杂的掌握和掌握。在处理内存泄漏时,没有一刀切的解决方案,因为泄漏可以通过各种不同的事件发生。
但是,如果我们采用最佳实践并定期执行严格的代码演练和分析,则可以最大程度地降低应用程序中内存泄漏的风险。