自动内存管理机制(2)- 内存回收和垃圾收集算法
1. 概述
首先思考三个问题:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
程序计数器、虚拟机栈、本地方法栈是线程私有的,因此这几个区域的内存分配和回收都具有确定性(线程结束时执行垃圾回收)。但Java堆和方法区因为是线程共有的,这部分的内存分配和回收都是动态的,垃圾收集器关注的就是这部分的内存。
2. 堆内存的回收
2.1. 如何判定是否回收
堆里面存放着Java中几乎所有的对象实例。在进行垃圾回收之前,首先要判断这些对象是否可以被回收,有以下两种判断算法:
-
引用计数算法
每个对象都有一个计数器,当这个对象被一个变量或另一个对象引用一次,该计数器加1,;若该引用失效则计数器减1.当计数器为0时,就认为该对象是无效对象。
引用计数法虽然简单,但存在一个严重的问题,它无法解决循环引用的问题。
-
可达性分析算法
所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。
GC Roots定义:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(即一般所说的Native方法)引用的对象
2.2. Java中引用的类型
判断一个对象是否存活,无论是引用计数法还是可达性分析算法,都需要和“引用”相关。
在JDK1.2之后,Java对引用的概念进行了扩充,将其分为强引用,软引用,弱引用和虚引用四种,这四种引用强度依次逐渐减弱。
-
强引用就是指在程序代码中普遍存在的,类似
Object obj = new Object()
这类的引用(即我们平时使用的引用,也就是通过关键字new创建的对象所关联的引用就是强引用)。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
-
软引用用来描述一些还有用但又非必须的对象。在系统要发生OOM异常时,才会对这些对象进行回收。
软引用通过SoftReference类实现。软引用的生命周期比强引用短一些。
-
弱引用也是用来描述非必须对象的,但它的强度比软引用更弱一些。只要垃圾收集器运行,软引用所指向的对象就会被回收。
弱引用通过WeakReference类实现。弱引用的生命周期比软引用短。
-
虚引用也叫幽灵引用,它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。
虚引用通过PhantomReference类来实现。
2.3. 回收对象的过程
即使在可达性分析算法中不可达的对象,也并不是一定会被清除的。要真正清除一个对象,至少要经历两次标记过程:
-
判断该对象是否覆盖了
finalize()
方法- 若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将finalize()扔到F-Queue队列中;
- 若未覆盖该方法,则直接释放对象内存;
-
执行F-Queue队列中的finalize()方法
虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束。如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。
-
对象重生或死亡
如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。
强烈不建议使用finalize()
来进行任何操作,使用try-finally
或者其他方式都可以做的更好、更及时。
3. 方法区的回收
3.1. 如何判定是否回收
方法区(或者说是HotSpot虚拟机中的永久代)中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是新生代中,常规应用进行一次垃圾收集一般可以回收70%左右的空间,而永久代的垃圾收集效率远小于这个值。
方法区的垃圾收集主要收集两部分内容:废弃常量、无用的类。
判断一个常量是否是“废弃常量”比较简单,没有引用即可。但判断一个类是否是“无用的类”的条件就就比较麻烦,有以下几个条件:
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
4. 垃圾收集算法
4.1 标记 - 清除算法
当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为Stop the World)。
它有两个阶段,标记和清除:
- 标记出所有需要回收的对象(遍历所有的GC Roots,将可达的对象标为存活对象)。
- 清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
不足之处:
- 效率问题,标记和清除两个过程的效率都不高
- 空间问题,标记清除后会产生大量不连续的内存碎片,导致以后的使用不便。
4.2. 复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用过的内存空间一次清理掉。
它有三个阶段:
- 当需要回收垃圾时,标记出废弃的数据。
- 将有用的数据复制到另一块内存上。
- 将第一块内存全部清除。
这种算法的代价是将内存缩小为了原来的一般,空间利用率不高。
解决空间利用率问题:
新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分空间。我们可以将内存划分成三块:Eden、Survior1、Survior2,内存大小分别是8:1:1,每次使用Eden和其中一块Servivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和刚刚用过的Survivor。
但如果出现对象申请的内存空间太大,Eden和Survivor加起来也存不下的情况,就需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
4.3. 标记 - 整理算法
在回收垃圾前,首先将所有废弃的对象做上标记,然后将所有未标记的对象移到一边,清空剩下的另一边区域,有以下几个过程:
- 标记出所有需要回收的对象(遍历所有的GC Roots,将可达的对象标为存活对象)。
- 让所有存活的第项都向一端移动,然后清理掉端边界以外的内存。
它是一种老年代的垃圾收集算法。老年代中的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,因此如果选用“复制”算法,每次需要复制大量存活的对象,会导致效率很低。而且,在新生代中使用“复制”算法,当Eden+Survior中都装不下某个对象时,可以使用老年代的内存进行“分配担保”,而如果在老年代使用该算法,那么在老年代中如果出现Eden+Survior装不下某个对象时,没有其他区域给他作分配担保。因此,老年代中一般使用“标记-整理”算法。
标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价,标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
4.4. 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,将内存划分为老年代和新生代。老年代中存放寿命较长的对象,新生代中存放“朝生夕死”的对象。然后在不同的区域使用不同的垃圾收集算法。
在新生代中选用复制算法;在老年代中选择“标记 - 清理”或者“标记 - 整理”算法进行回收。
5. 小结
-
如何判定一个对象是否需要回收
使用引用计数算法或者可达性分析算法
-
回收对象的过程
查看是否覆盖了finalize()方法,执行F-Queue队列中的finalize()方法,对象死亡或者重生
-
有哪几种引用
强引用,软引用,弱引用,虚引用
-
分别描述下垃圾收集算法
标记 - 清理算法
复制算法
标记 - 整理算法
分代收集算法