再谈Java内存泄漏及代码完善

尽管java虚拟机和垃圾回收机制管理着大部分的内存事务,但是在java软件中还是可能存在内存泄漏的情况.的确,在大型工程中,内存泄漏是一个普遍问题.避免内存泄漏的第一步,就是要了解他们发生的原因.这篇文章就是要介绍一些常见的缺陷,然后提供一些非常好的实践例子来指导你写出没有内存泄漏的代码.一旦你的程序存在内存泄漏,要查明代码中引起泄漏的原因是很困难的.同时这篇文章也要介绍一个新的工具来查找内存泄漏,然后指明发生的根本原因.这个工具容易上手,可以让你找到产品级系统中的内存泄漏.
垃圾回收(GC)的角色
虽然垃圾回收关心着大部分的问题,包括内存管理,使得程序员的任务显得更加轻松,但是程序员还是可能犯些错误导致内存泄漏问题.GC(垃圾回收)通过递归对所有从"根"对象(堆栈中的对象,静态数据成员,JNI句柄等等)继承下来的引用进行工作,然后标记所有可以访问的活动着的对象.而这些对象变成了程序唯一能够操纵的对象,其他的对象都被释放了.因为GC使得程序不能够访问那些被释放的对象,所以这样做是安全的.
内存管理可以说是自动的,但是这并没有让程序员脱离内存管理问题.比方说,对于内存的分配(还有释放)总是存在一定的开销,尽管这些开销对程序员来说是隐含的.一个程序如果创建了很多对象,那么它就要比完成相同任务而创建了较少对象的程序执行的速度慢(如果其他的条件都相同).
文章更多想说的,导致内存泄漏主要的原因是,先前申请了内存空间而忘记了释放.如果程序中存在对无用对象的引用,那么这些对象就会驻留内存,消耗内存,因为无法让垃圾回收器验证这些对象是否不再需要.正如我们前面看到的,如果存在对象的引用,这个对象就被定义为"活动的",同时不会被释放.要确定对象所占内存将被回收,程序员就要务必确认该对象不再会被使用.典型的做法就是把对象数据成员设为null或者从集合中移除该对象.注意,当局部变量不需要时,不需明显的设为null,因为一个方法执行完毕时,这些引用会自动被清理.
从更高一个层次看,这就是所有存在内存管的语言对内存泄漏所考虑的事情,剩余的对象引用将不再会被使用.
典型的泄漏
既然我们知道了在java中确实会存在内存泄漏,那么就让我们看一些典型的泄漏,并找出他们发生的原因.
全局集合
在大型应用程序中存在各种各样的全局数据仓库是很普遍的,比如一个JNDI-tree或者一个session table.在这些情况下,注意力就被放在了管理数据仓库的大小上.当然是有一些适当的机制可以将仓库中的无用数据移除.
可以有很多不同的解决形式,其中最常用的是一种周期运行的清除作业.这个作业会验证仓库中的数据然后清除一切不需要的数据.
另一个办法是计算引用的数量.集合负责跟踪集合中每个元素的引用者数量.这要求引用者通知集合什么时候已经对元素处理完毕.当引用者的数目为零时,就可以移除集合中的相关元素.
高速缓存
高速缓存是一种用来快速查找已经执行过的操作结果的数据结构.因此,如果一个操作执行很慢的话,你可以先把普通输入的数据放入高速缓存,然后过些时间再调用高速缓存中的数据.
高速缓存多少还有一点动态实现的意思,当数据操作完毕,又被送入高速缓存.一个典型的算法如下所示:
1.检查结果是否在高速缓存中,存在则返回结果;
2.如果结果不在,那么计算结果;
3.将结果放入高速缓存,以备将来的操作调用.
这个算法的问题(或者说潜在的内存泄漏)在最后一步.如果操作是分别多次输入,那么存入高速缓存的内容将会非常大.很明显这个方法不可取.
为了避免这种潜在的致命错误设计,程序就必须确定高速缓存在他所使用的内存中有一个上界.因此,更好的算法是:
1.检查结果是否在高速缓存中,存在则返回结果;
2.如果结果不在,那么计算结果;
3.如果高速缓存所占空间过大,移除缓存中旧的结果;
4.将结果放入高速缓存,以备将来的操作调用.
通过不断的从缓存中移除旧的结果,我们可以假设,将来,最新输入的数据可能被重用的几率要远远大于旧的结果.这通常是一个不错的设想.
这个新的算法会确保高速缓存的容量在预先确定的范围内.精确的范围是很难计算的,因为缓存中的对象存在引用时将继续有效.正确的划分高速缓存的大小是一个复杂的任务,你必须权衡可使用内存大小和数据快速存取之间的矛盾.
另一个解决这个问题的途径是使用java.lang.ref.SoftReference类来将对象放入高速缓存.这个方法可以保证当虚拟机用完内存或者需要更多堆的时候,可以释放这些对象的引用.
类装载器
Java类装载器创建就存在很多导致内存泄漏的漏洞.由于类装载器的复杂结构,使得很难得到内存泄漏的透视图.这些困难不仅仅是由于类装载器只与"普通的"对象引用有关,同时也和对象内部的引用有关,比如数据变量,方法和各种类.这意味着只要存在对数据变量,方法,各种类和对象的类装载器,那么类装载器将驻留在JVM中.既然类装载器可以同很多的类关联,同时也可以和静态数据变量关联,那么相当多的内存就可能发生泄漏.
定位内存泄漏
常常地,程序内存泄漏的最初迹象发生在出错之后,在你的程序中得到一个 OutOfMemoryError.这种典型的情况发生在产品环境中,而在那里,你希望内存泄漏尽可能的少,调试的可能性也达到最小.也许你的测试环境和产品的系统环境不尽相同,导致泄露的只会在产品中暴露.这种情况下,你需要一个低负荷的工具来监听和寻找内存泄漏.同时,你还需要把这个工具同你的系统联系起来,而不需要重新启动他或者机械化你的代码.也许更重要的是,当你做分析的时候,你需要能够同工具分离而使得系统不会受到干扰.
一个OutOfMemoryError常常是内存泄漏的一个标志,有可能应用程序的确用了太多的内存;这个时候,你既不能增加JVM的堆的数量,也不能改变你的程序而使得他减少内存使用.但是,在大多数情况下,一个OutOfMemoryError是内存泄漏的标志.一个解决办法就是继续监听GC的活动,看看随时间的流逝,内存使用量是否会增加,如果有,程序中一定存在内存泄漏.
详细输出
有很多办法来监听垃圾回收器的活动.也许运用最广泛的就是以:-Xverbose:gc选项运行JVM,然后观察输出结果一段时间.
[memory] 10.109-10.235: GC 65536K->16788K (65536K), 126.000 ms
箭头后的值(在这个例子中 16788K)是垃圾回收后堆的使用量.
控制台
观察这些无尽的GC详细统计输出是一件非常单调乏味的事情.好在有一些工具来代替我们做这些事情.The JRockit Management Console可以用图形的方式输出堆的使用量.通过观察图像,我们可以很方便的观察堆的使用量是否伴随时间增长.
管理控制台甚至可以配置成在堆使用量出现问题(或者其他的事件发生)时向你发送邮件.这个显然使得监控内存泄漏更加容易.
内存泄漏探测工具
有很多专门的内存泄漏探测工具.其中The JRockit Memory Leak Detector可以供来观察内存泄漏也可以针对性地找到泄漏的原因.这个强大的工具被紧密地集成在JRockit JVM中,可以提供最低可能的内存事务也可以轻松的访问虚拟机的堆.
专门工具的优势
一旦你知道程序中存在内存泄漏,你需要更专业的工具来查明为什么这里会有泄漏.而JVM是不可能告诉你的.现在有很多工具可以利用了.这些工具本质上主要通过两种方法来得到JVM的存储系统信息的:JVMTI和字节码仪器.Java虚拟机工具接口(JVMTI)和他的原有形式JVMPI(压型接口,profiling Interface)都是标准接口,作为外部工具同JVM进行通信,搜集JVM的信息.字节码仪器则是引用通过探针获得工具所需的字节信息的预处理技术.
通过这些技术来侦测内存泄漏存在两个缺点,而这使得他们在产品级环境中的运用不够理想.首先,根据两者对内存的使用量和内存事务性能的降级是不可以忽略的.从JVM获得的堆的使用量信息需要在工具中导出,收集和处理.这意味着要分配内存.按照JVM的性能导出信息是需要开销的,垃圾回收器在搜集信息的时候是运行的非常缓慢的.另一个缺点就是,这些工具所需要的信息是关系到JVM的.让工具在JVM开始运行的时候和它关联,而在分析的时候,分离工具而保持 JVM运行,这显然是不可能的.
既然JRockit Memory Leak Detector是被集成到JVM中的,那么以上两种缺点就不再存在.首先,大部分的处理和分析都是在JVM中完成的,所以就不再需要传送或重建任何数据.处理也可以建立在垃圾回收器的基础上,即提高速度.再有,内存泄漏侦测器可以同一个运行的JVM关联和分离,只要JVM在开始的时候伴随着 –Xmanagement选项(通过远程JMX接口允许监听和管理JVM).当工具分离以后,工具不会遗留任何东西在JVM中;JVM就可以全速运行代码就好像工具关联之前一样.
趋势分析
让我们更深一步来观察这个工具,了解他如何捕捉到内存泄漏.在你了解到代码中存在内存泄漏,第一步就是尝试计算出什么数据在泄漏——哪个对象类导致泄露.The JRockit Memory Leak Detector通过在垃圾回收的时候,计算每个类所包含的现有的对象来达到目的.如果某一个类的对象成员数目随着时间增长(增长率),那么这里很可能存在泄漏
因为一个泄漏很可能只是像水滴一样小,所以趋势分析必须运行足够长的一段时间.在每个短暂的时间段里,局部类的增加会使得泄漏发生推迟.但是,内存事务是非常小的(最大的内存事务是由在每个垃圾回收时从JRockit向内存泄漏探测器发送的一个数据包组成的).内存事务不应该成为任何系统的问题——甚至一个在产品阶段全速运行的程序.
一开始,数字会有很大的跳转,随时间的推进,这些数字会变得稳定,而后显示哪些类会不断的增大.
寻找根本原因
知道那些对象的类会导致泄露,有时候足够制止泄露问题.这个类也许只是被用在非常有限的部分,通过快速的视察就可以找到问题所在.不幸的是,这些信息是不够的.比方说,经常导致内存泄漏的对象类java.lang.String,然而 String类被应用于整个程序,这就变得有些无助.
我们想知道的是其他的对象是否会导致内存泄漏,好比上面提到的String类,为什么这些导致泄漏的对象还是在周围存在?哪些引用是指向这些对象的?如果列出所有引用String的对象,工作就会变得太大而没有实际意义.为了限制数据的数量,我们可以通过类把他们编成一个组,这样我们就可以看到,那些其他类的对象会依然泄漏对象(String类).比如,将一个String类放入 Hashtable,那里我们可以看到关联到String类的Hashtable入口.从Hashtable入口向后运行,我们终于找到那些关联到 String类的Hashtable对象.
向后工作
自从开始我们就一直着眼于对象类,而不是单独的对象,我们不知道那个Hashtable存在泄漏.如果我们可以找出所有的Hashtable在系统中有多大,我们可以假设最大的那个Hashtable存在泄漏(因为它可以聚集足够的泄漏而变得很大).因此,所有Hashtable,同时有和所有他们所涉及的数据,可以帮助我们查明导致泄露的精确的Hashtable.
计算一个对象所涉及的数据的开销是非常大的(这要求引用图表伴随着那个对象作为根运行)而且如果对每一个对象都这样处理,就需要很多时间.知道一些关于 Hashtable内部的实现机制可以带来捷径.在内部,一个Hashtable有一个Hashtable的数组入口.数组的增长伴随着 Hashtable中对象的增长.因此,要找到最大的Hashtable,我们可以把搜索限制在寻找包含Hashtable引用入口的最大的数组.这样就更快捷了.
向下深入
当我们发现了存在泄漏的Hashtable的实例,就可以顺藤摸瓜找到其他的引用这些Hashtable的实例,然后用上面的方法来找到是那个Hashtable存在问题.
举个例子,一个Hashtable可以有一个来自MyServer的对象的引用,而MyServer包含一个activeSessions数据成员.这些信息就足够深入代码找出问题所在.
找出分配点
当发现了内存泄漏问题,找到那些泄漏的对象在何处是非常有用的.也许没有足够的信息知道他们同其他相关对象之间的联系,但是关于他们在那里被创建的信息还是很有帮助的.当然,你不会愿意创建一个工具来打印出所有分配的堆栈路径.你也不会愿意在模拟环境中运行程序只是为了捕捉到一个内存泄漏.
有了JRockit Memory Leak Detector,程序代码可以动态的在内存分配出创建堆栈路径.这些堆栈路径可以在工具中累积,分析.如果你不启用这个工具,这个特征就不会有任何消耗,这就意味着时刻准备着开始.当需要分配路径时,JRockit的编译器可以让代码不工作,而监视内存分配,但只对需要的特定类有效.更好的是,当做完数据分析后,生成的机械代码会完全被移除,不会引起任何执行上的效率衰退.
总结
内存泄漏查找起来非常困难,文章中的一些避免泄漏的好的实践,包括了要时刻记住把什么放进了数据结构中,更接近的监视内存中意外的增长.
我们同时也看到了JRockit Memory Leak Detector是如何捕捉产品级系统中的内存泄漏的.该工具通过三步的方法发现泄漏.一,通过趋势分析发现那些对象类存在泄漏;二,找出同泄漏对象相关的其他类;三,向下发掘,观察独立的对象之间是如何相互联系的.同时,该工具也可以动态的,找出所有内存分配的堆栈路径.利用这三个特性,将该工具紧紧地集成在JVM中,那么就可以安全的,有效的捕捉和修复内存泄漏了.
资源
JRockit Tools Download
BEA JRockit 5.0 Documentation
New Features and Tools in JRockit 5.0
BEA JRockit DevCenter
Staffan Larsen是JRockit项目的工程师之一,这个项目是在1998年底他与别人联合创建的.
在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象.如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存.
在C++中,内存泄漏的范围更大一些.有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来.在Java中,这些不可达的对象都由GC负责回收,因此不需要考虑这部分的内存泄露.
注意事项:
1.最基本的建议是尽早释放无用对象的引用.如:
A a = new A();//应用a对象
a = null; //当使用对象a之后主动将其设置为空
注:如果a 是方法的返回值,不要做这样的处理,否则你从该方法中得到的返回值永远为空,而且这种错误不易被发现,排除
2.尽量少用finalize函数.它会加大GC的工作量.
3.如果需要使用经常用到的图片,可以使用soft应用类型.它尽可能把图片保存在内存中
4.注意集合数据类型,包括数组,树,图,链表等数据结构,这些数据结构对GC来说,回收更为复杂.
5.尽量避免在类的默认构造器中创建,初始化大量的对象,防止在调用其自类的构造器时造成不必要的内存资源浪费
6.尽量避免强制系统做垃圾内存的回收,增长系统做垃圾回收的最终时间
7.尽量避免显式申请数组空间
8.尽量做远程方法调用类应用开发时使用瞬间值变量,除非远程调用端需要获取该瞬间值变量的值.
9.尽量在合适的场景下使用对象池技术以提高系统性能.
长生命周期的对象持有短生命周期对象的引用就很可能发生内存泄露,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是java中内存泄露的发生场景.作者在书中提到了3个场景:
1.流失监听器问题,在awt,swing编程中,给组件添加了事件监听器,这些组件的生命周期如果很长的话,监听器对象将不能被正确回收.关于GUI编程我不是很熟悉,这一点存有疑问,因为显然你触发一个按钮的事件,当然是一直期待同样的行为发生,如果删除了监听器或者使用弱引用让JVM回收不符合业务逻辑和用户体验.
2.集合类,集合类仅仅有添加元素的方法,而没有相应的删除机制,导致内存被占用.这一点其实也不明确,这个集合类如果仅仅是局部变量,根本不会造成内存泄露,在方法栈退出后就没有引用了会被jvm正常回收.而如果这个集合类是全局性的变量(比如类中的静态属性,全局性的map等),那么没有相应的删除机制,很可能导致集合所占用的内存只增不减,因此提供这样的删除机制或者定期清除策略非常必要.
3.单例模式.不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在JVM的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被jvm正常回收,导致内存泄露,考虑下面的例子:
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是个比较大的对象或者集合类型会发生什么情况.
上面所讲的这些也启发我们如何去查找内存泄露问题,第一选择当然是利用工具,比如jprofiler,第二就是在代码复审的时候关注长生命周期对象:全局性的集合,单例模式的使用,类的static变量等等.