JAVA的内存回收机制(一)

May 2, 2016


  JAVA的内存回收主要是将“垃圾”清理掉,所谓垃圾也就是无用的对象、不可达的类等等用户不再使用但还留存在内存中的数据。垃圾回收主要在堆中进行,堆里面的对象这么多,怎么判断谁是垃圾呢?其中一个方法就是看这个对象的被引用计数——既然一个对象已经没有被别的对象引用了,那用户肯定就不使用这个对象了,收集器就可以安全地将它回收掉。

  采用引用计数的办法来计算一个对象是否可以被回收是一个挺好的办法,实现起来容易,计算起来也很快。但这种办法难以处理更复杂的情况,例如A引用了B,B引用了A,但他们都没有再被其它对象引用了,这时按照引用计数算法判断,A和B都不满足被回收的条件,显然这样就造成了内存泄漏。

  其实现在主流的判断对象是否存活的方法是可达性分析,即把某个对象作为GC root,从这个节点开始向下搜索。我们想象一个引用关系构成的树,根是GC root,只要在这个引用关系树里的对象都叫做可达对象,既然是可达对象,就不能被回收了。对于经过这样搜索还不可达的对象或者其它孤立的树,就可以全数回收掉,这样就解决了引用计数算法无法判断相互引用的对象的问题。至于GC root,JAVA虚拟机会持有对它的引用,所以它本身是不会被回收的。

  为了加深理解我举一个在Android开发中实际遇到的例子来说明上述机制是如何判断一个对象是否被回收,以及内存泄漏是如何出现的:

Activity的泄露

我们在Activity中常需要Handler来处理异步消息,建立Handler内部类的一般方法如下:

图片2

  可以看到AS都会提示这样做可能造成内存泄漏。原因就在于,这时的Handler是作为一个非静态的内部类,它会隐式地持有外部类的引用(我们在内部类里通过“外部类名.this”可以拿到这个引用)。为什么说可能造成内存泄漏呢?因为在DVM中,Thread是作为GC root的,不会在没结束的情况下被回收掉。所以我们创建的Handler如果是在主线程中,Handler就会和主线程的Looper关联以处理消息,而这个Handler的父类如果是Activity,持有Activity的引用就会导致Activity即使生命周期结束也不会被回收,也就是泄漏了。解决这个问题的方法很多,大概有三种:把内部类写外部类来避免持有父类引用、使用静态内部类+虚引用来访问外部类、在Activity调用finish的时候调Handler的removeCallbacksAndMessages()方法来移除所有消息。这几个方法不是本篇重点,就不展开介绍了。

  补充一点,如果Handler是在子线程里创建的,那就没这么麻烦,只要在Activity退出时把子线程结束掉就可以了。停掉之后Handler虽然还是引用着Activity,但是与GC root已经没有关联,属于不可达的对象,会被回收掉。

  理解了判断对象是否能被回收的机制,就可以学习对象是如何被回收的了。不同的垃圾回收器回收策略不同,但主要的垃圾回收算法只有四种:标记清除算法、标记整理算法、复制算法、分代收集算法。

  首先来讲分代收集算法,先介绍这种算法是因为它和另外三种并不是完全平行的关系,而且为了理解其它三种垃圾收集算法,就必须先明白分代收集算法的含义。分代收集算法指的是将堆划分为两个区域:新生代和老年代。新生代中的对象年龄较小,老年代中的对象年龄较大,新生代又划分为eden区和两个survivor区,eden区的对象最新,两个幸存区的对象要老一些。对于新生代,一般采用复制算法进行垃圾回收,对于老年代,则是采用标记整理或标记清除算法。需要注意的是,在下图中我并没有列出永久代(PermGen)这是因为只有HotSpot虚拟机有这个永久代,而且JAVA8开始永久代已经被MetaSpace取代了。

  接下来就可以讲复制算法了,复制算法用于对新生代进行回收,是一种高效的算法。主要思想是将内存分块,当某一块内存满了就将这块内存上需要保留的对象复制到另一块,然后清空这一块内存,另一块内存满的时候如法炮制。早期的复制算法把内存按照一比一分成两块,但造成了比较明显的浪费。经过人生经验的积累,现在的复制算法把内存分为三块,也就是Eden区和两个Survivor区。两个survivor区是一样大的,但是他们都比eden区小得多,这是因为在新生代,绝大部分的对象生命周期都非常短,90%以上的对象都活不过一次GC。

图片2

  复制算法效率很高,但这个高主要是高到哪里去了呢,主要是高在清除对象的时候,直接擦除一块内存就可以了。所以对于老年代中身经百战存活率高的对象,复制算法发现这时需要把大量存活的老对象复制到另一块内存,效率非常差。这时候就需要标记清除和标记整理算法来进行回收。

  标记清除算法分为标记阶段和清除阶段。标记阶段就是前面所说的判断一个对象是否能被回收的可达性分析,标记完成后就把那些可以清除的对象清除掉。说起来是很简单,但这么清除的次数多了就会产生大量碎片——明明可用内存还有很多,但连续的空间已经不够大导致不足以分配新对象,这时候只好再GC,然而并不能从根本上解决问题,所以标记清除算法也有自己的局限。

图片2

  针对这些局限,就有了标记整理算法,这个收集算法与标记清除的区别是清除时将所有的存活对象向一端移动,所有的存活对象移动完成后,就把有对象这一端内存边界之外的内存都清除掉,这样就解决了碎片问题。

图片2