我的JVM学习(垃圾收集器)


判断对象是否死亡通常有两种算法:

引用计数算法(python中使用的):

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1,引用失效时,计数器值就减1,计时器为0的对象就不可能再被使用。(但是,当两个对象无任何引用,它们互相引用着对象时,导致它们的引用计数都不为0,于是GC无法回收。)

可达性分析算法(Java中使用的):

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索的路径称为引用链,当一个对象到“GC Roots”没有任何引用链相连时,即对象不可用。

在java中以下几种对象可以作为GCRoots:

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

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

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

4)本地方法栈中JNI(通常说的Native方法)引用的对象



强引用:最普遍的饮用方式,只要强引用在,对象就不可能被回收。

软引用:用来描述有用但非必需的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收,如果回收后还没有足够内存,才会抛出内存溢出异常。

弱引用:弱引用关联的对象只能生存到下一次垃圾回收之前,无论内存是否足够,都会回收这部分对象。

虚引用:一个对象是否有虚引用不会对其生存时间构成影响,也无法通过虚引用获得对象实例,只会在对象回收时收到一个系统通知。


         在Java中,宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,如果该对象覆盖了finalize()方法,将会将其放置在一个F-Queue队列中,触发它的finalize()方法,然后GC将会对F-Queue队列中对象进行二次标记,如果对象要拯救自己,只需要重新与引用链上的任何一个对象建立关联即可,比如将自己赋值给某个类变量或者对象的成员变量,那在第二次标记时,它将被移除出“即将回收”的集合,如果这时候还没逃脱,那就真的被回收了。(任何一个对象的finalize()方法只会被调用一次,作者不推荐使用该方法做一些关闭资源的工作,try-finally可以做得更好)


       方法区回收主要回收两个内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似。例如:当没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果此时发生内存回收,必要的话“abc”常量会被系统清理出常量池。回收“无用的类”的条件比较苛刻:

1、该类所有实例已经被回收

2、加载该类的ClassLoader已被回收

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


垃圾收集的策略常用有如下几种:

(1) 标记-清除算法

标记清除法将垃圾回收分为两个阶段:标记阶段和清除阶段。

在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象,因此未被标记的对象就是未被引用的垃圾对象然后在清除阶段,清除所有未被标记的对象。这种方法可以解决循环引用的问题,只有两个对象不可达,即使它们互相引用也无济于事。也是会被判定位不可达对象。

标记清除算法可能产生的最大的问题就是空间碎片

如下图所示,简单描述了使用标记清除法对一块连续的内存空间进行回收。

从根节点开始(在这里仅显示了两个根节点),所有的有引用关系的对象均被标记为存活对象(箭头表示引用)。从根节点起,不可达对象均为垃圾对象。在标记操作完成后,系统回收所有不可达对象。

 我的JVM学习(4)垃圾收集器

从上图可以看出,回收后的内存空间不再连续在对象的对空间分配过程中,尤其是大对象的内存分配,不连续内存空间的工作效率要低于连续空间的,这也是该算法的缺点。

注意:标记清除算法先通过根节点标记所有可达对象,然后清除所有不可达对象,完成垃圾回收后面会讲到标记压缩算法,注意两者的区别。。。。。。


(2) 复制算法

算法思想将原有的内存空间分为两块相同的存储空间,每次只使用一块,在垃圾回收时,将正在使用的内存块中存活对象复制到未使用的那一块内存空间中,之后清除正在使用的内存块中的所有对象,完成垃圾回收。

如果系统中的垃圾对象很多,复制算法需要复制的存活对象就会相对较少(适用场景)。因此,在真正需要垃圾回收的时刻,复制算法的效率是很高的。而且,由于存活对象在垃圾回收过程中是一起被赋值到另一块内存空间中的,因此,可确保回收的内存空间是没有碎片的。(优点)

但是复制算法的代价是将系统内存空间折半,只使用一半空间,而且如果内存空间中垃圾对象少的话,复制对象也是很耗时的,因此,单纯的复制算法也是不可取的。(缺点)

 

图解算法回收流程:

A、B两块相同的内存空间(原有内存空间折半得到的两块相同大小内存空间AB),A在进行垃圾回收,将存活的对象复制到B中,B中的空间在复制后保持连续。完成复制后,清空A。并将空间B设置为当前使用内存空间。

 我的JVM学习(4)垃圾收集器

在java中的新生代串行垃圾回收器中,使用了复制算法的思想,新生代分为eden空间、from空间和to空间3个部分,其中from和to空间可以看做用于复制的两块大小相同、可互换角色的内存空间块(同一时间只能有一个被当做当前内存空间使用,另一个在垃圾回收时才发挥作用),from和to空间也称为survivor空间,用于存放未被回收的对象。

我的JVM学习(4)垃圾收集器

新生代对象】:存放年轻对象的堆空间,年轻对象指刚刚创建,或者经历垃圾回收次数不多的对象。

老年代对象】:存放老年对象的堆空间。即为经历多次垃圾回收依然存活的对象。


     在垃圾回收时,eden空间中存活的对象会被复制到未使用的survivor空间中(图中的to),正在使用的survivor空间(图中的from)中的年轻对象也会被复制到to空间中(大对象或者老年对象会直接进入老年代,如果to空间已满,则对象也会进入老年代)。此时eden和from空间中剩余对象就是垃圾对象,直接清空,to空间则存放此次回收后存活下来的对象。

优点:这种复制算法保证了内存空间的连续性,又避免了大量的空间浪费。

注意:复制算法比较适用于新生代。因为在新生代中,垃圾对象通常会多于存活对象,算法的效果会比较好。

 

(3) 标记压缩算法

复制算法的高效性是建立在存活对象少、垃圾对象多的情况下,这种情况在新生代比较常见,

但是在老年代中,大部分对象都是存活的对象,如果还是有复制算法的话,成本会比较高。因此,基于老年代这种特性,应该使用其他的回收算法。


标记压缩算法是老年代的回收算法,它在标记清除算法的基础上做了优化。(回忆一下,标记清除算法的缺点,垃圾回收后内存空间不再连续,影响了内存空间的使用效率。。。)

和标记清除算法一样,标记压缩算法也首先从根节点开始,对所有可达的对象做一次标记,

但之后,它并不是简单的清理未标记的对象,而是将所有的存活对象压缩到内存空间的一端,之后,清理边界外所有的空间

这样做避免的碎片的产生,又不需要两块相同的内存空间,因此性价比高。

 

图解其算法工作过程:

通过根节点标记出所有的可达对象后,沿着虚线进行对象的移动,将所有的可达对象移到一端,并保持他们之间的引用关系,最后,清理边界外的空间。

 我的JVM学习(4)垃圾收集器

标记压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片的整理,因此也称之为标记清除压缩算法。

 

(4) 分代算法

前面介绍的垃圾回收算法中,并没有一种算法可以完全替代其他算法,各自具有自己的特点和优势,因此需要根据垃圾对象的特性选择合适的垃圾回收算法。


分代算法思想:将内存空间根据对象的特点不同进行划分,选择合适的垃圾回收算法,以提高垃圾回收的效率

 我的JVM学习(4)垃圾收集器

通常,java虚拟机会将所有的新建对象都放入称为新生代的内存空间。

新生代的特点是:对象朝生夕灭,大约90%的对象会很快回收,因此,新生代比较适合使用复制算法。

当一个对象经过几次垃圾回收后依然存活,对象就会放入老年代的内存空间,在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的,因此,认为这些对象在一段时间内,甚至在程序的整个生命周期将是常驻内存的。

老年代的存活率是很高的,如果依然使用复制算法回收老年代,将需要复制大量的对象。这种做法是不可取的,根据分代的思想,对老年代的回收使用标记清除或者标记压缩算法可以提高垃圾回收效率。


注意:分代的思想被现有的虚拟机广泛使用,几乎所有的垃圾回收器都区分新生代和老年代

对于新生代和老年代来说,通常新生代回收的频率很高,但是每次回收的时间都很短,而老年代回收的频率比较低,但是被消耗很多的时间。为了支持高频率的新生代回收,虚拟机可能使用一种叫做卡表的数据结构,卡表为一个比特位集合,每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用,

这样以来,新生代GC时,可以不用花大量时间扫描所有老年代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有当卡表的标记为1时,才需要扫描给定区域的老年代对象,而卡表为0的所在区域的老年代对象,一定不含有新生代对象的引用。


如下图表示:

卡表中每一位表示老年代4KB的空间,卡表记录为0的老年代区域没有任何对象指向新生代,只有卡表为1的区域才有对象包含新生代对象的引用,因此在新生代GC时,只需要扫面卡表为1所在的老年代空间,使用这种方式,可以大大加快新生代的回收速度。

 我的JVM学习(4)垃圾收集器


相关文章: