了解虚拟机就一定需要了解垃圾回收机制,人们很久就在思考这三件事:那些内存需要回收?什么时候回收?怎样回收?在这篇文章是自己参考《深入理解Java虚拟机》一书学习后的总结,看完相信就会对垃圾回收有了一定的了解。
之前讲过的JVM内存的各个区域,我们知道程序计数器、虚拟机栈、本地方法栈 三个区域是线程私有的,也就是随线程生而生,随线程灭而灭。因为这些区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多的考虑回收问题。而Java堆和方法区则不一样,我们只有在程序处于运行期间是才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,这也是垃圾回收器工作的主要区域。
有一种方法就是使用引用计数法,它是给对象中添加一个引用计数器,当有一个地方引用它时,计数器的数值就加1,反之当引用失效时,计数器的值就减1。那么任何时刻计数器为0的对象就是不可能再被使用的。虽然引用计数法的实现简单判定效率高,大部分情况下都是一个不错的选择,但Java虚拟机中却不是使用该种方法来管理内存最主要的原因就是它很难解决对象之间互相循环引用的问题.即如果两个对象互相引用即使以后都不在需要,那么垃圾回收器也不会回收.
在主流的语言中,都是通过可达性分析来判断对象是否"存活"。这个算法的思路就是通过一系列的"GC Roots"的对象作为起始点,从这些节点向下搜寻,搜索所走过的路径称为"引用链",当一个对象到GC Roots不可达时,就证明该对象是不可用的.
在Java语言中,可以作为GC Roots 的对象包括下面几种:
在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这四种引用强度一次逐渐减弱。
Object obj = new Object()
这类引用只要强引用存在,垃圾回收器就永远不会回收被引用的对象。即使在可达性分析算法中不可达的对象,也不是必定会被回收。对于每一个对象都要进行两次标记,当在可达性分析为不可达时就第一次被标记会回收,此时被标记的对象还有一次可以“拯救”自己的机会,那就是对象是否会执行finalize()方法。当对象没有覆盖该方法或者该方法已经被调用过一次,那么就进行了第二次标记,也即基本上一定会被回收。
当对象覆盖了finalize()
方法,那么这个对象就会被放置在一个F-Queue
队列中,并稍后由虚拟机自动建立的一个低优先级的线程区执行。如果在执行对象的finalize()
方法时,对象在该方法中成功“拯救自己”——重新与引用链上的一个对象建立关联,那么这个对象就会重新变为可用对象。如果对象没有成功“拯救自己”那么也会被标记第二次而被回收。注意:任何一个对象的finalize()
方法智慧被调用一次,如果面临第二次回收,就不会再次执行finalize()
方法。
所以我们最后得到,如果一个堆上的对象在可达分析算法中到GC Roots中没有没有任何引用链相连,以及两次被标记回收,那么这个对象就会被垃圾回收器回收。
上面我们得到堆上的对象的回收规则,那么对于方法区一般会认为是没有垃圾回收的,因为比起堆上的垃圾回收相率极低。方法区中的垃圾回收器主要回收两部分:废弃的常量和无用的类。废弃常量指的是一个常量在常量池中但却没有引用指向,这个常量就会被清理出常量池。判断常量无用比较简单,但对于一个类是否是一个无用的类的判断却比较苛刻:
满足上述三个条件的类虚拟机可以进行回收,但并不一定会被回收,可以通过参数设置来调整。
这里介绍四种垃圾回收的算法的思想:
最基础的收集算法就是“标记—清除”算法,这个算法分为两个阶段:标记、清除。首先标记出所有需要回收的对象,在比偶埃及完成后统一回收所有被标记的对象。
这种方法的不足有两个:
为了解决第一中方法的效率问题,复制算法就出现了,它将可用的内存划分为内存相等的两块区域,每次只使用其中一块,当一块内存用完后,就将存活着的对象都复制到另一块内存中,然后把已经使用的空间一次性清理掉。这样分配内存空间就不会有内存碎片的情况,只需移动堆顶指针按顺序分配空间。这种方式实现简单、运行高效。但这种方式的不足就是将内存空间缩小为原来的一半,对空间的损耗较大。
现在的商业虚拟机都通过这种方法来回收“新生代”对于新生代的对象98%都是“朝生夕死”,所以不需要按照1:1的比例来划分内存空间而是将内存空间分为一个大的Eden空间和两块小的Survivor空间,每次都使用一块大空间和一块小空间,当回收时将存活的对象复制到另一个小空间中,最后清理掉用过的空间。其中Eden空间和Survivor空间大小比例是8:1,也就相当于每次只有10%的空间“浪费”。当然如果存活对象较多,多于10%时,需要依赖其他内存进行分配担保。
复制算法在对象存活率较高时就要进行很多次的复制操作,降低了效率,而且也会浪费50%的空间,所以老年代一般不会采取这种方法。根据老年代的特点,就有了标记—整理算法,标记过程与标记—清除算法中的标记相同,但后续步骤不是直接堆可回收的对象进行清理,而是让所有的存活对象都向一端移动,然后直接清除端边界以外的内存。
当前多数的虚拟机 都是采取的分代收集算法,这个算法时根据对象的存活周期不同将内存划分为几块,一般将Java堆分成新生代和老年代,这样就可以更具各个年代的特点选择最适当的收集算法。在新生代中,每次垃圾回收时都有大批对象被回收,少量存活于是就使用“复制算法”。而老年代因为对象的存活率较高就需要使用“标记—清理”或者“标记—整理”的算法来进行回收。
新生代用 “复制算法”,老年代用 “标记—清理”或者“标记—整理”算法
是不是任何时刻都能发生GC呢?当然不是。程序并不能在所有地方都可以停顿下来GC,只有在达到安全点才会执行。要知道如果进行GC操作首先要让整体程序在这一时刻保证不变,像是一个被冻结到某一点的状态,引用关系也不会再发生改变。此时虚拟机通过自己的方式会获取到在这些特定的点上的所有GC Roots的引用,只有在这种情况下也即“安全点”才可以发生GC。
选取安全点时既不可以太少以至于让GC等太久,也不可以太多以至于增大运行时负荷。所以安全点的选定基本上是以程序“是否具有让程序长时间等待的特性”而选择的,例如在方法调用、循环跳转、异常跳转等才会产生安全点。
另一个问题就是如何在GC发生时让所有的线程都跑到最近的“安全点”上停下来呢?这里介绍两种方案:
上述安全点似乎确实解决了问题,但如果是线程“不执行”的时候呢?典型的就是Sleep和Blocked状态下的线程,就无法相应JVM的中断请求,去安全点的位置挂起,对于这种情况就需要安全区域来解决。
安全区域是指:在一段时间下或一段代码片段中,引用关系不会发生变化,在这个区域任何时刻开始GC都是安全的,安全区域可以看作是安全点的扩展。
在线程执行到安全区域的代码块是,首先就会标识自己进入了安全区域,这样在这段时间要发起GC时,就不用管已经在安全区域的线程了。在线程要离开安全区域时,要检查系统是否完成了整个GC过程,如果没有,那线程就会继续等待,如果完成那么就可以离开安全区域。
GC将会发生在安全点和安全区域时
内存回收的具体进行过程时由虚拟机所采用的GC收集器决定的,而一台虚拟机也往往不只有一种GC收集器。
以上就是关于JVM的引用的一些特性以及JVM的垃圾回收机制的学习总结,也解答了开始时的三个问题。有任何问题欢迎指正,希望能帮助到你,也欢迎点赞关注一起进步。