翻译 | 理解Java中的内存泄漏

猪年第一篇译文,你们多多支持!java

原文自工程师baeldung博客,传送门git

1. 介绍

Java 的其中一个核心特色是经由内置的垃圾回收机制(GC)下的自动化内存管理。GC 默默地处理着内存分配和释放工做所以可以处理大部份内存泄漏问题。程序员

虽然 GC 可以有效地理一大部份内存,但他不保证能处理全部内存泄漏状况。GC 十分智能,但并不完美。即便是在谨慎的程序员所开发的应用程序下内存泄漏依旧会悄悄地出现。github

应用程序仍然会出现产生大量的多余的对象的状况,所以耗尽了全部关键的内存块资源,有时候还会致使应用程序崩坏。web

内存泄漏是 Java 中的一个永恒的问题。在这篇文章中,咱们将会讨论内存泄漏的潜在缘由,怎么在运行时识别它们而且怎么在应用程序中解决它们数据库

2. 什么是内存泄漏

内存泄漏是指这么一种状况,当存在对象在堆中再也不被使用,但垃圾回收器没法从内存中移除它们而且所以变得不可被维护。缓存

内存泄漏十分很差由于它锁住了部份内存资源而且逐渐下降系统的性能。而且若是没法处理它,应用程序最终会耗尽全部资源最终产生一个致命的错误 -- java.lang.OutOfMemoryError安全

这里有两种不一样类型的对象存在于堆内存中,被引用的以及未被引用的。被引用的对象是指那些在应用程序中仍然被主动使用的而未被引用的对象是指那些不在被使用的。服务器

垃圾回收器会按期清除未被引用对象,但历来都不收集那些仍然被引用的对象。这就是内存泄漏发生的其中一个缘由:框架

内存泄漏的症状:

  • 当应用程序持续长时间运行致使服务器性能的严重降低
  • 应用程序中的堆异常 OutOfMemoryError
  • 自发以及奇怪的程序崩溃
  • 程序偶然耗尽链接对象

让咱们关注下这些场景而且研究下它们为何会发生。

3. Java 中内存泄漏的类型

在任何的程序当中,内存泄漏能由几种缘由引发。在这节,咱们来讨论下最多见的一种。

3.1. 静态字段致使的内存泄漏

第一种可能致使内存泄漏的状况是大量使用静态字段。

在 Java,静态字段的生命周期一般和运行的应用程序的整个生命周期相匹配(除非类加载器有资格进行垃圾回收)

让咱们建立一个填充了静态 list 的简单 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之间,正如预期所想的那样,堆内存的使用增长了。

可是当咱们在调试点3跳出了 populateList() 方法,在 VisualVM 能够看到,堆内存仍然未被回收:

然而,在上述的程序当中,若是咱们在第二行把关键字 static 去掉的话,内存使用将会发生一个剧烈的变化,在 VisualVM 能够看到:

调试点的第一部分和存在 static 的例子差很少同样。但此次在跳出 populateList() 以后,list 所使用的内存所有被回收了由于咱们再也不引用它了。

所以使用 static 变量时咱们须要留意了。若是集合或者大对象被声明为 static,那么它们在应用程序的整个生命周期中都保留在内存中,所以锁住了那些本来能够用在其余重要地方的内存。

怎么预防这种状况发生呢?

  • 尽可能减低 static 变量的使用
  • 使用单例模式时,使用延迟加载而非当即加载

3.2. 未关闭资源致使的内存泄漏

当咱们产生新的链接或者开启流的时候,JVM 会为它们分配内存,像数据库链接、输入流或者会话对象等等。

忘记关闭流能致使内存被锁,从而它们也没法被回收。这甚至会出如今那些阻止程序执行关闭资源的语句的异常中。

不论哪一种状况,资源产生的链接都会消耗掉内存,而且若是不处理它,会下降性能和致使 OutOfMemoryError

怎么预防这种状况发生呢?

  • 始终使用 finally 块来关闭资源
  • 关闭资源的代码块(包括 finally 块)自身不能带有异常
  • 当使用 Java 7或更高版本,可使用 try-with-resources 语法

3.3. 不当的 equals()hashCode() 实现

当定义新类的时候,一种很是常见的疏忽是没有正确编写 equals()hashCode() 的重写实现方法。

HashSetHashMap 在许多操做当中使用这两个方法,若是咱们没有合理地重写它们,会致使潜在的内存泄漏问题。

让咱们以一个简单的 Person 类为例,而且将其做为一 HashMap 中的键:

public class Person {
    public String name;
     
    public Person(String name) {
        this.name = name;
    }
}
复制代码

如今咱们在 Map 当中做为键插入相同的 Person 对象。

请记住 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() 方法致使的内存泄漏

使用 finalizer 是另外一个潜在内存泄漏问题的来源。每当类中的 finalize() 方法被重写,那么该类的对象不会立刻被回收。相反,它们将会延后被 GC 放到队列当中序列化。

此外,若是用 finalize() 方法编写的代码不是最优的,而且 finalizer 队列跟不上 GC 的速度的话,那么,应用程序早晚会发生 OutOfMemoryError 异常。

为了演示这点,让咱们假设咱们已经有一个重写了 finalize() 方法的类而且这方法须要花费额外的一些时间来执行。当该类的大量对象被回收,VisualVM 是这样的:

然而,若是咱们仅仅是移除 finalize() 方法,同一个程序给出如下的响应:

怎么预防这种状况发生呢?

  • 咱们应该尽可能避免序列化

3.6. 字符串

Java 字符串池发生了重大变化,当它在 Java7 中从 PermGen 转移到 HeapSpace 时所发生的。可是对于在版本6及如下运行的程序,咱们在处理大字符串时应该更加注意。

若是咱们读取一个巨大的字符串对象,而且调用 intern() 方法,它就会进入到位于 PermGen (永久内存)的字符串池中,而只要咱们的应用程序运行,它就会一直呆在那里。

在 Java6 中本例子的 PermGen 在VisualVM 是这样的:

与此想法,在一个方法中,若是咱们只是从文件中读取字符串,而不进行 intern,PermGen 是这样的:

怎么预防这种状况发生呢?

  • 预防的最简单的方法就是升级到最新的 Java 版本,由于字符串池是从 Java7 开始移动到 HeapSpace 的
  • 若是须要处理大字符串,增长 PermGen 空间的大小,以免任何潜在的outofmemoryerror 异常
-XX:MaxPermSize=512m
复制代码

3.7. 使用 ThreadLocals

ThreadLocals 是一种结构,它使咱们可以将状态隔离到特定的线程中,从而实现线程安全。

当使用这种结构,每一个线程都会持有其 ThreadLocal 变量副本的隐式引用,而且维护它们自身的副本,而不是在活动状态的线程当中跨线程共享资源

尽管它有其优势,可是 ThreadLocal 的使用是受争议的。由于若是使用不恰当,它会致使内存泄漏。Joshua Bloch 曾经评论过 ThreadLocals*:

草率地使用线程池加上草率地使用线程局部变量,可能会致使意外的对象保留状况,这点在不少地方都被引发注意了,但把责任推给 ThreadLocal* 是没有依据的。

ThreadLocals 致使的内存泄漏

一旦持有的线程再也不活动,ThreadLocals 应当被回收。当问题就出在当 ThreadLocals 被使用在如今流行的应用服务器上。

如今的应用服务器是使用线程池去处理请求而并不是建立新的线程来处理(例如 Apache Tomcat 的 Executor)此外,它们还使用单独的类加载器。

因为应用服务器二弟线程池使用线程重用的概念来工做,所以它们历来都不会被回收 — 相反,它们被重用来服务于另外一个新的请求。

如今,若是任何类建立了一个 ThreadLocals 而并无显式地删除掉它,那么即便在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 分析工具是经过应用程序监视和诊断内存泄漏的工具。它分析应用程序内部发生的事情 — 例如内存是怎么分配的。

经过分析器,咱们可以比较不一样的方法和找到使用资源的最优方法。

在第三节中咱们使用 VisualVM。除此以外还有 Mission Control,JProfiler,YourKit,Java VisualVM,Netbeans Profiler 等等。

4.2. Verbose Garbage Collection

经过使用 Verbose Garbage Collection,咱们能够跟踪 GC 的详细轨迹,为了开启它,咱们须要在 JVM 配置中添加以下内容:

-verbose:gc
复制代码

经过添加这个参数,咱们能够看到 GC 内部的细节:

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

咱们也可使用 java.lang.ref 包中内置的引用对象来处理内存泄漏。使用 java.lang.ref 包,而并不会直接引用对象,使用对对象的特殊引用使得它们容易被回收。设计出的引用队列也让咱们了解到垃圾回收的执行操做。

4.4. Eclipse 的内存泄漏警告

对于 JDK1.5 或以上的项目,当遇到明显的内存泄漏状况时,Eclipse 都会显示警告和错误。所以使用 Eclipse 开发时,咱们能够经过查看 Problems 标签栏,来提防内存泄漏的警告了(若是有的话):

4.5. Benchmarking

咱们经过 Benchmarking 来度量和分析 Java 代码的性能。经过这种方法,咱们能够比较对同一个任务的不一样种作法之间的性能。这能够帮助咱们选择更好的方法去运行,也能够节约内存消耗。

4.6. 代码 review

最后,仍是以咱们最经典,老式的代码遍历方法来处理啦。

在某些状况下,即便是一个看起来微不足道的方法,也能够帮助咱们消除一些常见的内存泄漏问题。

5. 总结

用外行的话来讲,咱们能够把内存泄漏看成一种疾病,它经过阻塞重要的内存资源来下降应用程序的性能。和其余全部疾病同样,若是没有痊愈,随着时间推移,它可能致使致命的程序崩溃。

内存泄漏难以解决,找到它们须要对 Java 自己有很高的掌握以及知识。在处理内存泄漏时,没有适用于全部状况的解决方法,由于泄漏自己能够经过各类各样的事件发生。

然而,若是咱们采用最佳的代码方式实践而且按期作代码的回顾和严格的代码分析,那么咱们能够将应用程序中的内存泄漏风险降至最低。

像往常那样,用于生成本文章中 VisualVM 的响应的代码段在咱们的 Github 上能够获取到。

6. 译者总结

这篇文章很详细的讲述了各类发生内存泄漏的情形以及一些简单的解决方法,其中详细的解决方法在做者的其余文章中有说起,本人由于翻译的缘由并无放到上面,有须要的读者能够自行到文章本体去阅读。

并且本人由于时(TOU)间(LAN)缘由,并无把图片中的描述翻译过来,望各位读者见谅。

最后祝你们新春快乐。


小喇叭

广州芦苇科技Java开发团队

芦苇科技-广州专业互联网软件服务公司

抓住每一处细节 ,创造每个美好

关注咱们的公众号,了解更多

想和咱们一块儿奋斗吗?lagou搜索“ 芦苇科技 ”或者投放简历到 server@talkmoney.cn 加入咱们吧

关注咱们,你的评论和点赞对咱们最大的支持

相关文章
相关标签/搜索