Java程序中的“内存泄漏”问题
摘 要:由Java的内存管理机制谈起,分析了Java程序中的内存泄漏问题的原因,列举了典型的内存泄漏问题并给出了一些解决方法,最后讨论了如何找出程序中内存泄漏的问题。
关键字:Java内存泄漏;Java垃圾回收器;弱引用
附 件:无
大多数程序员都知道,使用Java编程语言的一大好处就是,不必再担心内存的分配和释放问题。您只须创建对象,当应用程序不再需要这些对象时,Java 会通过一种称为“垃圾回收”的机制将这些对象的内存释放掉。他们认为Java不存在内存泄漏问题,或者认为即使有内存泄漏也不是程序的责任,而是垃圾回收器(GC)或Java虚拟机(JVM)的问题。但事实真的是这样吗?Java真的已经解决了困扰其他编程语言的内存泄露问题了吗?
一、Java的内存管理机制
在进一步讨论之前,我们先了解一下Java的内存管理机制。Java的内存管理就是对象的分配和释放问题。在Java中,程序员需要通过关键字new为每个对象申请内存空间 (基本类型除外),所有的对象都在堆 (Heap)中分配空间。在Java中,内存的分配是由程序完成的,而内存的释放是则是由垃圾回收器决定和执行的,这种收支两条线的方法确实简化了程序员的工作。但同时,它也加重了JVM的负担,这也是Java程序运行速度较慢的原因之一。因为,垃圾回收器为了能够正确回收对象,必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等。监视对象状态是为了准确、及时地释放对象,而释放对象的基本原则就是该对象是否仍被引用。
垃圾收集器的工作是发现应用程序不再需要的对象,并在这些对象不再被访问或引用时将它们删除。垃圾收集器从根节点(在 Java 应用程序的整个生存周期内始终存在的那些类)开始,遍历所有仍被引用的节点,进行垃圾回收。任何对象只要不再被引用,就符合垃圾回收的条件。垃圾回收器回收这些对象后,它们所占用的内存资源也就被返回给了Java虚拟机。
Java使用有向图的方式进行内存管理,可以消除循环引用的问题,例如有三个对象,相互引用,只要它们和根线程不可达,那么垃圾回收器也是可以回收它们的。这种方式的优点是管理内存的精度很高,但是效率较低。另外一种常用的内存管理技术是使用计数器,例如COM模型采用计数器方式管理构件,它与有向图相比,精度较低(很难处理循环引用的问题),但执行效率却很高。
为了更好理解地垃圾回收器的工作原理,我们可以将对象考虑为有向图的顶点,将引用关系考虑为图的有向边,有向边从引用对象指向被引对象。每个线程可以作为一个图的起始顶点,例如大多程序从main线程开始执行,那么该图就是以main线程为顶点的一个有向图。在这个有向图中,根顶点可达的对象都是有效对象,如果某个对象不可达,那么垃圾回收器会认为这个对象不再被引用,可以被回收。对于程序的每一个时刻,我们都有一个有向图表示JVM的内存分配情况。
二、什么是Java中的内存泄露
在C++ 程序中,内存泄漏是指应用程序为某些对象被分配了内存空间,然后却因为某些原因不可达,以至于被这些对象使用的内存无法被释放并返还给操作系统,这些内存将永远收不回来。
令人欣慰的是,这种内存泄露问题在Java程序中并不存在。在Java中,对象使用的内存都由垃圾回收器负责回收的,而Java虚拟机并不存在任何被证实的内存泄漏问题。实践证明,垃圾收集器一般能够精确地判断哪些对象可被收集,回收它们占用的内存空间并返还给Java 虚拟机。
对于Java来说,内存泄漏是指在程序中存在一些实际上并不需要的对象引用。这些对象有下面两个特点,首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象。一个典型的例子是向一个集合中加入一些对象以便以后使用它们,但是却没有在使用完后从集合中删除对这些对象的引用。因为集合可以无限制地扩大,并且从来不会变小,所以当向集合中加入了太多的对象(或者是有很多的对象被集合中的元素所引用)时,就会因为堆空间被填满而导致内存耗尽。垃圾收集器并不会把这些您认为已经用完的对象当作垃圾进行回收,因为对于垃圾收集器来说,应用程序仍然可以通过这个集合在任何时候访问这些对象。
通过以上分析,可以知道在Java中也有内存泄漏,但范围比C++要小一些。因为Java从语言上保证,任何对象都是可达的,都由垃圾回收器进行内存的回收管理。
随着越来越多的服务器程序、嵌入式系统及游戏平台采用Java技术,出现了较多内存有限、需要长期运行Java应用。内存泄露问题也就变得十分关键,即使每次少量内存泄漏,长期运行之后,系统也有面临内存溢出的危险。
三、典型的内存泄漏问题及解决方法
我们知道了在Java中确实会存在内存泄漏,那么就让我们看一看几种典型的泄漏,并试图找出他们的解决方法。
3.1 全局集合
在大型应用程序中存在各种各样的全局数据储存库是很普遍的,比如一个Session Table。在这些情况下,必须注意管理储存库的大小。必须使用某种机制从储存库中移除不再需要的数据。
通常有很多不同的解决形式,其中最常用的一种是周期运行的清除作业。这个作业会验证仓库中的数据然后清除一切不需要的数据。
另一种管理储存库的方法是使用反向链接(Referrer)计数。然后集合负责统计集合中每个元素反向链接的数目,当反向链接数目为零时,该元素就可以从集合中移除了。
3.2 缓存
缓存一种用来快速查找已经执行过的操作结果的数据结构。因此,如果一个操作执行需要比较多的资源并会多次被使用,通常做法是把常用的输入数据的操作结果进行缓存,以便在下次调用该操作时使用缓存中的数据。缓存通常都是以动态方式实现的,如果缓存设置不正确而大量使用缓存的话,则会出现内存溢出的后果,因此需要将所使用的内存容量与检索数据的速度加以平衡。
常用的解决途径是使用软引用或弱引用类将对象放入缓存。这个方法可以保证当虚拟机用完内存或者需要更多堆的时候,可以释放这些对象的引用。
3.3 类装载器
Java类装载器的使用为内存泄漏提供了许多可乘之机。一般来说类装载器都具有复杂结构,因为类装载器不仅仅是只与“常规”的对象引用有关,同时也和对象内部的引用有关。比如数据变量,方法和各种类。这意味着只要存在对数据变量,方法,各种类和对象的引用,那么类装载器将驻留在Java虚拟机中。既然类装载器可以同很多的类关联,同时也可能和静态数据变量关联,那么相当多的内存就可能发生泄漏。
3.4 物理连接
一些物理连接,比如数据库连接和网络连接,除非其显式的关闭了连接,否则是不会自动被GC 回收的。Java数据库连接一般用DataSource.getConnection()来创建,当不再使用时必须用Close()方法来释放。对于ResultSet 和Statement 对象可以不进行显式回收,但Connection 一定要显式回收,,因为这些连接是独立于Java虚拟机的,在任何时候都无法自动回收,而Connection一旦回收,ResultSet 和Statement 对象就会立即变为NULL。但是如果使用连接池,情况就不一样了,除了要显式地关闭连接,还必须显式地关闭ResultSet和Statement 对象(关闭其中一个,另外一个也会关闭),否则就会造成大量的Statement 对象无法释放,从而引起内存泄漏。
3.5 内部模块和外部模块等的引用
内部类的引用是比较容易遗忘的一种,而且一旦没释放可能导致一系列的后继类对象没有释放。对于程序员而言,自己的程序很清楚,如果发现内存泄漏,自己对这些对象的引用可以很快定位并解决。但是在大型应用软件的开发中,整个系统并非一个人实现,个人担当的可能只是系统的某一机能或某机能的一个模块。所以程序员要小心外部模块不经意的引用,例如程序员A 负责A 模块,调用了B模块的一个方法如:public void registerMsg(Object b); 这种调用就要非常小心了,传入了一个对象,很可能模块B就保持了对该对象的引用,这时候就需要注意模块B是否需要提供相应的去除引用的操作。
四、如何找出内存泄漏
查找内存泄漏一般有两种方法:一是安排有经验的编程人员对代码进行走查和分析,找出内存泄漏发生的位置;二是使用专门的内存泄漏测试工具进行测试。
第一种方法,在代码走查工作中,可以安排对系统业务和开发语言较熟悉的开发人员对应用的代码进行了交叉走查,尽量找出代码中存在的数据库连接声明和结果集未关闭、代码冗余等问题代码。
第二种方法就是使用专门的内存泄漏工具进行测试。市场上已有专业检查Java内存泄漏的工具,它们的基本工作原理大同小异,都是通过监测Java程序运行时,所有对象的申请、释放等动作,将内存管理的所有信息进行统计、分析、可视化。开发人员将根据这些信息判断程序是否有内存泄漏问题。常用的工具有Optimizeit Profiler,JProbe Profiler,JinSight以及Rational公司的Purify等。