Java虚拟机学习笔记(2)——垃圾收集算法

时间:2022-12-27 20:16:48

        上一篇文章学习了JVM基本的内存模型,JVM内存区域可以分为:方法区、堆区、虚拟机栈、本地方法栈、程序计数器。方法区和堆区属于线程共享区域,而虚拟机栈、本地方法栈、程序计数器则属于线程隔离区域。其中,Hotspot虚拟机将虚拟机栈和本地方法栈合二为一,并且,由于该虚拟机实现将GC范围扩展至方法区,所以方法区也常被Hotspot开发者称为“永久代”。

        由于虚拟机栈和本地方法栈存放的是诸如局部变量表等信息,并且跟着线程产生和消亡,所以这部分区域不需要考虑垃圾收集,JVM的垃圾收集区域主要涉及Java堆和方法区,其中最主要的就是Java堆,几乎所有的对象都是在Java堆中完成内存分配的。所以,Java堆是垃圾收集的主要活动区域。

        现在的Java虚拟机采用的垃圾收集算法都是分代收集算法,分代收集算法指的就是不同区域根据不同区域的特点采用该区域更适合的算法。这里就必须要提到Java堆更详细的分区情况,Java虚拟机规范中,Java堆一般分成新生代和老年代,其中新生代还可以再分为Eden区和Survivor区(这与新生代采用的垃圾收集算法有关,新生代采用的垃圾收集算法是复制算法,这种算法将在后面介绍)。

        Java虚拟机规范中方法区可以不实现垃圾收集,但是实际上,类卸载的功能有时候也是很有必要的,所以,Java虚拟机一般都会实现方法区的垃圾收集。


垃圾收集算法思想


1、引用计数法:

           给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器就减1;任何时刻,计数器都为0的对象就时不可能再被使用的。但是,Java语言中并 没有选用引用计数算法来管理内存,其中最主要的原因就是它很难解决对象之间的相互循环引用的问题。

2、根搜索算法:

           在主流的商用程序语言中(Java和C#,甚至古老的Lisp),都是使用根搜索算法判定对象是否存活着。这种算法的基本实例就是通过一系列名为“GC Roots”的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的。在Java语言里,可作为GC Roots的对象包括下面几种:

            (1) 虚拟机栈(栈帧中的本地变量表)中引用的对象

            (2) 方法区中类静态属性引用的对象

            (3) 方法区中的常量引用的对象

            (4) 本地方法栈中JNI(即:一般说的Native方法)的引用对象


垃圾收集算法


           引用计数法和根搜索算法,都是垃圾收集算法的思想原型,但是由于引用计数法含有的重大问题(无法解决循环引用的垃圾收集),所以,现在的JVM垃圾收集算法都是基于根搜索算法为思想原型的,下面介绍的几种垃圾收集算法也都是根搜索算法。

1、标记 - 清除算法:

          这是最基础的算法(后面的收集算法都是基于这种实例对其缺点进行改进得到的),算法分“标记”和“清除”两个阶段。

            (1) 首先标记出所有需要回收的对象,

            (2) 在标记完成后统一回收掉所有被标记的对象。

        缺点:

            (1) 标记和清除效率都不高

            (2) 标记清除之后会产生大量的不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找

                 到足够的连续内存而不得不提前触发另外一次垃圾收集动作。

2、复制算法:

          复制算法的思想是将内存划分为等大的两块,每次只用一块,当一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是代价时要将内存缩小为原来的一般,代价未免太高了点。

          现在的商业虚拟机都时采用这种收集算法来回收新生代,IBM的专门研究表明,新生代的对象98%是朝生夕死的,所以不需要按1:1的比例来划分内存空间,而是将内存划分成一块较大的Eden空间和两块较小的Survivor空间,每次只使用Eden空间和其中一块Survivor空间。

          HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,所以新生代中可用内存空间为整个新生代容量的90%。但是,并没办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够多时,需要依赖其它内存(这里指老年代)进行分配担保。

3、标记整理算法:

           标记整理算法和标记 - 清除算法一样,但后序步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4、分代收集算法:

           当前的商业虚拟机的垃圾收集都采用“分代收集”算法,这种算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般Java堆分成新生代和老年代。新生代中对象存活率低,用复制算法,老年代中对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记 - 清理”或 “标记 - 整理”算法来进行回收。


Java中的对象引用类型


          JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Sort Reference)、弱引用(Weak Reference)虚引用(Phantom Refernce)。

          (1) 强引用:使用最多的引用,我们new出来的对象的引用都是强引用,这类引用指向的对象一定不会被回收

          (2) 软引用:软引用描述一些还有用,但不是必须的对象,将系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中并进行二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。在JDK 1.2后,提供了SoftReference类来实现软引用。

          (3) 弱引用:用来描述非必须的对象。当垃圾收集发生时,只被弱引用指向的对象都会被回收。在JDK 1.2后,提供了WeakReference类来实现弱引用。

          (4) 虚引用:这种引用不会对其对象的生存时间构成影响,为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时受到一个通知。在JDK 1.2后,提供了PhantomReference类来实现弱引用。


生存还是死亡?


          在根搜索算法中不可达的对象,也不是“非死不可”,这个时候他们暂时处于“缓刑”阶段。也就是说,该对象还有被一次拯救的机会。如果对象在进行根搜索后发现没有与GC Roots相连接的引用链条,那么它将会被第一次标记并进行一次筛选。筛选的条件是,此对象是否有必须要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必须执行”。

          如果一个对象被判定未有必要执行finalize()方法,那么这个对象将会被放置在一个F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程区执行(这里的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束)。所以,你要拯救一个即将被回收的对象,可以重写对象的finalize()方法,并在该方法中将该对象与任何一个Gc Root相连(比如和类变量等),这样,再下一次进行GC标记时,这个对象会被移出“待回收集合”,当然,建议还是别这么干,因为它的运行代价高、不确定性大。


回收方法区:


          回收方法区(HotSpot虚拟机的永久代),很多人认为方法区时没有垃圾回收的,Java虚拟机规范中确实说过可以不要求虚拟机再方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低;在堆中,尤其时在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率时远低于此的。

          永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

         (1) 回收废弃常量与回收Java堆中对象非常类似,无引用就可以回收。常量池中的其它类(接口)方法、字段的符号引用也与此类似。

         (2) “无用的类”的判断条件比较苛刻

                 1) 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。

                 2) 加载该类的ClassLoader已经被回收。

                 3) 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类方法。

          虚拟机可以对满足上面3个条件的无用类进行回收(这里指“可以”,并不是和对象一样,无用就回收)。在大量使用反射、动态代理、CGLib等bytecode框架的场景,已经动态生成JSP和OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。