垃圾收集算法


在Java中,垃圾收集(Garbage Collection, GC)是一种自动化的内存管理机制,它负责回收不再使用的对象所占用的内存。Java虚拟机(JVM)提供了多种不同的垃圾收集器,每种垃圾收集器都有其特点和适用场景,使用的也是不同的垃圾收集算法。以下是几种常见的垃圾收集算法:

标记-清除算法

标记-清除(Mark-Sweep)算法是最基础的收集算法,它分为标记和清除两个阶段。
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实就是垃圾判定算法中介绍的过程。
该算法有两个缺点:

  • 效率问题:标记和清除两个过程的效率都不高
  • 空间碎片:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

执行过程:
在这里插入图片描述

复制算法

复制(Copying)算法解决了效率低的问题。它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,简单高效。
缺点是:可用内存缩小为原来的一半,代价有点儿高。
执行过程:
在这里插入图片描述
现在的商业虚拟机都采用这种收集算法来回收新生代。
通常将内存分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor区,最后清理掉Eden和刚用过的Survivor区。据研究表明,新生代中的对象98%是朝生夕死的。
HotSpot默认Eden和Survivor的大小比例是8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的90%。
当然,并98%只是一般场景下的统计数据,实际上没办法保证每次回收都只有不多于10%的对象存活。当Survivor空间不够用时,需要依赖其他内存进行分配担保(Handle Promotion)。

标记-压缩算法

复制算法在对象存活率较高时需要进行较多的复制操作,效率会降低很多。更关键的是需要有额外的空间进行分配担保,以应对内存中所有对象100%存活的极端情况,所以老年代一般不能直接选择这种算法。
标记-压缩(Mark-Compact)算法,也叫标记-整理算法。标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
执行过程:
在这里插入图片描述

分代收集算法

现在虚拟机的垃圾收集都采用分代收集(Generational Collection)算法。这种算法根据对象存活周期的不同将内存划分为几块。
一般把Java堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。

  • 新生代:每次收集时都有大批对象死去,只有少量存活,选用复制算法,只需要复制出少量存活对象就可以快速完成收集。
  • 老年代:对象存活率高、没有额外空间对它进行分配担保,一般使用标记-清理或标记-压缩算法。

卡表

内存分代以后,为了支持高频率的新生代回收,HotSpot VM使用了一种叫作卡表(Card Table)的数据结构。
卡表是一个字节的集合,每一个字节可以用来表示老年代某一区域中的所有对象是否持有新生对象的引用。根据卡表将堆空间划分为一系列2次幂大小的卡页(Card Page),卡表用于标记卡页的状态,每个卡表项对应一个卡页。HotSpot VM的卡页大小为512字节,卡表为一个简单的字节数组,即卡表的每个标记项为1个字节。
在这里插入图片描述
当老年代中的某个卡页持有了新生代对象的引用时,HotSpot VM就把对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),默认标识为0。
这样在进行YGC时,可以不用全量扫描所有老年代对象或不用全量标记所有活跃对象来确定对年轻代对象的引用关系,只需要扫描卡表项为dirty的对应卡页,把它们加入GC Roots中一并扫描。而卡表项为0的区域一定不包含对新生代的引用。这样可以提高扫描效率,减少YGC的停顿时间。
在实际应用中,仅靠卡表是无法完成具体扫描任务的,还需要与偏移表、屏障等配合才能更好地完成标记卡表项及扫描卡页中的对象等操作。

写屏障

卡表缩减了GC Roots扫描范围的问题,但卡表元素如何维护呢?例如它们何时变脏、谁来把它们变脏等。
答案也简单:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型赋值的那一刻。但如何在对象赋值时更新卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景呢?经过即时编译后的代码已经是纯粹的机器指令流,这就必须找到一个在机器码层面的手机,把维护卡表的动作放到每一个赋值操作中。
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术来维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”动作的一个AOP切面,也就是说赋值的前后都在写屏障的覆盖范畴内。赋值前后的写屏障分别叫写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier)。

这里的写屏障与解决并发乱序执行问题中的内存屏障不同。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

伪共享问题

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享(False Sharing)”问题。这是处理并发底层细节时经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低,这就是伪共享问题。
为了避免该问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
在JDK7之后,HotSpot虚拟机增加一个参数:-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值