这次来说一下关于Java垃圾收集的一些思想和算法。
垃圾收集的触发条件
1)因为GC线程的优先级较低,所以当虚拟机空闲时,即没有线程工作时,会触发GC。
2)Java堆空间不足以给新对象分配内存时。
引用计数算法
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值减1;任何时刻计数器值为0的对象就是不可能再被使用的。
这种算法原理很好理解,逻辑上看也没毛病。
但是,Java语言中没有选用引用计数算法来管理内存,最主要的原因是它很难解决对象之间的相互循环引用的问题。
我想到的最简单明了的例子就是回调,被调用的对象和回调对象之间互相持有对方的引用,形成一个回环,即使其他地方都无法访问(不持有引用)这两个对象,他们的引用计数也不会变成0,使用引用计数算法就无法回收它们。
那应该使用什么算法判定对象是否存活?
根搜索算法
看下面这张图就很好理解了这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
蓝底表示仍然存活的对象,白底表示判定可回收的对象。
在Java语言里,可作为GC Roots 的对象包括下面几种
1、虚拟机栈(栈帧中的本地变量表)中的引用的对象。
2、方法区中的类静态属性引用的对象。
3、方法区中的常量引用的对象。
4、本地方法栈中JNI(Native方法)的引用的对象。
要真正回收一个对象,至少要经历两次标记过程:如果在上面的根搜索后发现没有与GC Roots引用链相连,那么它会被第一次标记并进行筛选,筛选该对象是否有必要执行finalize()方法。如果对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过了(任何一个对象的finalize()方法只会被系统自动调用一次),就视为“没必要执行”;如果有必要执行,则把它放置在一个名为F-Queue的队列之中,这里的执行仅指虚拟机会触发这个方法,但并不承诺会等待它运行结束。
不过大多数情况下我们不需要关心finalize()方法,如果有需要显示回收的资源,可以使用try-finally或其他方式完成。
引用的分类
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四种,引用的强度依次逐渐减弱。
强引用是指类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
软引用用来描述一些还有用但非必需的对象,在系统将要发生内存溢出异常时,将会把这些对象列入回收范围并进行第二次回收。
弱引用也是描述非必需对象对的,但是被弱引用关联的对象只能生存到下一次垃圾收集发生之前(不安全)。
虚引用,无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被回收时收到一个系统通知。
垃圾收集算法
以下算法笔者没有找到合适的示意图(毕竟是其他大牛辛辛苦苦画的),如果小伙伴想象不出来的话可以直接百度对应算法的示意图。
标记-清除算法
这是最基础的收集算法。分为“标记”和“清除”两个阶段,标记的操作就如上面所提到的。这种算法的主要缺点有两个:一个是效率问题,标记和清除过程的效率不高;另外一个是空间问题,标记清除后会产生大量不连续的内存碎片。
类似旧XP时代有试过在我的电脑里整理磁盘空间时看到的那样,磁盘的实际存储状况是断断续续的,只不过内存本来就小得多,这样零碎的内存可用空间可能导致程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存进而触发另一次垃圾收集。
复制算法
这种算法的思想是把内存分为等大的两块,每次只使用其中一块,当这块内存(暂称为使用中的区域)用完时,就把其中还存活的对象复制到另一块内存中(暂称为保留区域,复制过去之后在保留区域中是连续的),然后把这块使用中的区域内存完全清理掉,接着保留区域就变成使用中的区域,被清理的使用区域变成了保留区域(两者角色互换)。
这种方法不用考虑内存碎片等复杂情况,因为复制过去以后就是连续的,只要移动堆顶指针按顺序继续分配内存即可。只是这种算法的代价是将内存缩小为原来的一半,太高昂。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死,并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间(上一篇提到过,是指新生代内存的划分),每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。
如果另一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记-整理算法
这是根据老年代的特点(存活率高、没有额外空间对它进行分配担保)提出的算法,过程和“标记-清除”算法类似,只不过不是标记可回收对象直接回收,而是让存活的对象向一端移动,然后直接清理掉端边界以外的内存(也就是把对象往前移,挤在一起后把后面的内存空间直接清空一次)。
分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法。
其实前面说的都是围绕分代来解决垃圾收集的问题。总结来说就是在新生代中,每次垃圾收集只有少量对象存活,选用复制算法。而在老年代中因为对象的存活比较稳定,所以选用“标记-清除”或“标记-整理”算法来进行回收。
内存分配和回收策略
先说明一下:
新生代GC(MinorGC)指发生在新生代的垃圾收集动作;
老年代GC(FullGC)指发生在老年代的GC,速度一般会比MinorGC慢10倍以上。
大多数情况下,对象在新生代Eden区(存在于Java堆中)中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次MinorGC。
为了避免经常出现大对象而使垃圾收集提前触发(指Eden空间其实还有很多空间但是不足以给这种大对象分配内存),在虚拟机中可以设置一个参数令大于这个设置值的对象直接在老年代中分配。
为了识别对象的“年龄”,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden“出生”并经过一次MinorGC后仍然存活并被转移到Survivor空间中,就将该对象的年龄设为1,此后该对象每次经历MinorGC仍然留在Survivor空间中的话,年龄就加1,当它的年龄增长到老年代的年龄阈值(可以通过参数设置)时,就会被转移到老年代。
当然对象不一定非要达到年龄阈值才能转移到老年代,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,就可以把年龄大于等于该年龄的对象转移到老年代。
刚才提到了一个分配担保机制,因为这个机制的存在,虚拟机需要确定老年代是否可以担保下一次GC并决定是否对老年代进行GC(即FullGC)。
在发生MinorGC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间大小,如果大于,则改为直接进行一次FullGC。如果小于,则查看HandlePromotionFailure设置是否允许担保失败:如果允许,那只会进行MinorGC;如果不允许,则改为进行一次FullGC。
显然这是一种通过经验预测的手段,能有效避免频繁发起FullGC(耗时长),还是有可能出现担保失败的。如果出现担保失败,就只能在失败后再发起一次FullGC咯。
垃圾收集器
垃圾收集器是内存回收的具体实现。新生代和老年代都有多种垃圾收集器(不同厂商、不同版本的虚拟机提供的垃圾收集器会有很大差别),并且一些垃圾处理器可以搭配组合使用。各种垃圾收集器详细的参数信息笔者也没有去了解,有兴趣的小伙伴可以去查找最新的一些垃圾收集器的资料。
小伙伴们若是有指正或者建议欢迎在下方留言。
参考:
1、《深入理解Java虚拟机》