深入理解Java虚拟机---(4)对象是否“死亡”的判断和GC的相关收集算法
写在前面:
在总结GC之前,首先要说,Java语言的一大好处就是讲程序员从繁杂的垃圾回收,释放对象内存空间中解放出来,相比之下,C++语言,还需要通过程序员手动的去管理,释放内存空间,省去了程序员的一大部分工作。在这一篇博客中,将会总结对象的“死亡”判断和GC的相关收集算法等。
GC的研究范围:
首先在前一篇博客中,我们知道了JVM的内存区域划分,很多区域是线程独享的,比如:程序计数器、虚拟机栈、本地方法栈,这几块区域的内存空间随着线程产生而产生,随着线程毁灭而毁灭,并且这一部分的内存大小,在类结构确定时,就已经确定,所以,这一部分的垃圾回收并不是我们的主要讨论范围。一般方法结束或者线程结束时,内存就跟着回收了。
那么另一部分,也就是所有线程共享的部分,比如Java堆和方法区,这一部分,我们只有在程序运行时,才会知道创建了哪些对象,所以就意味着这部分的内存空间分配是动态的,是我们的主要讨论范围。
怎么判断对象的"生存"还是"死亡"?
对象的"死亡"即一个对象不被任何对象引用了。判断的对象的死亡的算法一般有两种:
(1) 引用计数法
这种算法理解起来比较容易,就是给对象添加一个计数器,当一个地方引用时,计数器+1,同理,当引用失效的时候,计数器-1,当计数器=0的时候,对象就是相当于"死亡"。
注:这种看起来很简单的算法,虽然在一些公司有着不错的应用,但是引用计数法无法解决对象循环引用的情况,如下面的代码:
package gc;
public class GCDemo1 {
public Object instance = null;
public static void testGC() {
GCDemo1 temp1 = new GCDemo1();
GCDemo1 temp2 = new GCDemo1();
// 对象之间循环引用
temp1.instance = temp2;
temp2.instance = temp1;
System.gc();
}
}
上面的代码中就是对象之间的循环引用,这样计数器永远不会为0,但是这些对象实际上却已经不会被访问,GC也无法回收他们。
(2) 可达性分析法(Reachability Analysis)
算法的思路是,首先会找到程序中一系列称为"GC Roots"的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连,则说明GC Roots到这个对象是不可达的,也就是对象是无用的。就可以判定成GC 的回收对象。
判定的GC Roots的标准:
--虚拟机栈中引用的对象
--方法区中类静态属性的引用的对象
--方法区中常量引用的对象
--本地方法栈(Native)引用的对象
引用的类型
在JDK1.2以前,Java的引用定义为如果reference类型的数值代表另一块内存的起始地址,则说明是引用,反之则不是。在这样的定义下,一个对象只有两种可能:引用/非引用。这样的标准我们无法描述特定的情况,比如说:当内存空间足够的时候,我们像保留这些对象,内存空间在一次GC之后,还是很紧张,则我们清楚这些对象。
在JDK1.2之后,对引用进行了补充,按引用的由强到弱排列为:
强引用(Stong Reference):程序中正常存在的引用,eg:Object obj = new Object(),强引用还存在,GC就不会回收掉强引用的对象。
软引用(Soft Reference):是指一些还有用但是并非必需的对象。在系统将发生内存溢出异常之前,将把这些对象列为垃圾会回收器的回收对象进行第二次回收,如果这次回收之后,还没有足够的内存,才会抛出内存溢出异常。
弱引用(Weak Reference):描述非必需对象。但是强度弱与软引用,被弱引用关联的对象只能生存都下一次垃圾回收之前。无论当前内存足够,都会回收掉只被弱引用的关联的对象。
虚引用(Phantom Reference):
是最弱的一种引用关系。虚引用的存在不会影响其生存时间。为对象设置虚引用的唯一目的是能在对象呗收集器收集的时候,对对象能收到一个系统通知。
对象的标记
我们之前分析的可达性分析算法,而即使是可达性分析算法中不可达的对象,也并非是"非死不可"。要想让一个对象真正死亡,将会经历两次标记过程。
第一次标记:即在可达性分析之后,发现没有与GC Roots想连接的引用链,,那么将会被第一次标记,并且进行一次筛选,筛选的条件是此对象是否有必要finalize方法。如果对象没有覆盖finalize方法或者finalize方法以及被JVM调用过一次,那么就会被判断为"没有必要执行"。
如果被判断为有必要执行finalize方法,那么对象会被放在F-Queue队列中,并稍后由一个由JVM自己建立的,低优先级的Finalizer线程区执行。执行意味着JVM会调用这个方法,但是不代表JVM会允许finalize方法运行结束(JVM这样做是担心finalize方法执行缓慢或者进入死循环,这样会在F-Queue队列中一直出不去)。
第二次标记:GC将会对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize方法中"拯救"了自己,即重新与引用链上的任何一个对象建立关联即可。这样,在第二次标记时,会被移除队列。
注:finalize方法只会对系统调用一次,也就是说只可以被拯救一次,如果第二次再进入F-Queue队列,就不会调用了finalize方法了。并且,finalize方法只是当初Java语言为了让程序员适应C++,我们并不建议使用finalize,因为finally也可以实现finalize方法的效果。
方法区的垃圾回收:
方法区是HotSpot虚拟机中的永生代,对于永生代和新生代来说,永生代的垃圾收集效率很低,而新生代的垃圾收集一般回收70%-95%的空间。
永生代的垃圾收集主要分为两部分:废弃常量+无用的类。废弃常量的回收类似于堆的对象回收,比如:常量池中的字符串
"abcd",如果没有任何一个对象引用"abcd"这个常量,那么在GC的时候,"abcd"就会被回收。
回收无用的类需要满足以下3个条件:
(1) 该类所有的实例都已经被回收,也就是Java堆不存在该类的任何实例。
(2) 加载该类的ClassLoader已经被回收。
(3) 该类对应的Java.lang.Class对象没有被任何地方引用,也无法通过任何地方反射访问该类的方法。
注:在这些情况都满足下,也仅仅代表无用类可以被GC回收,而不是必须被回收。
垃圾收集相关算法:
在上述内容中,介绍了垃圾什么时候会被回收,接下来,要结束回收垃圾时的原理。
(1) 标记-清除算法(Mark-Sweep):
标记-清除算法是最基础的垃圾回收算法。算法分为两个阶段:"标记阶段"+"清除阶段"。首先会标记出所有需要回收的对象(标记方式参考前面的内容),在标记过所有的对象之后,统一回收。
缺点:这种垃圾回收算法,第一效率不高,因为"标记阶段"+"清除阶段"的效率都不高。第二点,清除之后会产生大量不连续的内存碎片,导致如果向分配给一个对象较大内存空间时,找不到一块连续的内存空间。
(2) 复制算法(Copying):
复制算法将内容按照容量划分为大小相等的两块,每次只使用其中的一块。当一块的内存用完了,就将还存活的着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这种算法,每次只对整个半区进行内存回收,就不用考虑内存碎片等情况。但代价是,每次牺牲了一半的内存。
还有一种分配是将内存分为3块,一块Eden和两块Survivor空间,每次使用一块Eden和一块Survivor空间,回收的时候,将Eden和Survivor还存活的对象一次性的复制到另外一块空间上。Eden和Survivor是8比1(8:1)。这样每次只有10%的空间被浪费。但是当Survivor空间不够用时,需要其他内存进行分配担保。
(3) 标记-整理算法(Mark-Compact):
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率很低,避免出现对象100%存活的情况,所以老年代一般不采用这样的算法。
所以根据老年代的特点,提出了标记-整理算法,这种算法标记与标记-清楚算法一样,但是这种算法是让存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
(4) 分代收集算法(Generational Collection):
是当前商业虚拟机都采用的算法。根据对象存活周期的不同将内存划分为几块。一般分为新生代和老年代。新生代对象采取复制算法。老年代采取"标记-清楚"/"标记-整理"算法。
上一篇: 垃圾回收的算法与实现学习笔记五、GC标记-清除算法
下一篇: 引用计数法的循环引用问题