JVM垃圾回收(下)

首先我们来看一下JAVA对象的生命周期:

在这里插入图片描述

我们可以看出,大部分的Java对象只会存活一小波时间,但是存活下来的这少部分的Java对象,将会存活很长一段时间。

之所以要提到这个假设,是因为它早就了JVM的分代回收思想。简单来说,就是将堆空间划分成为两代,分为新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长的时候,就会被移动到老年代。

JVM可以给不同代使用不同的GC算法:

对于新生代,我们猜测大部分的Java对象只能存活一小段时间,所以就可以采用耗时短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。

对于老年代,我们猜测大部分的垃圾已经被回收过了,所以老年代的对象大概率一直存活下去,只不过当真正触发针对老年代的回收的时候,就代表这个假设出错了,或者堆的空间耗尽了。

所以这时候,JVM就会进行一次全堆扫描,成本很高。(不过现在的发展情况是都在往并发的角度去收集)

之前,我们详细的说明过G1 GC https://blog.csdn.net/qq_41936805/article/details/95982684

这次就来针对于新生代的GC,简称Minor GC,首先来看一下JVM中的堆是如何划分的。

JVM的虚拟机的堆划分

前面说到,JVM将堆分为老年代和新生代,其中,新生代又被分为Eden区,以及两个大小相同的Survivor区。

默认情况下,JVM采取的是一种动态分配的策略(对应Java虚拟机参数:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况调整Eden区和Survivor区的比例。

不过你也可以不动态分配,用-Xx:SurvivorRatio来固定这个比例就行。但是需要注意的是,其中一个Survivor区会一直为空,因此比例越低,浪费的堆空间将越高。

在这里插入图片描述
通常来讲,当我们调用new指令的时候,它会在Eden区划出一块作为存储对象的内存,由于堆空间是线程共享的,因此直接在这里边划空间是需要同步进行的。但就是因为这样,有可能就会两个对象公用一段内存了。

对于这种情况,JVM的做法的预先流出内存空间,并且只让这个对象在这里存储。那如果内存没分够咋整?

方法就是再多分一些就好了,这项技术被称之为TLAB(Thread Local Allocation Buffer,对应的虚拟机参数为 -Xx:UseTLAB,默认开启)

具体来说,每个线程可以向JVM申请一段连续的内存,比如2048字节,作为线程私有的TLAB。这个操作需要加锁,线程㤇维护两个指针(实际可能更多,但是就这两个最重要),一个指向TLAB中空余内存吧的起始位置,一个指向TLAB末尾。

接下来的new指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。

如果加法后空余内存指针的值仍然≤指向末尾的指针,就分配成功,不然就需要重新申请新的TLAB

当Eden区的空间耗尽了怎么办?这个时候JVM就会触发一次Minor GC,来手机新生代的垃圾。存活下来的对象,就会被送到Survivor 区。

前面提到,新生代共有连个Survivor区,我们分别用from和to来指代。其中to指向的Survivor是空的。

当发生Minor GC的时候,Eden区和from指向的Survivor区中存活对象会被复制到to指向的Survivor区中,然后交换from 和 to指针,以保证下一次Minor GC的时候,to指向Survivor区还是空的。

换句话说,当发生Minor GC的时候,我们使用的是标记-复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中。李翔情况下,Eden区的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记-复制算法的效果很好。

Minor GC的另一个好处就是不会对整个堆进行垃圾回收。但是它却又一个问题,老年代要是偶然引用了新生代的对象,也就是说,在标记存活对象的时候,也要扫描老年代的引用情况,如果真有这种情况,那么这个老年代就会被作为GC ROOTS,导致全表扫描。

卡表

HotSpot给出的一种解决方案就是卡表(Card Table)的技术,该技术将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否能够存有指向新生代对象的引用。如果可能存在,我们就认为这张卡是脏的。

在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC ROOTS里。当完成所有脏卡的扫描后,JVM便会将脏卡的标识位清零。

由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

在Minor GC之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

因为:

首先,如果要保证每个指向新生代的卡都认定是脏卡,那么JVM需要截获每个引用型实例变量的写操作,并且做出对应的写标识位操作。

这个操作在解释执行器中比较容易实现,但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障。它要去尽可能简洁,这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。

因此,写屏障会将一切引用都当做可能指向新生代的引用,这必然会带来一定的开销,但是会增大吞吐量,只不过在高并发环境下又有些特殊。

在高并发环境下,写屏障又带来了虚共享的问题,在HotSpot中,卡表是通过byte数组实现的,对于一个64字节的缓存行来说,如果用它来加载部分卡表,那么它将对应64张卡,也就是32KB内存。

如果同时有两个Java线程,在这32KB内存中进行引用更新操作,那么也将会造成存储卡表的统一发部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。

总结

JVM将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。其中,新生代分为Eden区和两个大小一致的Survivor区,其中一个必须为空。

在只针对新生代的Minor GC中,Eden区和非空Survivor区存活对象会被复制到空的Survivor区中,当Survivor区中存活对象复制次数超过一定数值,就会晋升为老年代。

因为Minor GC只针对新生代进行垃圾回收,所以在枚举GCRoots的时候,它需要考虑从老年代到新生代的应用。为了避免扫描整个老年嗲,JVM引入了名为卡表的技术,标出可能存在老年代到新生代引用的内存区域。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值