深入理解Java内存泄漏

195 阅读4分钟
原文链接: xiaozhuanlan.com

Java一个很重要的特性便是自动的内存管理,不必像c/c++那样需要严格控制对象的创建和销毁,我们可以随心所欲的创建对象,gc帮我们自动回收无用的内存对象(根据gc roots算法,一个对象如果无法根据gc roots触达,那么它将要回收)。
gc能高效的管理java内存,但是它并不能完全阻止内存泄漏的发生。GC设计很精妙,但并不完美。即使一个工作多年的“老司机”也会写出有内存泄漏风险的程序。
那么什么是内存泄漏,怎么识别内存泄漏,如何解决这些问题呢?本文将对此做以详细的讲解。

1、什么是内存泄漏。

内存泄漏,就是那些在java堆中已经无用的,但是垃圾回收器却不能将它们回收的case。无用,执行以后不会再次使用。垃圾回收器,不能回收它,还有垃圾回收器不能回收的对象吗?那是什么?你可以想象一下,如果一个对象跟根据gc roots触达到它,垃圾回收器会回收它吗?
我们知道内存泄漏很糟糕,因为它占用内存资源而且会降低系统性能。如果不处理它,程序最终会耗尽系统资源,抛出OutOfMemoryError而终结。
在java堆中有两类型对象,有引用的和无引用的。有引用的,表示这些对象还存活的,无引用的,表示没有任何存活的对象还对它有引用。
垃圾回收器,会周期性的移除没有引用的对象,但是它从不回收那些还被引用的对象。这也是内存泄漏的所在。

内存泄漏的主要症状有:

1、当程序运行很长一段时间以后,系统性能严重的降低。
2、程序爆出heap内存溢出(OutOfMemoryError)
3、程序奇异的奔溃。
4、程序偶尔会用完连接对象。

2、java中几种内存泄漏

在任何应用程序中,内存泄漏都可能因为许多种原因发生。本节将介绍一些最常见的case:

2.1 静态字段引起的内存泄漏

大量的使用静态变量,可能导致内存泄漏。
在java中,静态变量的生命周期,伴随着程序的运行期间(除非类加载器被垃圾回收)。
我们看看下面一个比较常见的例子:

public class MyStaticTest {
    public static List<Double> list = new ArrayList<>();
    public void compute() {
        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 MyStaticTest().compute();
        Log.info("Debug Point 3");
    }
}

如果我们在这段程序执行的时候,分析它的堆内存,我们就会发现debug point 1和2之前,如期望的,堆内存会增加。
但是,当我们执行完compute方法以后,堆内存不会回收,在VisualJVM中观看,如下图所示:

在上面一段程序,如果我们把static去掉,再来观看堆内存回收情况,图像如下:

我们在用使用static类型字段时,需要很小心。像类似的case,如何避免呢?

  • 尽量少用静态变量。
  • 当使用单例的时候,我们通过实现一种懒加载机制,而不是早加载。

2.2 未关闭的资源

每当我们创建一个网络连接或者一个流的时候,jvm将为这些资源分配内存。常见的比如:数据库连接、输入流、session对象。
如果忘记关闭这些资源,它将占有内存资源,并且GC还无法回收他们。如何避免呢?

  • 记得在finnally代码块里关闭资源
  • 即使在finnally块里关闭资源的代码也不能有异常,不然也不能正常关闭资源。
  • 当我们使用java 7以后,我们可以使用try-with-resources特性。

2.3 不合适的equals和hashCode方法实现

当我们定义一个新类的时候,我们通常会去重写equals和hashCode函数。HashSet和HashMap使用这些方法在许多操作,如果他们不能被正确的重写,这也将成为一个潜在的内存泄漏的可能。
举个例子:

public class Person {
    public String name;

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

我们将重复插入Person对象,把它作为HashMap的key。在Map中不容许key重复。

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

此处,我们使用Person作为一个key。既然Map不容许重复的key,那么我们上面这块代码也将不会增加太多的内存。
但是,如果我们不重写equals方法,这些重复的对象,增加内存。在java堆中,我们将看到许多对象,截图如下:

然后,如果我们重写equals和hashCode方法,那么在堆内存中只有一个对象。看看我们修改后的例子:
```java
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;