天天看点

理解java 中内存泄漏

理解java 中内存泄漏

java的核心优势之一是使用内置的垃圾回收机制(简称GC)实现自动内存管理。GC隐式地负责分配和释放内存,因此能够处理大部分内存泄漏问题。

虽然GC有效地处理了很大一部分内存,但它不能保证内存泄漏的解决方案是万无一失的。GC非常智能,但并非完美无缺。即使在认真开发人员的应用程序中,内存泄漏也可能悄然出现。如应用程序生成大量多余对象的情况,从而耗尽关键的内存资源,有时导致整个应用程序失败。

内存泄漏是java与生俱来的问题。本文我们探寻内存泄漏的潜在原因,如何在运行时识别它们,以及如何在应用程序中处理该问题。

1. 什么事内存泄漏

内存泄漏是指存在堆中的对象不再被使用,但GC不能在内存中删除它们,而不必要地维护它们。

内存泄漏产生问题是占用内存资源并逐渐降低系统性能。如何不及时处理,应用程序将耗尽资源,最终导致致命的 java.lang.OutOfMemory错误。

在堆内存中有两种对象——引用对象和非引用对象(可达或不可达)。引用对象是指应用程序正在使用的对象,而非引用对象是指应用程序不再使用的对象。GC负责定期地删除非引用对象,但不会回收引用对象。下图为内存泄漏内存示意图:

理解java 中内存泄漏

内存泄漏症状

  • 引用运行一段时间后性能严重下降
  • 产生OutOfMemoryError错误
  • 自然地或奇怪地应用程序崩溃
  • 应用程序偶尔会耗尽连接对象

下面我们详细讲解这些场景以及如何应对。

2. java 中内存泄漏类型

应用程序产生内存泄漏可能有多种原因,下面我们讨论一些常见原因。

2.1. static 字段导致内存泄漏

第一个能引起内存泄漏的场景是static变量。java中static字段的生命周期通常是整个应用程序(除非类加载器有资格进行垃圾收集).

下面创建一个简单java程序,填充一个static的list变量:

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");
    }
}
           

如果我们应用程序执行过程中的分析堆内存情况,可以看到debug 1位置和2位置,堆内存如期望一致保持增长。但在debug 3位置离开populateList()方法,堆内存没有垃圾回收,下面是VisualVM截图:

理解java 中内存泄漏

如果我们删除第二行的static关键字,堆内存会产生戏剧性变化,如图示:

理解java 中内存泄漏

从开始到debug 2几乎与static情况下一致。但是在离开populateList()方法之后,该列表的所有内存都被垃圾回收,因为已经没有对它的任何引用。

因此我们需要注意static变量的使用。如果集合或打对象被申明为static,那会保留至整个应用程序过程中,从而阻塞了本来可以在其他地方使用的重要内存。

如何解决

  • 最小化使用static变量
  • 使用单例模式时,使用延迟加载对象而不是急切加载的实现

2.2. 未关闭资源

当我们使用连接或打开流,jvm会为这些资源分配内存,如:数据库连接、输入流以及session对象。

忘记关闭这些资源会一直占用内存,GC无法回收。还有在关闭资源之前遇到异常也会出现这种情况。

在这两种情况下,资源留下打开的连接会消耗内存,如果我们不处理它们,它们会降低系统性能,甚至可能导致OutOfMemoryError异常。

如何解决

  • 总是使用finally块关闭内存
  • 关闭资源的代码块(即使在finally块中)也不应该有任何异常。
  • 可以使用java7中的try-with-resource块自动关闭资源

2.3. 不正确实现equals()和hashCode()方法

当定义类时,一个很常见的疏忽是没有正确覆盖equals()和hashCode()方法。HashSet 和 HashMap的很多操作都需要使用这两个方法,如果没有正确实现,可能产生潜在的内存泄漏错误。

让我们以一个简单的Person类为例,并将其用作HashMap中的键:

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

现在,我们将重复的Person对象作为key插入至Map中,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("jon"), 1);
    }
    Assert.assertFalse(map.size() == 1);
}
           

这里使用Person对象作为key,因为Map不允许有重复key,大量重复的person对象不会增加内存。但因为没有正确定义equals()方法,重复对象却增加了内存,实际在内存中不只一个对象,Heap Memory 图示如下:

理解java 中内存泄漏

如果我们正确地覆盖了equals()和hashCode()方法,那么在map中始终会只有一个对象。正确实现Person类代码如下:

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()方法,堆内存图示如下:

hashCode

另外一个示例是使用如Hibernate的ORM工具,其使用equals()和hashCode()方法去分析对象并保存至缓存中。如果没有正确覆盖equals()和hashCode()方法,内存泄漏的几率很高,因为Hibernate不能正确比较对象,会把重复的对象填充至缓存中。

如何解决

  • 作为重要经验,在定义新类时总需要覆盖equals()和hashCode()方法
  • 不仅要覆盖这些方法,还必须以最佳的方式覆盖这些方法

2.4. 内部类引用外部类

这种情况主要这对非static内部类(匿名类),这些内部类总是需要一个封闭类的实例。每个非静态内部类缺省都有一个隐式引用至其容器类。如果我们在应用中使用这个内部类对象,那么即使容器类对象出了作用域,也不会被GC回收。

考虑一个类,它包含对许多大对象的引用,并且有一个非静态的内部类。现在当我们创建一个内部类的对象时,内存模型看起来是这样的:

理解java 中内存泄漏

如果我们申明内部类为static,那么相同的内存模型为:

理解java 中内存泄漏

这是因为内部类对象隐式保持外部类对象的引用,因此GC不能回收,对匿名内部类也一样。

如何解决

  • 如果内部类不需要访问外部类成员,最好定义为static内部类

2.5. finalize()方法

使用finalize() 方法也是造成内存泄漏的潜在原因之一。重写类的finalize()方法时,该类的对象不会立即被垃圾回收。相反,GC将它们排队等待稍后某个时间点执行。

此外,如果用finalize()方法编写的代码不是最优的,如果finalizer队列跟不上Java垃圾收集器的速度,那么我们的应用程序迟早会遇到OutOfMemoryError异常。

为了演示,我们定义类并重写finalize()方法,方法内使用sleep模拟需要一定时间才能执行完成。那么该类的当大量对象需要回收时,那么内存会类似这样:

理解java 中内存泄漏

如果删除finalize()方法,同样内存图示如下:

理解java 中内存泄漏

如何解决

  • 尽量避免覆盖finalize()方法

2.6. intern字符串

在Java 7中带来了重大变化,Java字符串池从PermGen转移到HeapSpace。但是对于在版本6及以下运行的应用程序,我们在处理大字符串时应该加以注意。

如果我们读取一个巨大的字符串对象,并在该对象上调用intern(),那么它将进入位于PermGen(永久内存)中的字符串池,并在应用程序运行时一直驻留。这将阻塞内存回收并造成主要的内存泄漏。

在JVM 1.6 的PermGen 内存图示:

理解java 中内存泄漏

相反,如果从文件中读字符串,当不调用intern方法,那么内存图示如下:

理解java 中内存泄漏

如何解决

  • 最简单方式是升级至java最新版本,从java7开始字符串池已经迁移至堆内存
  • 遇到大字符串对象,可以增加PermGen内存空间大小,避免潜在的OutOfMemoryError异常

2.7. 使用ThreadLocal

ThreadLocal能够隔离特定线程状态并实现线程安全。当使用ThreadLocal时,每个线程会隐式引用ThreadLocal变量的拷贝,并维护自己的拷贝,避免多个活动线程共享资源。

尽管ThreadLocal有其优点,但是它的使用是有争议的,因为如果使用不当,它会导致内存泄漏。

一旦持有的线程不再是活动的,ThreadLocal变量应该被垃圾收集。但是,当ThreadLocal变量与现代应用服务器一起使用时,问题就出现了。

现代应用程序使用线程池代替创建一个线程(如:Executor类),并且使用独立的类加载器。由于应用程序中的线程池使用线程重用的概念,因此它们从来不被垃圾回收————相反,它们被重用来服务另一个请求。

如果类创建ThreadLocal变量,却没有显示删除,那么该对象拷贝将与工作线程一起保留至应用程序结束,则会阻止对象被回收,造成内存泄漏。

如何解决

  • 当不再使用ThreadLocal对象时,最好清除————ThreadLocal提供了remove方法,会删除该变量的当前线程值
  • 不要使用ThreadLocal.set(null)方法清除值————其没有实际清除值,相反,其查找与当前线程关联的Map,并将键-值对分别设置为当前线程和null
  • 更好的思路是把ThreadLocal作为资源,需要在finally块中回收,确保即使遇到异常也会关闭:
try {
    threadLocal.set(System.nanoTime());
    //... further processing
}
finally {
    threadLocal.remove();
}
           

3. 其他策略处理内存泄漏

虽然在处理内存泄漏时没有一种通用的解决方案,但是有一些方法可以有效处理泄漏。

3.1. 启用性能分析

java性能分析工具可以监控并诊断应用程序的内存泄漏,它们分析应用程序内部发生的事情——例如,内存是如何分配的。使用分析工具可以比较不同方法并发现优化资源方法。前一节中一直使用Java VisualVM,其他工具还有如Mission Control, JProfiler, YourKit, Java VisualVM, and the Netbeans Profiler。

3.2. 开启详细GC参数

启用详细GC收集情况,可以跟踪GC,通过增加JVM配置:

-verbose:gc
           

增加该参数,可以看到GC内部发生的详细信息:

理解java 中内存泄漏

3.3. 使用引用对象避免内存泄漏

我们也可以采用java中内置的引用对象处理内存泄漏,位于java.lang.ref包中,使用ref包中的引用而非直接引用对象,使它们更容易回收。

设计引用队列目的是让我们知道垃圾收集器执行的操作。

3.4. IDE内存泄漏警告

对于jdk1.5以上项目,遇到上述情况时IDE会报出警告(如Eclipse和idea),我们可以查看警告内容并修正:

理解java 中内存泄漏

3.5. 基准测试

通过基准测试可以衡量和分析java代码性能。针对实现相同功能的不同方法对比,可以帮助我们选择最佳方法并节约内存。

3.6. 代码审查

最后,我们总是使用经典的、老式的方法来执行简单的代码审查。在某些情况下,即使这个看起来微不足道的方法也可以帮助消除一些常见的内存泄漏问题。

4. 总结

用外行的术语来理解,可以将内存泄漏看作一种疾病,它通过占用重要内存资源,从而降低应用程序的性能。和所有其他疾病一样,如果不能治愈,随着时间的推移,它可能会导致致命的程序崩溃。

内存泄漏很难解决,找到它们需要对Java语言相当精通和掌握。在处理内存泄漏时,没有适用于所有情况的解决方案,因为泄漏可能通过各种各样的情况发生。

然而,如果我们采用最佳实践并定期执行严格的代码测试和性能分析,那么我们可以将应用程序中内存泄漏的风险降到最低。