Java的一个重要优点就是通过垃圾收集器(Garbage Collection,GC)自动管理内存的回收,程序员不需要通过调用函数来释放内存。因此,很多程序员认为Java不存在内存泄漏问题,或者认为即使有内存泄漏也不是程序的责任,而是GC或JVM的问题。其实,这种想法是不正确的,因为Java也存在内存泄露,但它的表现与C++不同。
随着越来越多的服务器程序采用Java技术,例如JSP,Servlet, EJB等,服务器程序往往长期运行。另外,在很多嵌入式系统中,内存的总量非常有限。内存泄露问题也就变得十分关键,即使每次运行少量泄漏,长期运行之后,系统也是面临崩溃的危险。
为了判断Java中是否有内存泄露,我们首先必须了解Java是如何管理内存的。Java的内存管理就是对象的分配和释放问题。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。另外,对象的释放是由GC决定和执行的。在Java中,内存的分配是由程序完成的,而内存的释放是有GC完成的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的工作。这也是Java程序运行速度较慢的原因之一。因为,GC为了能够正确释放对象,GC必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。
监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。
为了更好理解GC的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用者指向被引对象。另外,每个线程对象可以作为一个图的起始顶点,例如大多程序从main进程开始执行,那么该图就是以main进程顶点开始的一棵根树。在这个有向图中,根顶点可达的对象都是有效对象,GC将不回收这些对象。如果某个对象 (连通子图)与这个根顶点不可达(注意,该图为有向图),那么我们认为这个(这些)对象不再被引用,可以被GC回收。
以下,我们举一个例子说明如何用有向图表示内存管理。对于程序的每一个时刻,我们都有一个有向图表示JVM的内存分配情况。以下右图,就是左边程序运行到第6行的示意图。
![图1](https://i-blog.csdnimg.cn/blog_migrate/f95376c97257ad5eb6d1a72b9f5e9fd7.png)
Java使用有向图的方式进行内存管理,可以消除引用循环的问题,例如有三个对象,相互引用,只要它们和根进程不可达的,那么GC也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度行低(很难处理循环引用的问题),但执行效率很高。
下面,我们就可以描述什么是内存泄漏。在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。如果对象满足这两个条件,这些对象就可以判定为Java中的内存泄漏,这些对象不会被GC所回收,然而它却占用内存。
在C++中,内存泄漏的范围更大一些。有些对象被分配了内存空间,然后却不可达,由于C++中没有GC,这些内存将永远收不回来。在Java中,这些不可达的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。
通过分析,我们得知,对于C++,程序员需要自己管理边和顶点,而对于Java程序员只需要管理边就可以了(不需要管理顶点的释放)。通过这种方式,Java提高了编程的效率。
![图2](https://i-blog.csdnimg.cn/blog_migrate/c5b7abd9d34de7e259e436d64d1c545f.png)
因此,通过以上分析,我们知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,所有的不可达对象都由GC管理。
对于程序员来说,GC基本是透明的,不可见的。虽然,我们只有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义, 该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。
下面给出了一个简单的内存泄露的例子。在这个例子中,我们循环申请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引用这些对象。
最后一个重要的问题,就是如何检测Java的内存泄漏。目前,我们通常使用一些工具来检查Java程序的内存泄漏问题。市场上已有几种专业检查Java内存泄漏的工具,它们的基本工作原理大同小异,都是通过监测Java程序运行时,所有对象的申请、释放等动作,将内存管理的所有信息进行统计、分析、可视化。开发人员将根据这些信息判断程序是否有内存泄漏问题。这些工具包括Optimizeit Profiler,JProbe Profiler,JinSight , Rational 公司的Purify等。
下面,我们将简单介绍Optimizeit的基本功能和工作原理。
Optimizeit Profiler版本4.11支持Application,Applet,Servlet和Romote Application四类应用,并且可以支持大多数类型的JVM,包括SUN JDK系列,IBM的JDK系列,和Jbuilder的JVM等。并且,该软件是由Java编写,因此它支持多种操作系统。Optimizeit系列还包括Thread Debugger和Code Coverage两个工具,分别用于监测运行时的线程状态和代码覆盖面。
当设置好所有的参数了,我们就可以在OptimizeIt环境下运行被测程序,在程序运行过程中,Optimizeit可以监视内存的使用曲线(如下图),包括JVM申请的堆(heap)的大小,和实际使用的内存大小。另外,在运行过程中,我们可以随时暂停程序的运行,甚至强行调用GC,让GC进行内存回收。通过内存使用曲线,我们可以整体了解程序使用内存的情况。这种监测对于长期运行的应用程序非常有必要,也很容易发现内存泄露。
![图3](https://i-blog.csdnimg.cn/blog_migrate/ddb73ad5cd25685090c6f02065308937.png)
在运行过程中,我们还可以从不同视角观查内存的使用情况,Optimizeit提供了四种方式:
- 堆视角。 这是一个全面的视角,我们可以了解堆中的所有的对象信息(数量和种类),并进行统计、排序,过滤。了解相关对象的变化情况。
- 方法视角。通过方法视角,我们可以得知每一种类的对象,都分配在哪些方法中,以及它们的数量。
- 对象视角。给定一个对象,通过对象视角,我们可以显示它的所有出引用和入引用对象,我们可以了解这个对象的所有引用关系。
- 引用图。 给定一个根,通过引用图,我们可以显示从该顶点出发的所有出引用。
在运行过程中,我们可以随时观察内存的使用情况,通过这种方式,我们可以很快找到那些长期不被释放,并且不再使用的对象。我们通过检查这些对象的生存周期,确认其是否为内存泄露。在实践当中,寻找内存泄露是一件非常麻烦的事情,它需要程序员对整个程序的代码比较清楚,并且需要丰富的调试经验,但是这个过程对于很多关键的Java程序都是十分重要的。
综上所述,Java也存在内存泄露问题,其原因主要是一些对象虽然不再被使用,但它们仍然被引用。为了解决这些问题,我们可以通过软件工具来检查内存泄露,检查的主要原理就是暴露出所有堆中的对象,让程序员寻找那些无用但仍被引用的对象。
Java系统中内存泄漏测试方法的研究
问题的提出
笔者曾经参与开发的网管系统,系统规模庞大,涉及上百万行代码。系统主要采用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所示。
![](http://dev.yesky.com/imagelist/06/38/82l19y2gmb61.jpg)
图1 初始状态
![](http://dev.yesky.com/imagelist/06/38/3338k33cia67.jpg)
图2 TR1-A链和TR2-D链断开,A、B、C、D掉入回收池
![](http://dev.yesky.com/imagelist/06/38/76mk48n3sdnh.jpg)
图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语言开发的、对稳定性有比较高要求的软件系统。
(转自)
http://blog.csdn.net/elimago/archive/2007/12/18/1946380.aspx
来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/540962/viewspace-659820/,如需转载,请注明出处,否则将追究法律责任。
转载于:http://blog.itpub.net/540962/viewspace-659820/