内存泄漏介绍
Java的一个核心优点是在内置垃圾收集器(简称GC)的帮助下实现了自动内存管理。GC隐式地负责分配和释放内存,因此能够处理大多数内存泄漏问题。
虽然GC可以有效地处理大部分内存,但它不能保证为内存泄漏提供一个万无一失的解决方案。GC相当聪明,但并非完美无缺。即使在一个有责任心的开发人员的应用程序中,内存泄漏仍然会悄悄地发生。
仍然可能存在这样的情况:应用程序生成大量多余的对象,从而耗尽关键的内存资源,有时会导致整个应用程序失败。
内存泄漏是Java中的一个真正的问题。在本文中,我们将了解内存泄漏的潜在原因,如何在运行时识别它们,以及如何在应用程序中处理它们。
什么是内存泄露
内存泄漏是指堆中存在不再使用的对象,但垃圾回收器无法将它们从内存中删除,因此不需要对它们进行维护。
内存泄漏是很糟糕的,因为它会阻塞内存资源并随着时间的推移而降低系统性能。如果不处理,应用程序最终将耗尽其资源,最终以致命的java.lang.OutOfMemoryError.
有两种不同类型的对象驻留在堆内存中-引用的和未引用的。引用的对象是那些在应用程序中仍有活动引用的对象,而未引用的对象没有任何活动引用。
垃圾回收器定期删除未引用的对象,但从不收集仍在被引用的对象。这是可能发生内存泄漏的地方:
内存泄漏的症状
- 当应用程序长时间连续运行时,性能会严重下降
- OutOfMemoryError应用程序中出现堆错误
- 自发和奇怪的应用程序崩溃
- 应用程序偶尔会用完连接对象
让我们仔细看看这些场景,以及如何处理它们。
Java中的内存泄漏类型
在任何应用程序中,内存泄漏的发生有多种原因。在本节中,我们将讨论最常见的。
静态字段内存泄漏
第一种可能导致潜在内存泄漏的情况是大量使用静态变量。
在Java中,静态字段的生命周期通常与正在运行的应用程序的整个生命周期相匹配(除非类加载器有资格进行垃圾收集)。
让我们创建一个简单的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()
方法之后,该列表的所有内存都被垃圾回收,因为我们没有对它的任何引用。
因此,我们需要非常密切地注意静态变量的使用。如果集合或大对象被声明为静态的,那么它们在应用程序的整个生命周期中都会保留在内存中,从而阻塞了可以在其他地方使用的重要内存。
如何预防?
- 尽量减少静态变量的使用
- 使用singleton时,依赖于一个延迟加载对象而不是急于加载的实现
通过未关闭的资源
每当我们建立一个新的连接或打开一个流时,JVM会为这些资源分配内存。一些示例包括数据库连接、输入流和会话对象。
忘记关闭这些资源会阻塞内存,从而使GC无法访问它们。如果出现异常,阻止程序执行到达处理代码以关闭这些资源的语句,甚至会发生这种情况。
在这两种情况下,资源留下的开放连接会消耗内存,如果我们不处理它们,它们可能会降低性能,甚至可能导致OutOfMemoryError。
如何预防?
- 始终使用finally块关闭资源
- 关闭资源的代码(即使在finally块中)本身不应该有任何异常
- 在使用java7+时,我们可以使用try with resources块
equals() 和hashCode() 实现不正确
在定义新类时,一个非常常见的疏忽是没有为equals()
和hashCode()
方法编写正确的重写方法。
HashSet和HashMap在许多操作中使用这些方法,如果没有正确重写它们,那么它们可能成为潜在内存泄漏问题的根源。
让我们以一个普通的Person类为例,并将其用作HashMap中的键:
public class Person {
public String name;
public Person(String name) {
this.name = name;
}
}
现在,我们将把重复的Person
对象插入使用此键的映射中。
请记住,映射不能包含重复的键:
@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);
}
这里我们用人作为key。由于Map不允许重复的键,我们作为键插入的大量重复的Person对象不应该增加内存。
但是由于我们没有定义正确的equals()方法,重复的对象会堆积起来并增加内存,这就是为什么我们在内存中看到多个对象。VisualVM中的堆内存如下所示:
如果我们正确地重写了equals()
和hashCode()
方法,那么这个映射中只存在一个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()方法
- 仅仅重写是不够的,但是必须以最佳方式重写这些方法
引用外部类的内部类
这种情况发生在非静态内部类(匿名类)的情况下。对于初始化,这些内部类始终需要封闭类的实例。
默认情况下,每个非静态内部类都有对其包含类的隐式引用。如果我们在应用程序中使用这个内部类对象,那么即使在包含类的对象超出范围之后,它也不会被垃圾回收。
考虑一个类,它包含对大量大对象的引用,并且有一个非静态的内部类。现在,当我们创建一个只包含内部类的对象时,内存模型如下所示:
但是,如果只将内部类声明为static,那么相同的内存模型如下所示:
发生这种情况是因为内部类对象隐式地持有对外部类对象的引用,从而使其成为垃圾回收的无效候选对象。匿名类的情况也是如此。
如何预防?
- 如果内部类不需要访问包含的类成员,请考虑将其转换为静态类
除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/901.html
暂无评论