摘 要 稳定性是衡量软件系统质量的重要指标,泄漏是破坏系统稳定性的重要因素。由于采用垃圾回收机制,Java语言的内存泄漏的模式与C++等语言相比有很大的不同。全文通过与C++中的内存泄漏问题进行对比,讲述了Java内存泄漏的基本原理,以及如何借助Optimizeit profiler工具来测试内存泄漏和分析内存泄漏的原因,在实践中证明这是一套行之有效的方法。
关键词 Java; 内存泄漏; GC(垃圾收集器) 引用; Optimizeit
问题的提出 笔者曾经参与开发的网管系统,系统规模庞大,涉及上百万行代码。系统主要采用Java语言开发,大体上分为客户端、服务器和数据库三个层次。在版本进入测试和试用的过程中,现场人员和测试部人员纷纷反映:系统的稳定性比较差,经常会出现服务器端运行一昼夜就死机的现象,客户端跑死的现象也比较频繁地发生。对于网管系统来讲,经常性的服务器死机是个比较严重的问题,因为频繁的死机不仅可能导致前后台数据不一致,发生错误,更会引起用户的不满,降低客户的信任度。因此,服务器端的稳定性问题必须尽快解决。
解决思路 通过察看服务器端日志,发现死机前服务器端频繁抛出OutOfMemoryException内存溢出错误,因此初步把死机的原因定位为内存泄漏引起内存不足,进而引起内存溢出错误。如何查找引起内存泄漏的原因呢?有两种思路:第一种,安排有经验的编程人员对代码进行走查和分析,找出内存泄漏发生的位置;第二种,使用专门的内存泄漏测试工具Optimizeit进行测试。这两种方法都是解决系统稳定性问题的有效手段,使用内存测试工具对于已经暴露出来的内存泄漏问题的定位和解决非常有效;但是软件测试的理论也告诉我们,系统中永远存在一些没有暴露出来的问题,而且,系统的稳定性问题也不仅仅只是内存泄漏的问题,代码走查是提高系统的整体代码质量乃至解决潜在问题的有效手段。基于这样的考虑,我们的内存稳定性工作决定采用代码走查结合测试工具的使用,双管齐下,争取比较彻底地解决系统的稳定性问题。
在代码走查的工作中,安排了对系统业务和开发语言工具比较熟悉的开发人员对应用的代码进行了交叉走查,找出代码中存在的数据库连接声明和结果集未关闭、代码冗余和低效等故障若干,取得了良好的效果,文中主要讲述结合工具的使用对已经出现的内存泄漏问题的定位方法。
内存泄漏的基本原理 在C++语言
程序中,使用new操作符创建的对象,在使用完毕后应该通过delete操作符显示地释放,否则,这些对象将占用堆空间,永远没有办法得到回收,从而引起内存空间的泄漏。如下的简单代码就可以引起内存的泄漏:
void function(){ Int[] vec = new int[5]; } |
在function()方法执行完毕后,vec数组已经是不可达对象,在C++语言中,这样的对象永远也得不到释放,称这种现象为内存泄漏。
而Java是通过垃圾收集器(Garbage Collection,GC)自动管理内存的回收,程序员不需要通过调用函数来释放内存,但它只能回收无用并且不再被其它对象引用的那些对象所占用的空间。在下面的代码中,循环申请Object对象,并将所申请的对象放入一个Vector中,如果仅仅释放对象本身,但是因为Vector仍然引用该对象,所以这个对象对GC来说是不可回收的。因此,如果对象加入到Vector后,还必须从Vector中删除,最简单的方法就是将Vector对象设置为null。
Vector v = new Vector(10); for (int i = 1; i < 100; i++) { Object o = new Object(); v.add(o); o = null; }//此时,所有的Object对象都没有被释放,因为变量v引用这些对象。 |
实际上无用,而还被引用的对象,GC就无能为力了(事实上GC认为它还有用),这一点是导致内存泄漏最重要的原因。
Java的内存回收机制可以形象地理解为在堆空间中引入了重力场,已经加载的类的静态变量和处于活动线程的堆栈空间的变量是这个空间的牵引对象。这里牵引对象是指按照Java语言规范,即便没有其它对象保持对它的引用也不能够被回收的对象,即Java内存空间中的本原对象。当然类可能被去加载,活动线程的堆栈也是不断变化的,牵引对象的集合也是不断变化的。对于堆空间中的任何一个对象,如果存在一条或者多条从某个或者某几个牵引对象到该对象的引用链,则就是可达对象,可以形象地理解为从牵引对象伸出的引用链将其拉住,避免掉到回收池中;而其它的不可达对象由于不存在牵引对象的拉力,在重力的作用下将掉入回收池。在图1中,A、B、C、D、E、F六个对象都被牵引对象所直接或者间接地“牵引”,使得它们避免在重力的作用下掉入回收池。如果TR1-A链和TR2-D链断开,则A、B、C三个对象由于失去牵引,在重力的作用下掉入回收池(被回收),D对象也是同样的原因掉入回收池,而F对象仍然存在一个牵引链(TR3-E-F),所以不会被回收,如图2、3所示。
图1 初始状态
图2 TR1-A链和TR2-D链断开,A、B、C、D掉入回收池
图3 A、B、C、D四个对象被回收
通过前面的介绍可以看到,由于采用了垃圾回收机制,任何不可达对象都可以由垃圾收集线程回收。因此通常说的Java内存泄漏其实是指无意识的、非故意的对象引用,或者无意识的对象保持。无意识的对象引用是指代码的开发人员本来已经对对象使用完毕,却因为编码的错误而意外地保存了对该对象的引用(这个引用的存在并不是编码人员的主观意愿),从而使得该对象一直无法被垃圾回收器回收掉,这种本来以为可以释放掉的却最终未能被释放的空间可以认为是被“泄漏了”。
这里通过一个例子来演示Java的内存泄漏。假设有一个日志类Logger,其提供一个静态的log(String msg)方法,任何其它类都可以调用Logger.Log(message)来将message的内容记录到系统的日志文件中。Logger类有一个类型为HashMap的静态变量temp,每次在执行log(message)方法的时候,都首先将message的值丢入temp中(以当前线程+当前时间为键),在方法退出之前再从temp中将以当前线程和当前时间为键的条目删除。注意,这里当前时间是不断变化的,所以log方法在退出之前执行删除条目的操作并不能删除方法执行之初丢入的条目。这样,任何一个作为参数传给log方法的字符串最终由于被Logger的静态变量temp引用,而无法得到回收,这种违背实现者主观意图的无意识的对象保持就是我们所说的Java内存泄漏。
鉴别泄漏对象的方法
一般说来,一个正常的系统在其运行稳定后其内存的占用量是基本稳定的,不应该是无限制的增长的,同样,对任何一个类的对象的使用个数也有一个相对稳定的上限,不应该是持续增长的。根据这样的基本假设,我们可以持续地观察系统运行时使用的内存的大小和各实例的个数,如果内存的大小持续地增长,则说明系统存在内存泄漏,如果某个类的实例的个数持续地增长,则说明这个类的实例可能存在泄漏情况。
Optimizeit是Borland公司的产品,主要用于协助对软件系统进行代码优化和故障诊断,其功能众多,使用方便,其中的OptimizeIt Profiler主要用于内存泄漏的分析。Profiler的堆视图(如图4)就是用来观察系统运行使用的内存大小和各个类的实例分配的个数的,其界面如图四所示,各列自左至右分别为类名称、当前实例个数、自上个标记点开始增长的实例个数、占用的内存空间的大小、自上次标记点开始增长的内存的大小、被释放的实例的个数信息、自上次标记点开始增长的内存的大小被释放的实例的个数信息,表的最后一行是汇总数据,分别表示目前JVM中的对象实例总数、实例增长总数、内存使用总数、内存使用增长总数等。
在实践中,可以分别在系统运行四个小时、八个小时、十二个小时和二十四个小时时间点记录当时的内存状态(即抓取当时的内存快照,是工具提供的功能,这个快照也是供下一步分析使用),找出实例个数增长的前十位的类,记录下这十个类的名称和当前实例的个数。在记录完数据后,点击Profiler中右上角的Mark按钮,将该点的状态作为下一次记录数据时的比较点。
图4 Profiler 堆视图
系统运行二十四小时以后可以得到四个内存快照。对这四个内存快照进行综合分析,如果每一次快照的内存使用都比上一次有增长,可以认定系统存在内存泄漏,找出在四个快照中实例个数都保持增长的类,这些类可以初步被认定为存在泄漏。
分析与定位 通过上面的数据收集和初步分析,可以得出初步结论:系统是否存在内存泄漏和哪些对象存在泄漏(被泄漏),如果结论是存在泄漏,就可以进入分析和定位阶段了。
前面已经谈到Java中的内存泄漏就是无意识的对象保持,简单地讲就是因为编码的错误导致了一条本来不应该存在的引用链的存在(从而导致了被引用的对象无法释放),因此内存泄漏分析的任务就是找出这条多余的引用链,并找到其形成的原因。前面还讲到过牵引对象,包括已经加载的类的静态变量和处于活动线程的堆栈空间的变量。由于活动线程的堆栈空间是迅速变化的,处于堆栈空间内的牵引对象集合是迅速变化的,而作为类的静态变量的牵引对象的集合在系统运行期间是相对稳定的。
对每个被泄漏的实例对象,必然存在一条从某个牵引对象出发到达该对象的引用链。处于堆栈空间的牵引对象在被从栈中弹出后就失去其牵引的能力,变为非牵引对象,因此,在长时间的运行后,被泄露的对象基本上都是被作为类的静态变量的牵引对象牵引。
Profiler的内存视图除了堆视图以外,还包括实例分配视图(图5)和实例引用图(图6)。
Profiler的实例引用图为找出从牵引对象到泄漏对象的引用链提供了非常直接的方法,其界面的第二个栏目中显示的就是从泄漏对象出发的逆向引用链。需要注意的是,当一个类的实例存在泄漏时,并非其所有的实例都是被泄漏的,往往只有一部分是被泄漏对象,其它则是正常使用的对象,要判断哪些是正常的引用链,哪些是不正常的引用链(引起泄漏的引用链)。通过抽取多个实例进行引用图的分析统计以后,可以找出一条或者多条从牵引对象出发的引用链,下面的任务就是找出这条引用链形成的原因。
实例分配图提供的功能是对每个类的实例的分配位置进行统计,查看实例分配的统计结果对于分析引用链的形成具有一定的作用,因为找到分配链与引用链的交点往往就可以找到了引用链形成的原因,下面将具体介绍。
图5 实例分配图
图6 实例引用图
设想一个实例对象a在方法f中被分配,最终被实例对象b所引用,下面来分析从b到a的引用链可能的形成原因。方法f在创建对象a后,对它的使用分为四种情况:1、将a作为返回值返回;2、将a作为参数调用其它方法;3、在方法内部将a的引用传递给其它对象;4、其它情况。其中情况4不会造成由b到a的引用链的生成,不用考虑。下面考虑其它三种情况:对于1、2两种情况,其造成的结果都是在另一个方法内部获得了对象a的引用,它的分析与方法f的分析完全一样(递归分析);考虑第3种情况:1、假设方法f直接将对象a的引用加入到对象b,则对象b到a的引用链就找到了,分析结束;2、假设方法f将对象a的引用加入到对象c,则接下来就需要跟踪对象c的使用,对象c的分析比对象a的分析步骤更多一些,但大体原理都是一样的,就是跟踪对象从创建后被使用的历程,最终找到其被牵引对象引用的原因。
现在将泄漏对象的引用链以及引用链形成的原因找到了,内存泄漏测试与分析的工作就到此结束,接下来的工作就是修改相应的或者实现中的错误了。
总结 使用上述的测试和分析方法,在实践中先后进行了三次测试,找出了好几处内存泄漏错误。系统的稳定性得到很大程度的提高,最初运行1~2天就抛出内存溢出异常,修改完成后,系统从未出现过内存溢出异常。此方法适用于任何使用Java语言开发的、对稳定性有比较高要求的软件系统