jvm——垃圾收集算法

垃圾回收的区域

    讲垃圾回收之前,我们首先要明确,垃圾回收的区域是方法区和堆,而方法区中主要是类信息,调用指令,常量等,这些一般是不能回收的,所以垃圾回收的主要区域是堆。

垃圾定位(标记)

    我们要回收内存中的垃圾,我们首先要定位到这块垃圾,否则我们也不知道收集哪里的内存,jvm中定位内存普遍有引用计数器和可达性分析两种算法,其中引用计数器已经被淘汰,现在主流的都是可达性分析算法。

引用计数器算法

    在每一个对象上增加一个引用计数器,当多一个引用指向了这个对象时,就把计数器+1,当引用失效时把计数器-1,这样一来,任何时刻出现计数器等于0时就代表着这个对象死亡了,他不可能再被任何一个引用引用到了。这个算法的原理很简单,效率也很高,实现也容易,但是有一个致命缺陷:
    假设A对象中有一个引用指向B对象,B对象中有一个引用执行A,除此之外,AB对象再无其他引用了,此时AB对象的引用计数器值都为1,但是整个jvm中却再也没有一个引用可以指向A和B这两个对象了。A和B成了一个组合游离块。这个游离块其实应该被回收掉,但是由于计数器不为0,所以无法被回收,这是引用计数器算法的致命缺陷,也是引用计数器算法被淘汰的根本原因。

可达性分析

    可达性分析的成立的基础是,jvm中的对象一般都不会是独立存在的,比如我们要new个对象:

public class GCRootTest {
    private StringBuilder stringBuilder;

    private  void createInstance() {
        stringBuilder = new StringBuilder("HAHAHA");
    }

    public static void main(String[] args) throws ClassNotFoundException {
        //1.带引用
        GCRootTest gcRootTest = new GCRootTest();
        //2.不带引用
        new StringBuilder("hahha");
        //3.引用链stringBuilder>gcRootTest
        gcRootTest.createInstance();
    }
}

main函数中new出来的对象,他依靠于StringBuilder stringBuilder这个引用才有意义,如果没有引用(如第二步),他就是可回收的垃圾,因为程序无法再定位到这个new出来的对象,而之后的引用如果要使用new StringBuilder(“hahha”)这个对象,也需要链接stringBuilder才能会得到这个对象(如第三步),所以对象与对象之间在内存中会形成一个又一个的引用链,甚至他们之间还会互相交叉。
    我们知道了引用链,但不是所有引用链都是不可回收的,比如上面引用计数器中的那两个A和B,他们两个也形成了引用链,然而这条链是可回收的,而有用的链需要有正确的链头,以下几种链头才是有效的:

  • 虚拟机栈和本地方法栈中的引用对象:就如上面main中的gcRootTest,虚拟机栈和本地方法栈随线程产生和灭亡,这些引用对象说明正在或即将使用,与之连接引用链也就会被程序找到和使用。
  • 方法区中的常量或者静态变量的引用对象:方法区中的常量或者静态变量也是随时可以被程序中的拿来使用的,与之连接的对象也就可能会被随时使用。
    而上面所说的有效的链头,其实就是GC ROOT。
    可达性分析
OopMap数据结构:从保守式GC到准确式GC

    可达性分析时,jvm会从虚拟机栈中的局部变量表进行枚举变量,如果这是指向堆内存的引用对象,则会被当做GC ROOT,而找出GC ROOT的策略上出现了两种分歧:

  • 保守式GC:早期的时候,jvm通过扫描局部变量表的中的每一个块内存判断,他是否为一个指针,是否指向堆内存,这种通过一些条件来模糊查找出来的结果具有不确定性,而且效率也慢,唯一的好处是实现比较简单。最终垃圾回收时也只会回收那些确定的内存,对于不确定的也不敢回收。

  • 准确式GC:由于保守式GC的不确定性和低效率,所以有了准确式GC,他是利用一种数据结构(OopMap,其实就是一张映射表),当jvm的解释器或者及时编译器(JIT)从方法区拿到指令变成机器码塞入虚拟机栈的时候,记录下当前局部变量表中,多少偏移量的内存上记录的是什么类型数据。这样当gc进行GC ROOTS的枚举时,就不用去虚拟机栈中一行一行的判断每一块对象的类型了,直接根据OopMap就能获得哪些内存可以为GC ROOTS了,并且不会出错。

安全点

    由于gc需要根据OopMap来枚举GC ROOTS,而OopMap并不是每一行代码上都会生成OopMap的(据说是为了节省空间),而会生成OopMap的地方就是安全点,gc一般情况下,需要所有用户线程进入安全点,才会开始工作,否则得不到最新的OopMap,那得到的GC ROOTS也就不准确了。一般jvm会选择程序的选择分叉点,子方法执行点,循环执行点这些地方作为安全点。

进入安全点的方式

上面说一般gc需要所有的用户线程进入安全点才会工作,一般有两种让用户线程进入安全点的方式:抢先式中断和主动式中断。抢先式指当GC准备开始的时候,强制停掉所有的线程,然后判断每个线程是否已经到了安全点,如果没有,则放他们执行到安全点。而主动式则是给每个线程定一个标志,然后每个线程去轮训,当轮训到GC要运行时,等走到下一个安全点就自己停下来。现在的虚拟机基本都采用的主动式中断。

安全域

gc需要用户线程进入安全点再工作,但是不是所有线程都能进入安全点的,比如一个线程进入了wait或者sleep状态。所以就有了安全域的概念,指在一段指令区域中并不会改变索引链,则认为一部分是安全域,比如线程wait时,索引链是不会变的。当GC发现多线程都在安全域时也会开始,而多线程即将离开安全域时,会判断GC是否完成,如果未完成会等GC结束再往下执行。

垃圾定位扩展

    gc一般进行可达性分析时并不是分析一次就认为某块内存需要清理了,整个清理过程如图:
gc工作流程
其中第一步到第五步都是一次定位的过程,可以看到gc进行了不止一次的可达性分析。正常情况下,gc对内存进行过一次可达性分析后,对不可达的内存对象打上标记,然后查看整个对象是否有重写finalize()方法,如果重写了这个方法,看是否已经执行过这个方法。如果这个对象重写了finalize()方法并且没有执行过,就会把这个对象扔进一个队列(F-Queue),然后jvm会开启一个线程专门将这个队列中的对象依次执行他们的finalize()。gc定位过所有内存块之后,会重复再来一遍(一般会重复两次),然后只有多次都被标记的对象最后才会真正被回收。
    所以说jvm为对象提供了依次起死回生的机会,你只要在finalize()将自己重新和引用链连起来,就能逃过被回收的命运,但是这个也不是绝对的,因为jvm开启的执行finalize()方法的线程,不会保证finalize()方法会完整执行结束,因为finalize()中如果存在耗时操作,会严重影响gc的速度。所以不要使用finalize()这个方法。

垃圾回收的算法

标记-清除法

    标记即为前面说的垃圾定位,清除则是直接清除被标记的内存。这种算法看上去即为简单,但实际上却有两个严重缺点:效率偏低、产生大量碎片内存,不利于后续的内存利用。如果后续内存需要一个较大空间的连续内存,很可能会因为找不到这样的内存空间而重复触发垃圾回收。

复制法

    把内存划分为两块相同的区域,其中一块作为存储区,另一块作为备用区,当垃圾回收时,会把存储区中存活的对象复制到备用区中,然后把存储区中所有内存全部清除,这样优点是不会产生乱七八糟的不连续内存,缺点是可用内存少了一半,并且当存活内存偏多的情况下,复制操作过多,效率低下。但根据IBM的大佬们的分析(具体谁分析的我也不知道了),大部分的对象都是朝生夕死的,这也就意味着备用区不用留这么多,所以在在IBM的GC垃圾回收中,将内存分为了一块较大的Eden,和两块小的Survivor空间,下面分代垃圾收集中会细说。

标记-整理法

和标记清除法类似,但是他在标记之后不是直接进行清除,而是把存活的内存统一移动到一遍,然后把另一边都清掉。

分代垃圾回收

    所谓分代垃圾收集就是将堆内存分成几个部分,根据不同部分的特点使用不用的垃圾分类算法:
内存分类
jvm将堆内存分为新生代和老年代两部分,默认情况下老年代内存大小为新生代的两倍,新生代采用的复制算法,老年代采用的标记清除或者标记整理算法。

新生代回收

    新生代采用的复制法,而图中新生代被细分为Eden、Survivor、Survivor这三块区域,也是为了方便新生代使用复制法进行垃圾回收的。默认情况下新生代三个区的内存比例为:Eden:Survivor:Survivor=8:1:1。我们将两个Survivor记为S1和S2,方便下面区分。
    当有数据写入堆内存时,首先会写入新生代的Eden中,当Eden区内存不足,无法继续分配内存时发生垃圾回收,Eden中不可回收的内存会被复制到S1中,然后清空Eden,从而有内存可以用来写入数据。
    当内存再次不足时,再次触发垃圾回收,Eden和S1中不可回收的内存会被复制到S2中,然后清空Eden和S1。第三次内存不足进行垃圾回收时,再反过来将Eden和S2中不可回收部分复制到S1,然后清空Eden和S2,如此不断反复的进行新生代的垃圾回收。
    从上面的流程我们可以看出为什么会有两个Survivor了,方便复制清除时可以来回的倒数据,如果只有一个Survior,那就没地方可以复制了,类似于我们实现交换两个变量的代码,总需要一个temp变量作为中间存储的变量。
    上面讲到Eden和Survivor的内存大小为8:1,相差这么大的原因是,我们认为新生代中的内存是朝生暮死的,即这些内存存活不过一个垃圾回收周期,所以Survivor的内存很小。

新生代进入老年代

    新生代回收里说道数据写入堆的时候,都是进入的新生代,那什么时候数据才会进老年代呢,有以下几种情况。

  1. 每一个对象的对象头中都有一个Age属性,当首次进入堆内存时,age=0,当对象在新生代的复制算法中倒来倒去的时候,age会不断+1,默认情况下到age>=15时,即这个对象在新生代中扛过了15次垃圾回收,这是这个对象就会从新生代移动到老年代。
  2. 新生代中Survivor区很小,我们前面说是因为认为新生代的内存都是朝生暮死的,但是这个并不一定,也有可能某一次垃圾回收时,会有较多的对象不是朝生暮死,造成Survivor内存不足,这时候老年代就是给Survivor作为担保。当某一次垃圾回收往Survivor复制时存不下了,那对象就会直接被移到老年代。
  3. 对于一些很大的对象,比如一个很长的ArrayList,往往写入时就会被直接写入老年代,而不是新生代。原因是这些大对象往往需要很长的连续内存,新生代可能无法提供的。
  4. 当新生代垃圾回收时,Survivor中,同一age的对象超过Survivor一半内存是,那age值大于这个值的所有对象都会被整体移入老年代。比如S1为1G,age=3的对象有513MB,此时age>=3的所有对象会全部被移动到老年代,而不是再复制到S2中去。
Minor GC与Full GC

    需要注意的是,我们上面讲新生代的垃圾回收,也叫Minor GC。由于新生代中对象产生和回收是很频繁,所以Minor GC也是很频繁的,但是由于新生代较小,所以Minor GC也很快。
    而Full GC则是指全局垃圾回收包括新生代、老年代、以及堆以外的方法区。由于Full GC涉及的内存范围很大,所以速度也很慢,一般是Minor GC十倍以上。Minor GC和Full GC都是会产生stop the world(即整个jvm停下里等待垃圾回收,目前是不存在能够不产生stop the world的GC,只能无限的缩短整个时间),但是由于Minor GC很快,所以宏观上来看外部是几乎感知不到的。但是Full GC由于相对较慢,有时候能感受到明显的短暂卡顿。

Full GC发生条件
  1. 手动调用System.gc();调用完之后,并不一定会立即执行GC,这是给jvm的一个提议,但jvm不一定听取。一般正常情况下不会需要System.gc()。
  2. 担保失败。上面讲新生代的时候讲过,老年代会为新生代的Survivor进行内存担保,但是也有情况老年代内存也不够了,这时候就出现了担保失败,就会造成Full GC。
  3. Concurrent Failure。这是在CMS收集器中会出现的一种错误,会造成Full GC。

扩展

    一般情况下,垃圾回收的过程都是需要进行stop the world的,也就是所有工作线程都需要停下来等待gc一个人进行工作。比如可达性分析时进行枚举GC ROOTS,如果使用到OopMap数据结构就强制要求所有线程停在安全点,等gc工作。或者如果你使用复制法或者整理法进行内存清理时,由于你动了正在被使用的内存,如果你不停止所有用户线程,那你一动这些线程使用的内存,那用户线程就会出现问题。
    正式由于gc造成的stop the world,后一章讲真正的回收器时,除了实现本章的算法,所有垃圾收集器都把目光聚焦在对stop the world的优化与权衡上。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值