深入理解Java中的内存泄漏(译)

原文地址

1. 介绍

使用内建的垃圾收集器(或者是短暂的GC)来进行内存自动管理是使用Java的核心好处之一,GC机制在后台自动进行内存分配和回收,因此能够对大部分内存泄漏的情况进行处理。

尽管GC机制能够高效地管理一部分内存,但并不意味着它能简化内存泄漏的处理。GC很智能, 但并不是万能的。就处是一个细致的开发者写的应用也有可能出现内存泄漏。

有些情况下应用程序会产生大量的多余对象,导致占用了很多内存资源,甚至会导致应用程序的崩溃。

内存在Java中是一个真正的问题,在本篇教程中我们可以看到产生内存泄漏的场景,怎样在运行时发现它们以及怎么在程序中去处理这些问题。

2. 什么是内存泄漏

内存泄漏就是某些场景下有些对象在堆中不再被用到,但是垃圾收集器并不能回收它们,因此它们没有被合理的管理。

内存泄漏不仅会占用内存资源而且随着时间推移还会影响程序的性能,如果对其不采取任何措施,最终会耗尽系统资源产生 java.lang.OutOfMemoryError异常导致程序终止。

在堆内存中有被引用和无引用两种类型的对象,被引用的对象在程序中会有有效的引用指向,无引用对象则没有。

垃圾回收器能够回收阶段性地回收没有被引用的对象,但是并不会回收那些被引用的资源, 这也是内存泄漏出现的根本原因。

image-20190131193807625

内存泄漏的表现

  • 应用在长期运行期间出现严重的性能降级
  • 应用中出现OutOfMemoryError堆内存错误
  • 自发或者是莫名其妙的应用崩溃
  • 应用偶尔性出现对象连接被占满

接下来让我们具体看看这些场景以及如何去应对。

3. Java中内存泄漏的类型

在任何应用中,内存泄漏的出现都有若干的可能。这里我们会讨论经常会出现的场景。

3.1 静态字段导致的内存泄漏

第一个常见出现内存泄漏的场景就是大量使用静态变量。

Java中静态字段会有和运行中应用程序同样长的生命周期(除非是类加载器被垃圾回收器回收)。

下面这段代码就用了一个静态的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");
    }
}

现在如果我们在这段程序运行时分析堆内存的使用情况,我们会发现在points 1和points 2之间堆内存的使用会增加。

然后执行完方法populateList运行到Points 3是使用的内存并没有被回收,通过VisualVM可以看到下图:

image-20190131195332463

然而如果我们去掉上段代码中第2行的static修饰关键字,内存使用情况会出现很大的改观,通过VisualVM可以看到:

image-20190131195537792

通过两幅图的对比我们可以看到代码的前半部分执行情况基本一样,但是去掉static关键字后当程序执行完populateList方法,list占用的内存由于没有任何引用因此全部被垃圾回收器回收了。

因此我们对使用静态变量应要非常小心。如果集合或者大对象被static关键字修饰,那么在应用的整个生命周期中它们都会被保存在内存中,导致占用了其它地方需要用到的宝贵内存。

那么如何避免这种情况?

  • 减少静态变量的使用
  • 当使用单例时,采用懒加载来迭代提前加载

3.2 没有被关闭的资源

当我们打开一个连接或者是创建一个流是地,JVM会给这些资源分配内存。例如数据库的连接,输入i流和session对象。

如果忘了关闭这些资源将会一直占用系统内存,导致GC不能回收这些对象。甚至在现在异常时也会出现,因为程序会因为抛出异常直接跳过关闭资源的代码。

在某些场景下,打开的资源连接会占用着内存,如果我们不对其进行处理,会严重影响性能甚至导致OutOfMemoryError异常。

那么如何避免这种情况:

  • 必须使用finally块来关闭资源
  • 关闭资源的代码块(即使是finally代码块)自身不能抛出任何异常
  • 如果使用Java 7 以一的版本,可以使用try-with-resources代码块

3.3 对equals和hashCode进行了不恰当的实现

当我们定义一个新类时,一种常见的问题就是不恰当地重写equals和hashCode方法。

对HashSet和HashMap的许多操作都会用到这些方法,如果我们不正常地重写了它们,也有可能导致内存泄漏。

那么我们以Person类作为示例,并且将其作为HashMap的一个key:

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

接下来往以Person作为key的Map中插入重复的Person对象

注意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作为Map的key,由于Map不允许重复的key,因此重复的我们插入重复的Person不应该增加内存的占用。

但是因为我们没有定义合适的equals方法,这些重复的对象累积起来导致内存的增加,因此我们在内存中不只看到一个对象。VisualVM的堆内存使用情况如下:

image-20190131202229689

然而如果我们对equals和hashCode方法进行了恰当的重写,那么在Map中只会存在一个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;
    }
}

在这种情况下,下面的断言就会为true:

@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代码进行了合适的重写后,同样的程序执行堆内存占用情况会如下:

image-20190131202639392

另外一个例子就是使用ORM工具如Hibernate,会用equals和hashCode方法来分析对象

并且将其保存在缓存中。

如果没有对这些方法进行恰当的重写就很有可能导致内存泄漏,因为Hibernate就不能区别这些对象从而在缓存中保存了重复的对象。

那么如何避免这种情况?

  • 当定义一个实体类时,第一件事就是要重写equals的hashCode方法
  • 不仅要重写,而且要进行恰当的重写

更多的信息可以阅读教程 Generate equals() and hashCode() with EclipseGuide to hashCode() in Java.

3.4 内部类引用外部类

在非静态类(匿名类)中会出现这种情况。在初始化时,这些内部类总是需要一个完整类的实例。

在默认情况下,非静态内部类对其外部类会有隐式的引。当在应用程序中用这些内存类时,即使引用外部类的对象已经失效了,但并不会被垃圾回收。

当一个类引用了有许多大对象并且有一个非静态内部类,就算只是创建一个内部类,内存占用情况会如下:

image-20190131204345455

然而我们只是需要将这个内部类声明为静态的内存占用情况就会如下:

image-20190131204509626

出现这种情况的原因是因为内部类持有对外部类的引用, 从而导致垃圾回收器不能回收外部类。在匿名类中同样会出现这样的情况。

那么如何避免这种情况:

  • 如果内部类不需要用到外部类的成员对象,考虑将其改为静态类

3.5 使用finalize方法

当使用finalize方法时也会产生内存泄漏。任何时候当对象的finalize方法被调用时,垃圾回收器并不会立即回收它而是将其放入到回收队列,等到合适时机才回收。

除此之外,如果重写的finalize方法并不是最佳导致finalizer队列跟不上垃圾回收器的速度,或早或迟会导致程序出现OutOfMemoryError

为了演示这种情况,我们可以重写一个对象的finalize方法并且在该方法的执行需要一定的时间。当大量持有该类的对象被垃圾回收时,在VisualVM中表现如下:

image-20190131205545337

然而当我们去掉重写的finalize方法后同样的程序表现如下:

image-20190131205637710

那么如何避免这种情况?

  • 尽量避免重写finalize方法

更多的信息可能阅读Guide to the finalize Method in Java

3.6 Interned 字符串

在Java7中Java 字符串常量池从永久代移到了堆空间中,但是对时使用java6以及更低版本的应用来说,我们在使用大量字符串时要非常小心。

当我们读取大量的长字符串并且调用intern方法,那么这些字符串就会被放到永久代的字符串常量池,只要程序在运行它将会一直存在。这将占用很多内存并且导致内存泄漏。

在java1.6中永久代的使用情况通过VisualVM观察如下:

image-20190131210408433

相对这种情况,如果我们只是从一个文件中读取字符串而不调用intern方法,那么永久代的使用情况就是这样:

image-20190131210548663

那么如何避免这种情况?

  • 最简单的方法就是将java升级到7及以上的版本,因为将字符串常量池移到了堆区
  • 如果使用了大量了字符串,那么就可以通过增加永久代的大小来避免OutOfMemoryErrors

-XX:MaxPermSize=512m

3.7 使用本地线程变量

通过使用ThreadLocal (更多可阅读 Introduction to ThreadLocal in Java 教程) 本地线程变量可以对线程进行隔离从而达到线程安全的目的。

当使用本地线程变量时,每个线程在存活期间都会持有一份对该变量拷贝的引用并且会自己维护这份拷贝,而不是在多线程之间共享。

尽管使用ThreadLocal有如此大的好处,但是却有很多争论的,因为如果使用不当很容易导致内存泄漏。Joshua Bloch对ThreadLocal的使用作过如下评论:

在许多地方都写到,过于分散的线程池使用和过于分散的本地线程变量使用会导致意想不到对象存留。但归罪于本地线程亦是是莫须有的罪名。

ThreadLocal中的内存泄漏

当线程不再存活时,那么它引用的ThreadLocal对象也会被垃圾回收。但是当今应用服务器的使用导致ThreadLocal的使用出现了问题。

当今服务器应用通过使用线程池来传递请求而不是创建新的线程(比如Apache Tomcat服务器就是使用Executor框架)。此外,它们使用独立的类加载器。

由于线程池采取的是线程复用的理念,因此它们从不会被垃圾回收,而是被其它的请求复用。

这种情况下如果任何一个类创建了一个ThreadLocal变量但没有显示移除它,那么这个对象的拷贝就会一直被工作线程持有直到应用程序终止,导致对应不能被垃圾回收器回收。

那么如何避免这种情况?

  • ThreadLocal提供了remove方法,该方法会移除当前线程对它的拷贝。所以养成当ThreadLocal对象不再使用就清除它的好习惯。

  • 不要使用 ThreadLocal.set(null) 来清理值。因为它不会清理这个合二为一而是将ThreadLocalMap中的kv分别设置为空

  • 更好的解决办法可以考虑将ThreadLocal作为一个资源对象将释放代码放在finally块中从而保证总是能被回收,即使发生异常:

  • try {
        threadLocal.set(System.nanoTime());
        //... further processing
    }
    finally {
        threadLocal.remove();
    }
    

4. 处理内存泄漏的其它策略

尽管对处理内存泄漏没有能用的方法,但不是有一些方式可以减少这些泄漏。

4.1 启用分析器

Java分析器就是一些监控和诊断应用内存泄漏的工具。通过它可以分析我们应用程序内存的运行情况, 比如说内存的分配。

通过使用分析器,我们可以对比不同的情形从而对资源进行最佳的使用。

在本文第3部分中我们使用了Java VisualVM.可以阅读 Guide to Java Profilers这篇文章来学更多的分析器,如Mission Control, JProfiler, YourKit, Java VisualVM, 和the Netbeans Profiler.

4.2 打印详细的垃圾回收情况

通过打印增援回收情况, 我们可以追踪GC的具体情况。通过使用如下JVM参数即可:

-verbose:gc

加上这个参数后,我们就可以看到GC的具体情况:

image-20190131215507630

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

通过使用java.lang.ref 包中的一些类而不是直接引用对象,使用不同的引用类型让它们能更好的被垃圾回收。引用队列的设计就是让我们知道我们引用的对象是否被回收了,更多信息可以阅读Soft References in Java

4.4 Eclipse内存泄漏的警告

对于使用JDK 1.5及以上版本的应用, Eclipse在我们程序出现明显的内存泄漏时会显示警告和错误。因此,当使用Eclipse进行开发时我们要多关注"Problems"标签页并且对内存泄漏警告(如果有的话)更加警惕。

image-20190131215937615

4.5 Benchmarking

通过执行benchmark我们可以衡量和分析Java代码的性能。这种方式我们可以对同一任务的不同实现进行对比, 从而帮助我们选择更好的方式并且节约内存。

可以阅读Microbenchmarking with Java 教程获取关于benchmarking的更多资料。

5. 总结

用外行人的话来说,我们可以将内存泄漏当作占用重要内存资源从而导致应用程序性能下降的一种疾病,像其它疾病一样,如果没有治愈它,那么随着时间推移会导致应用以崩溃而失败。

使用Java时找到并解决内存泄漏需要很高的技巧和丰富的经验,在很多情况下都会现在泄漏,因此并没有一种通过的方法来处理内存泄漏。

然而,如果通过采用分析等手段以使用最佳的方式和执行严格的代码测试,我们就可以减少应用中的内存泄漏。

一如既往,本文中产生VisualVM效果图的代码在GitHub都可以找到。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • 第二部分 自动内存管理机制 第二章 java内存异常与内存溢出异常 运行数据区域 程序计数器:当前线程所执行的字节...
    小明oh阅读 1,089评论 0 2
  • 九种基本数据类型的大小,以及他们的封装类。(1)九种基本数据类型和封装类 (2)自动装箱和自动拆箱 什么是自动装箱...
    关玮琳linSir阅读 1,836评论 0 47
  • 一、运行时数据区域 Java虚拟机管理的内存包括几个运行时数据内存:方法区、虚拟机栈、本地方法栈、堆、程序计数器,...
    加油小杜阅读 1,491评论 1 15
  • 一、运行时数据区域 Java虚拟机管理的内存包括几个运行时数据内存:方法区、虚拟机栈、本地方法栈、堆、程序计数器,...
    luhanlin阅读 525评论 0 0
  • 什么是真正的善?让人舒服是吗?当然不能怎么舒服怎么来吧。有些善令人当下想当不舒服。否则,文殊菩萨没必要以大威德金刚...
    嘉妈阅读 220评论 0 2