内存泄露从入门到精通三部曲之基础知识篇
内存泄露,我们要研究的泄露对象到底是什么?
什么是内存泄露
内存不在GC掌控之内了。
当一个对象已经不需要再使用了,本该被回收时,而有另外一个正在使用的对象持有它的引用从而就导致对象不能被回收。这种导致了本该被回收的对象不能被回收而停留在堆内存中,就产生了内存泄漏
内存泄露(Memory Leak):
进程中某些对象已经没有使用价值了,但是他们却还可以直接或者间接地被引用到GC Root导致无法回收。当内存泄露过多的时候,再加上应用本身占用的内存,日积月累最终就会导致内存溢出OOM.
内存溢出(OOM):
当应用占用的heap资源超过了Dalvik虚拟机分配的内存就会内存溢出。比如:加载大图片。
内存的分配策略
首先我们来了解程序运行时,所需内存的分配策略:
按照编译原理的观点,程序运行时的内存分配有三种策略,分别是静态的,栈式的,和堆式的,对应的,三种存储策略使用的内存空间主要分别是静态存储区(也称方法区)、堆区和栈区。他们的功能不同,对他们使用方式也就不同。
静态存储区(方法区):内存在程序编译的时候就已经分配好,这块内存在程序整个运行期间都存在。它主要存放静态数据、全局static数据和常量。
栈区:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
堆区:亦称动态内存分配。程序在运行的时候用malloc或new申请任意大小的内存,程序员自己负责在适当的时候用free或delete释放内存(Java则依赖垃圾回收器)。动态内存的生存期可以由我们决定,如果我们不释放内存,程序将在最后才释放掉动态内存。 但是,良好的编程习惯是:如果某动态内存不再使用,需要将其释放掉。
接下来我们集中说下堆和栈的区别:
在函数中(说明是局部变量)定义的一些基本类型的变量和对象的引用变量都是在函数的栈内存中分配。当在一段代码块中定义一个变量时,java就在栈中为这个变量分配内存空间,当超过变量的作用域后,java会自动释放掉为该变量分配的内存空间,该内存空间可以立刻被另作他用。
堆内存用于存放所有由new创建的对象(内容包括该对象其中的所有成员变量)和数组。在堆中分配的内存,由java虚拟机自动垃圾回收器来管理。在堆中产生了一个数组或者对象后,还可以在栈中定义一个特殊的变量,这个变量的取值等于数组或者对象在堆内存中的首地址,在栈中的这个特殊的变量就变成了数组或者对象的引用变量,以后就可以在程序中使用栈内存中的引用变量来访问堆中的数组或者对象,引用变量相当于为数组或者对象起的一个别名,或者代号。
堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit系统理论上是4G),所以堆的空间比较灵活,比较大。栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在编译时确定,VC中可设置)。
对于堆,频繁的new/delete会造成大量内存碎片,使程序效率降低。对于栈,它是先进后出的队列,进出一一对应,不产生碎片,运行效率稳定高。
结论
局部变量的基本数据类型和引用存储于栈中,引用的对象实体存储于堆中。 ——因为它们属于方法中的变量,生命周期随方法而结束。
成员变量全部存储与堆中(包括基本数据类型,引用和引用的对象实体) ——因为它们属于类,类对象终究是要被new出来使用的。
回到我们的问题:内存泄露需要关注的是什么?
我们这里说的内存泄露,是针对,也只针对堆内存,他们存放的就是引用指向的对象实体。
内存为什么会泄露
为了判断Java中是否有内存泄露,我们首先必须了解Java是如何管理(堆)内存的。Java的内存管理就是对象的分配和释放问题。在Java中,内存的分配是由程序完成的,而内存的释放是由垃圾收集器(Garbage Collection,GC)完成的,程序员不需要通过调用函数来释放内存,但它只能回收无用并且不再被其它对象引用的那些对象所占用的空间。
Java的内存垃圾回收机制是从程序的主要运行对象(如静态对象/寄存器/栈上指向的堆内存对象等)开始检查引用链,当遍历一遍后得到上述这些无法回收的对象和他们所引用的对象链,组成无法回收的对象集合,而其他孤立对象(集)就作为垃圾回收。GC为了能够正确释放对象,必须监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等,GC都需要进行监控。监视对象状态是为了更加准确地、及时地释放对象,而释放对象的根本原则就是该对象不再被引用。
在Java中,这些无用的对象都由GC负责回收,因此程序员不需要考虑这部分的内存泄露。虽然,我们有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义,该函数不保证JVM的垃圾收集器一定会执行。因为不同的JVM实现者可能使用不同的算法管理GC。通常GC的线程的优先级别较低。JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。
在Java语言中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象。
至此,我们来看看Java中需要被回收的垃圾:
{ Person p1 = new Person(); …… }
引用句柄p1的作用域是从定义到“}”处,执行完这对大括号中的所有代码后,产生的Person对象就会变成垃圾,因为引用这个对象的句柄p1已超过其作用域,p1失效,在栈中被销毁,因此堆上的Person对象不再被任何句柄引用了。 因此person变为垃圾,会被回收。
从上面的例子和解释,可以看到一个很关键的词:引用。
通俗的讲,通过A能调用并访问到B,那就说明A持有B的引用,或A就是B的引用,B的引用计数+1.
(1)比如 Person p1 = new Person();通过P1能操作Person对象,因此P1是Person的引用;
(2)比如类O中有一个成员变量是I类对象,因此我们可以使用o.i的方式来访问I类对象的成员,因此o持有一个i对象的引用。
GC过程与对象的引用类型是严重相关的,我们来看看Java对引用的分类Strong reference, SoftReference, WeakReference, PhatomReference
四种引用类型
强引用:Java里面使用最广泛的一种,也是对象默认的引用类型,它并没有特别的关键字,就是默认的new Object()形式创建的。如果一个对象具有强引用,那么垃圾回收器是不会对它进行回收操作的,当内存空间不足时,Java虚拟机将会抛出OOM错误,这时应用将会停止。
软引用:一个对象如果只有软引用, 那么当内存空间充足时,垃圾回收器不会对它进行回收操作,只有当内存空间不足时,这个对象才会被回收。软引用可以用来实现内存敏感的高速缓存,如果配合引用队列(ReferenceQueue)使用,当软引用指向的对象被垃圾回收器回收后,Java虚拟机将会把这个软引用加入到与之关联的引用队列中。
弱引用:从名字可以看出,弱引用是比软引用更弱的一种引用类型,只是弱引用指向的对象的生命周期更短,当垃圾回收器扫描到只具有弱引用的对象时, 不论当前内存是否充足,都会对弱引用对象进行回收。弱引用也可以和一个引用队列配合使用,当弱引用指向的对象被回收后,Java虚拟机会将这个弱引用加入到与之关联的引用队列中。
虚引用:和软引用虚引用不同, 虚引用并不会对所指向的对象的生命周期产生任何影响,也就是对象还会按照它原来的方式被垃圾回收器回收,虚引用本质上只是一个标记作用,主要用来跟踪对象被垃圾回收的活动,虚引用必须和引用队列配合使用,当对象被垃圾回收时,如果存在虚引用,那么Java虚拟机会将这个虚引用加入到与之相关联的引用队列中。
讲多一步,这里的软引用/弱引用一般是做什么的呢?
在Android应用的开发中,为了防止内存溢出,在处理一些占用内存大而且声明周期较长的对象时候,可以尽量应用软引用和弱引用技术。
软/弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。利用这个队列可以得知被回收的软/弱引用的对象列表,从而为缓冲器清除已失效的软/弱引用。
假设我们的应用会用到大量的默认图片,比如应用中有默认的头像,默认游戏图标等等,这些图片很多地方会用到。如果每次都去读取图片,由于读取文件需要硬件操作,速度较慢,会导致性能较低。所以我们考虑将图片缓存起来,需要的时候直接从内存中读取。但是,由于图片占用内存空间比较大,缓存很多图片需要很多的内存,就可能比较容易发生OutOfMemory异常。这时,我们可以考虑使用软/弱引用技术来避免这个问题发生。以下就是高速缓冲器的雏形:
首先定义一个HashMap,保存软引用对象。
再来定义一个方法,保存Bitmap的软引用到HashMap。
使用软引用以后,在OutOfMemory异常发生之前,这些缓存的图片资源的内存空间可以被释放掉的,从而避免内存达到上限,避免Crash发生。软引用会在第二次GC中被回收,也就是在弱引用被回收过一次后,还是不够分配内存的话才会被回收。
如果只是想避免OutOfMemory异常的发生,则可以使用软引用。如果对于应用的性能更在意,想尽快回收一些占用内存比较大的对象,则可以使用弱引用。弱引用是只要发生GC就会被回收。
另外可以根据对象是否经常使用来判断选择软引用还是弱引用。如果该对象可能会经常使用的,就尽量用软引用。如果该对象不被使用的可能性更大些,就可以用弱引用。
回到我们的问题,为什么内存会泄露?
堆内存中的长生命周期的对象持有短生命周期对象的强/软引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收,这就是Java中内存泄露的根本原因。
相关基础知识
ReferenceQueue
ReferenceQueue顾名思义,就是一个引用队列,其内部通过两个Reference类型的成员变量head和tail来构成一个链表结构.并提供了入队出队的相应方法。在使用过程中:
你可以在构造软引用的时间传入一个队列,当这个软引用被回收的时间,会进入队列(进入逻辑是在Daemons.java中,再具体的调用是在JNI层的),当你知道这个软引用被回收以后,那么你就可以销毁一些数据。软引用和弱引用都可以传引用队列,而虚引用必须和引用队列在一起使用,因为虚引用就是起的这个标识作用。
可以看到他重写了Reference的get方法直接返回null.所以说它并不是为了改变某个对象的生命周期而存在的.它常用于跟踪某些对象的生命周期状态,它只有一个接受ReferenceQueue的构造方法.正是这个ReferenceQueue的神奇功效帮助PhantomReference实现了跟踪对象生命周期的功能。
虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用必须和引用队列(ReferenceQueue)联合使用(在虚引用函数就必须关联指定)。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,自动把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。 基于以上原理,MLD工具在调用接口addObject加入监控类型时,会为该类型对象增加一个虚引用,注意虚引用并不会影响该对象被正常回收。因此可以在ReferenceQueue引用队列中统计未被回收的监控对象是否超过指定阀值。 利用PhantomReferences(虚引用)和ReferenceQueue(引用队列),当PhantomReferences被加入到相关联的ReferenceQueue时,则视该对象已经或处于垃圾回收器回收阶段了。
软/弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。利用这个队列可以得知被回收的软/弱引用的对象列表,从而为缓冲器清除已失效的软/弱引用。
Demons守护线程
Daemons.java中定义了4个守护线程(Android_M之前是5个,其中就包括鼎鼎大名的GC线程。但再Android_M中GCDaemon和HeapTrimDaemon合并了)。并且在fork出进程的时候会将Daemons.java中定义的几个守护线程都跑起来。
ReferenceQueueDaemon在应用启动后就开始工作,任务是从ReferenceQueue.unqueue中读出需要处理的Reference。并将读出的Reference放入构造其自身时传入的ReferenceQueue中。 虚拟机在每次GC完成后会调用ReferenceQueue.add方法将这次GC释放的内存的对象所对应的Reference添加到ReferenceQueue.unqueue中 ,一个典型的生产者消费者模型。
注意,被GC回收后会添加到这个队列中,这个方法是在JNI层做的。
当然,当不使用Reference时,或者构造Reference不传入ReferenceQueue时,这部分处理工作其实是直接跳过的。
所以说到这里,ReferenceQueue的作用也很明显了,它就是起到了一个监控对象生命周期的作用。即当对象被GC回收时,倘若为它创建了带ReferenceQueue的Reference,那么会将这个Reference加入到构造它时传入的ReferenceQueue中。这样我们遍历这个ReferenceQueue就知道被监控的对象是否被GC回收了。前面说的PhantomReference通常用来监控对象的生命周期也就是这个原理。
FinalizerReference
FinalizerReference.java
可以看到 FinalizerReference内部定义了一个static的ReferenceQueue对象queue.这个queue在add方法中作为FinalizerReference的构造方法参数构造了一个FinalizerReference对象,并将构造的FinalizerReference对象加入到他自身维护的一个队列中.remove方法从其自身维护的队列中删除指定的Reference。另外看到FinalizerReference的get方法返回的是zombie成员。这个成员是在虚拟机中从referent拷贝过来的(后面介绍GC时会详细说明)。 简单来说,FinalizerReference就是一个派生自Reference的类,内部实现了一个由head,prev,next维护的队列,还有一个自己定义的成员变量queue。它的蹊跷之处就在这个queue成员变量和add方法。 在其add方法中使用这个queue和参数中的一个对象构造了一个FinalizerReference,并将其插入自己维护的队列中。根据前面对ReferenceQueue的说明,当这个被FinalizerReference引用的对象被GC释放其所占用的内存堆空间时,会把这个对象的FinalizerReference引用插入到这个queue中。这个add方法同样是从虚拟机中反调回来的,当一个对象实现了finalize方法,虚拟机中能够检测到,并且反调这个add方法将实现了finalize方法的对象当做参数传出来。即所有实现了finalize方法的对象的生命周期都被FinalizerReference的queue所监控着,当GC发生时queue中就会插入当前正准备释放内存的对象的FinalizerReference引用。到这里能很清晰看出这个也是一个典型的围绕这个queue成员变量的生产者消费者模型,生产者已经找到,接下来看下哪里去消费这个queue呢?我们还是将目光转向Daemons.java Daemons.java
FinalizerDaemon是Daemons.java中定义的另一个守护线程,FinalizerReference中定义的queue的消费者就是它。它内部定义了一个ReferenceQueue类型的对象queue,并将其赋值为前面说的FinalizerReference中的定义的那个queue。run方法中通过ReferenceQueue的remove方法把保存在queue中的Reference获取出来并通过doFinalize方法做下一步处理。前面提过ReferenceQueue的remove方法是阻塞的,在队列中没有Reference时将阻塞直到有Reference入队。我们看一下doFinalize方法,通过从队列中获取出来的reference的get方法获取到被引用的真实对象,并在这里调用该对象的finalize方法。但在这之前会通过FinalizerWatchdogDaemon.INSTANCE.notify()唤醒FinalizerWatchdogDaemon守护线程,FinalizerWatchdogDaemon在稍后介绍。 总结起来,FinalizerQueue和FinalizerDaemon组合起来完成了在合适的时机去调用我们实现的finalize方法的工作:虚拟机检测到有对象实现了finalize方法会调用FinalizerQueue的add方法使得在GC的时候能将实现了finalize方法的对象的引用加入到FinalizerQueue的queue成员中。而FinalizerDaemon则从FinalizerQueue的queue中取出跟踪的引用并调用被引用对象的finalize方法。 上面提到的FinalizerWatchdogDaemon同样是定义在Daemons.java中的一个守护线程。它的代码比较简单,感兴趣的朋友可以去看一下。这里主要介绍下它的作用。它主要用来监控finalize方法执行的时长,并在finalize执行超时时会抛出finalize() timed out异常并退出进程。所以我们在实现finalize方法的时候一定不能在finalize方法内做太过负责的事情。另外从这里也看出,如果对象实现了finalize方法,那么它的内存会等到其finalize方法执行完成才真正释放,这从某种程度上说也推迟啦GC回收内存的进度。所以不是万不得已个人是不建议实现finalize方法的。
finalize 方法是在对象被GC回收时,会先执行 finalize方法。 含有 finalize 方法的 object 是在 new 的时候由虚拟机生成了一个 finalize reference 在来引用到该Object的,而在 finalize 方法执行的时候,该 object 所对应的 finalize Reference 会被释放掉,即使在这个时候把该 object 复活(即用强引用引用住该 object ),再第二次被 GC 的时候由于没有了 finalize reference 与之对应,所以 finalize 方法不会再执行。 含有Finalize方法的object需要至少经过两轮GC才有可能被释放。
Last updated