JVM内存泄漏分析

问题

Java线程是JVM基础的一部分。你的Java堆空间内存占用不仅仅是由于静态对象和长生命的对象导致,还有可能因为短生命对象。
OutOfMemoryError问题经常被误认为是内存泄露引起。我们经常忽略错误的线程执行模型和它们持有的JVM里的短生命对象,直到它们的执行完成我们才发现。
在这种问题情形下:

  • 程序中短生命/无状态对象(XML,JSON数据负载等)被线程持有的时间会变得很长(线程锁争用,大量数据负载,远程系统的慢响应时间等)。
  • 这种短生命对象会因为垃圾收集而晋升到长生命空间,比如老年代空间。
  • 副作用是会导致老年代空间很快被占满,增加了Full GC(major收集)的频率。
  • 由于这种严重的情况,它将导致更多的GC垃圾收集,增加JVM暂停时间和最终的OutOfMemoryError:Java堆空间。

你的应用此时被停掉,你很疑惑到底怎么回事。最后,你考虑增加Java堆空间或者寻找哪里有内存泄露,你真的找对路了么?
避免在线程栈大小(虚拟机栈)和Java堆内存占用之间产生混淆是非常重要的。线程栈(虚拟机栈)大小是一种特殊的内存空间,它被JVM用于存储每个方法调用。当一个线程调用方法A,它将这个调用入栈。如果方法A调用方法B,同样也会入栈。一旦方法执行完毕,这个调用便从栈里出栈。
这种线程方法调用会导致Java对象产生,并分配在Java堆里。增加线程栈的大小是没有任何效果的(对象最终在堆得年轻代(Eden)产生)。而调整线程栈大小通常是要处理java.lang.stackoverflowerror错误或者OutOfMemoryError: unable to create new native thread错误的时候才会需要。

编写对GC友好,不会泄露的代码

使用更多生命周期短的、小的、不改变指向(immutable)的对象,编写清晰的代码

出于懒惰也好,朴素的节俭意识也好,我们都习惯对一个变量重用再重用。但是,Java的垃圾收集器喜欢短生命周期的对象,对象如果在新生代内,在垃圾收集发生前就死掉了,垃圾收集器就什么都不用做了。现代JVM构建一个新对象只需要10个本地CPU指令,并不弱于C/C++。但垃圾收集没有压缩算法时会稍慢,更频繁的New对象也导致更频繁的GC。大对象的分配效率更低,而且对非压缩算法的垃圾收集器,更容易造成碎片。对象重用增加了代码的复杂度,降低了可读性。

将用完的对象设为NULL其实没什么作用

把对象主动设为Null的”好习惯”其实没什么用,JIT Compiler会自动分析local变量的生命周期。
只有一个例外情况,就是String[1024] foo这种赤裸裸的数组,你需要主动的foo[100]=null释放第100号元素,所以最好还是直接用ArrayList这些标准库算了。

避免显式GC(System.gc())

大家都知道System.gc()不好,full-gc浪费巨大,GC的时机把握不一定对等等,甚至有-XX:+DisableExplicitGC的JVM参数来禁止它。
但我还不会用System.gc()呢,不怕不怕。真的不怕吗?
先用FindBugs查一下所用到的全部第三方类库吧。
至少RMI 就会老实不客气的执行System.gc()来实现分布式GC算法。但我也不会用RMI啊。那EJB呢,EJB可是建在RMI上的。
如果无可避免,用-Dsun.rmi.dgc.client.gcInterval=3600000-Dsun.rmi.dgc.server.gcInterval=3600000 (单位为微妙) 增大大GC的间隔(原默认值为1分钟),-XX:+ExplicitGCInvokesConcurrentSystem.gc()也CMS并发执行。

finalize()

大家也都知道finalize()不好,分配代价昂贵,释放代价更昂贵(要多走一个循环,而且他们死得慢,和他们相关联的对象也跟着死得慢了),又不确定能否被调用(JVM开始关闭时,就不会再进行垃圾收集),又不确定何时被调用(GC时间不定,即使system.gc()也只是提醒而不是强迫GC,又不确定以什么样的顺序调用,所以finalize不是C++的析构函数,也不像C++的析构函数。
我们都知道啊,所以我从来都没使用。都是在显式的维护那些外部资源,比如在finally{}里释放。

内存泄漏

Java 不是有垃圾收集器了吗?怎么还泄漏啊?
此泄漏非比泄漏。C/C++的泄漏,是对象已不可到达,而内存又没有回收,真正的内存黑洞。
而Java的泄漏,则是因为各种原因,对象对应用已经无用,但一直被持有,一直可到达。

  • 被生命周期极长的集合类不当持有,号称是Java内存泄漏的首因。
  • 这些集合类的生命周期通常极长,而且是一个辅助管理性质的对象,在一个业务事务运行完后,如果没有将某个业务对象主动的从中清除的话,这个集合就会吃越来越多内存,可以用WeakReference,如WeakHashMap,使得它持有的对象不增加对象的引用数。
  • Scope定义不对,这个很简单了,方法的局部变量定义成类的变量,类的静态变量等。
  • 异常时没有加finally{}来释放某些资源,JDBC时代也是很普遍的事情。
  • 另外一些我了解不深的原因,如:Swing里的Listener没有显式remove;内部类持有外部对象的隐式引用;Finalizers造成关联对象没有被及时清空等。

    Java内存回收机制

    不论哪种语言的内存分配方式,都需要返回所分配内存的真实地址,也就是返回一个指针到内存块的首地址。Java中对象是采用new或者反射的方法创建的,这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由Java虚拟机通过垃圾回收机制完成的。
    Java中对内存对象的访问,使用的是引用的方式。在Java代码中我们维护一个内存对象的引用变量,通过这个引用变量的值,我们可以访问到对应的内存地址中的内存对象空间。在Java程序中,这个引用变量本身既可以存放堆内存中,又可以放在代码栈的内存中(与基本数据类型相同)。GC线程会从代码栈中的引用变量开始跟踪,从而判定哪些内存是正在使用的。如果GC线程通过这种方式,无法跟踪到某一块堆内存,那么GC就认为这块内存将不再使用了(因为代码中已经无法访问这块内存了)。
    GC为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控,Java会使用有向图的方法进行管理内存,实时监控对象是否可以达到,如果不可到达,则就将其回收,这样也可以消除引用循环的问题。
    在Java语言中,判断一个内存空间是否符合垃圾收集标准有两个:一个是给对象赋予了空值null,以下再没有调用过,另一个是给对象赋予了新值,这样重新分配了内存空间。
    内存泄露是指无用对象(不再使用的对象)持续占有内存或无用对象的内存得不到及时释放,从而造成的内存空间的浪费称为内存泄露。内存泄露有时不严重且不易察觉,这样开发者就不知道存在内存泄露,但有时也会很严重,会提示Out of memory
    那么,Java内存泄露根本原因是什么呢?长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景。具体主要有如下几大类:

    静态集合类引起内存泄露

    HashMapVector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,他们所引用的所有的对象Object也不能被释放,因为他们也将一直被Vector等引用着。
    例:
    1
    2
    3
    4
    5
    6
    7
    Static Vector v = new Vector(10); 
    for (int i = 1; i<100; i++)
    {
    Object o = new Object();
    v.add(o);
    o = null; //只是当前引用被赋予null,对象o依然是有值
    }//

在这个例子中,循环申请Object对象,并将所申请的对象放入一个Vector中,如果仅仅释放引用本身(o=null),那么Vector仍然引用该对象,所以这个对象对GC来说是不可回收的。因为,GC在跟踪代码栈中的引用时,会发现v引用,而继续往下跟踪,就会发现v引用指向的内存空间中又存在指向Object对象的引用。也就是说尽管o引用已经被置空,但是Object对象仍然存在其他的引用,是可以被访问到的,所以GC无法将其释放掉。因此,如果对象加入到Vector后,还必须从Vector中删除,最简单的方法就是将Vector对象设置为null。

覆写hashcode

当集合里面的对象属性被修改后,再调用remove()方法时不起作用。(这需要覆写hashcode(),此hashcode()必需包含被修改的属性)

例1

实体对象,覆写hashcode方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package memory;

public class Person {
private String username;
private String password;
private int age;

public Person(String username, String password, int age) {
this.username = username;
this.password = password;
this.age = age;
}

public String getUsername() {
return username;
}

public void setUsername(String username) {
this.username = username;
}

public String getPassword() {
return password;
}

public void setPassword(String password) {
this.password = password;
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + age;
result = prime * result
+ ((password == null) ? 0 : password.hashCode());
result = prime * result
+ ((username == null) ? 0 : username.hashCode());
return result;
}

public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Person other = (Person) obj;
if (age != other.age)
return false;
if (password == null) {
if (other.password != null)
return false;
} else if (!password.equals(other.password))
return false;
if (username == null) {
if (other.username != null)
return false;
} else if (!username.equals(other.username))
return false;
return true;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) {
Set<Person> set = new HashSet<Person>();
Person p1 = new Person("唐僧", "pwd1", 25);
Person p2 = new Person("孙悟空", "pwd2", 26);
Person p3 = new Person("猪八戒", "pwd3", 27);
set.add(p1);
set.add(p2);
set.add(p3);

System.out.println("p1:" + p1.hashCode());
System.out.println("p2:" + p2.hashCode());
System.out.println("p3:" + p3.hashCode());
System.out.println("修改前包含p3对象:" + set.contains(p3) + ";hasecode =" + p3.hashCode());
// System.out.println("总共有:" + set.size() + " 个元素!"); // 结果:总共有:3 个元素!
p3.setAge(2); // 修改p3的年龄,此时p3元素对应的hashcode值发生改变
System.out.println("修改后包含p3对象:" + set.contains(p3) + ";hasecode =" + p3.hashCode());

System.out.println("是否删除成功:" + set.remove(p3)); // 此时remove不掉,造成内存泄漏
set.add(p3); // 重新添加,居然添加成功
System.out.println("-------------------------------新增p3后");
for (Person person : set) {
System.out.println(person + ";" + person.getUsername());
}
}

结果

1
2
3
4
5
6
7
8
9
10
11
p1:107826683
p2:130403032
p3:136136341
修改前包含p3对象:true;hasecode =136136341
修改后包含p3对象:false;hasecode =136112316
是否删除成功:false
-------------------------------新增p3后
memory.Person@81ce8bc;猪八戒
memory.Person@7c5cad8;孙悟空
memory.Person@66d4dfb;唐僧
memory.Person@81ce8bc;猪八戒

总结:set是不能重复的。set的内部是一个HashMapset#add()就是map#add()map里是按照entry#table来存储对象的,add里是按照对象的hashcode的值计算table的位置。

  • 未修改:根据hashcode计算的值,把对象引用存放在table的某个位置。
  • 修改:根据新的hashcode的值,把对象引用存放在table的新位置。

修改前和修改后,实际上在table中存放了,2个地址。但是2个地址都是指向同一个引用。 hashcode是计算对象存储的地址,对象没有改变,改变的是hashcode,也就是存储的地址。所以有2个引用指向一个对象地址。
contain = false,也是定位不到旧的hashcode计算值的table位置。这个hashcode被重新设置,所以并不存在table中。
查看hashMap#add(),就可以知道新旧2个table位置,实际上存的都是对象的引用。
旧的hashcode计算值的table位置是3,新的hashcode计算值的位置是15。

监听器

在Java编程中,我们都需要和监听器打交道,通常一个应用当中会用到很多监听器,我们会调用一个控件的诸如addXXXListener()等方法来增加监听器,但往往在释放对象的时候却没有记住去删除这些监听器,从而增加了内存泄漏的机会。

各种连接

比如数据库连接(dataSourse.getConnection()),网络连接(socket)和io连接,除非其显式的调用了其close()将其连接关闭,否则是不会自动被GC回收的。对于ResultsetStatement对象可以不进行显式回收,但Connection一定要显式回收,因为Connection在任何时候都无法自动回收,而Connection一旦回收,ResultsetStatement对象就会立即为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭ResultsetStatement对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement对象无法释放,从而引起内存泄漏。这种情况下一般都会在try里面去的连接,在finally里面释放连接。

内部类和外部模块等的引用

内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。此外程序员还要小心外部模块不经意的引用,例如程序员A负责A模块,调用了B模块的一个方法如:

1
public void registerMsg(Object b);

这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B是否提供相应的操作去除引用。

单例模式

不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露。
例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A{ 
public A(){
B.getInstance().setA(this);
}
....
}
//B类采用单例模式
class B{
private A a;
private static B instance=new B();
public B(){}
public static B getInstance(){
return instance;
}
public void setA(A a){
this.a=a;
}
//getter...
}

显然B采用singleton模式,它持有一个A对象的引用,而这个A类的对象将不能被回收。想象下如果A是个比较复杂的对象或者集合类型会发生什么情况。

本地变量

在不涉及复杂数据结构的一般情况下,Java的内存泄露表现为一个内存对象的生命周期超出了程序需要它的时间长度。我们有时也将其称为“对象游离”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FileSearch{

private byte[] content;
private File mFile;

public FileSearch(File file){
mFile = file;
}

public boolean hasString(String str){
int size = getFileSize(mFile);
content = new byte[size];
loadFile(mFile, content);

String s = new String(content);
return s.contains(str);
}
}

在这段代码中,FileSearch类中有一个函数hasString,用来判断文档中是否含有指定的字符串。流程是先将mFile加载到内存中,然后进行判断。但是,这里的问题是,将content声明为了实例变量,而不是本地变量(局部变量)。于是,在此函数返回之后,内存中仍然存在整个文件的数据。而很明显,这些数据我们后续是不再需要的,这就造成了内存的无故浪费。
要避免这种情况下的内存泄露,要求我们以C/C++的内存管理思维来管理自己分配的内存。

  1. 是在声明对象引用之前,明确内存对象的有效作用域。在一个函数内有效的内存对象,应该声明为local变量,与类实例生命周期相同的要声明为实例变量以此类推。
  2. 在内存对象不再需要时,记得手动将其引用置空。

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×