常见 GC 算法?年轻代老年代?年轻代如何提升老年代?看完这篇你就懂了!!

在这里插入图片描述

虽然 Java 不用“手动管理”内存回收,代码写起来很顺畅。但是你有没有想过,这些内存是怎么被回收的?

其实,JVM 是有专门的线程在做这件事情。当我们的内存空间达到一定条件时,会自动触发。这个过程就叫作 GC,负责 GC 的组件,就叫作垃圾回收器。

GC 算法组成

标记

垃圾回收的第一步,就是找出活跃的对象,也就是根据 GC Roots 遍历的所有的可达对象,这个过程,就是标记
在这里插入图片描述
如图所示,圆圈代表的是对象。绿色的代表 GC Roots,红色的代表可以追溯到的对象。可以看到标记之后,仍然有多个灰色的圆圈,它们都是被回收的对象。

清除

清除阶段就是把未被标记的对象回收掉。

在这里插入图片描述
但是这种简单的清除方式,有一个明显的弊端,那就是碎片问题。

比如我申请了 1k、2k、3k、4k、5k 的内存。
在这里插入图片描述
由于某种原因 ,2k 和 4k 的内存,我不再使用,就需要交给垃圾回收器回收。

在这里插入图片描述
这个时候,我应该有足足 6k 的空闲空间。接下来,我打算申请另外一个 5k 的空间,结果系统告诉我内存不足了。系统运行时间越长,这种碎片就越多。

复制

解决碎片问题没有银弹,只有老老实实的进行内存整理

复制的思路就是提供一个对等的内存空间,将存货的对象复制过去,然后清除原来的内存空间

在程序设计中,一般遇到扩缩容或者碎片整理问题时,复制算法都是非常有效的,比如 HashMap 的扩容和 Redis 的 rehash。

在这里插入图片描述
这种方式看似非常完美的,解决了碎片问题。但是,它的弊端也非常明显。它浪费了几乎一半的内存空间来做这个事情,如果资源本来就很有限,这就是一种无法容忍的浪费。

整理

其实,不用分配一个对等的额外空间,也是可以完成内存的整理工作。

你可以把内存想象成一个非常大的数组,根据随机的 index 删除了一些数据。那么对整个数组的清理,其实是不需要另外一个数组来进行支持的,使用程序就可以实现。

它的主要思路,就是移动所有存活的对象,且按照内存地址顺序依次排列,然后将末端内存地址以后的内存全部回收。

在这里插入图片描述

但是,对象的引用关系一般都是非常复杂的,从效率上来说,一般整理算法是要低于复制算法的。

STW

如果在垃圾回收的时候(不管是标记还是整理复制),又有新的对象进入怎么办?
为了保证程序不会乱套,最好的办法就是暂停用户的一切线程。也就是在这段时间,你是不能 new 对象的,只能等待。表现在 JVM 上就是短暂的卡顿,什么都干不了。这个头疼的现象,就叫作 Stop the world。简称 STW。

常见 GC 算法

我们简要介绍了一些常见的内存回收算法,目前,JVM 的垃圾回收器,都是对几种朴素算法的发扬光大。

标记-清除算法

分为 标记 和 清除
首先从每个 GC Roots 出发依次标记有引用关系的对象,最后清除没有标记的对象

特点

执行效率不稳定

如果堆包含大量对象且大部分都需要回收,必须进行大量的标记清除算法,导致效率随对象数量的增加而降低

同时,存在内存空间碎片化的问题,会产生大量不连续的内存碎片,导致以后需要分配大对象时容易触发 Full GC

标记-复制算法

分为 标记 和 复制

为了解决内存碎片问题,可将内存按照容量划分为大小相等的两块,每次只使用其中一块,当使用的这块空间用完了,就将存活对象复制到另一块,再把已使用过的内存空间一次清理掉,主要用于新生代

特点

实现简单,运行高效,解决了内存碎片化问题。但是代价是一次只能用到一半内存,浪费空间

标记-整理算法

标记-复制算法在对象存活率较高时进行较多复制操作,效率低,如果不想浪费空间,就需要有额外的空间分配担保,来应对被使用内存中所有对象都存活的极端情况,所以老年代中一般不使用此算法

分为 标记 和 整理
首先从每个 GC Roots 出发依次标记有引用关系的对象,让所有存活对象都向内存空间一端移动,然后清理掉边界以外的内存

特点

标记-整理 和 标记-清除 的差异在于前者是一种移动式的算法而后者是非移动的。如果移动对象存活,尤其是在老年代这种每次回收都有大量对象存活的区域,是一种极为负担的操作,而且移动必须全程暂停用户线程,如果不移动对象就会导致空间碎片问题,只能依赖更复杂的内存分配器和访问器解决

总结

标记-清除(Mark-Sweep)
效率一般,缺点是会造成内存碎片问题。

标记-复制算法(Copy)
复制算法是所有算法里面效率最高的,缺点是会造成一定的空间浪费。

标记-整理(Mark-Compact)
效率比前两者要差,但没有空间浪费,也消除了内存碎片问题。

所以,没有最优的算法,只有最合适的算法。

分代

基于弱代假设 (Weak Generational Hypothesis):大部分对象生命周期短,而其他对象则很可能存活很长时间。

将堆内存分为:

  • 年轻代(Young Generation): 存放生命周期较短的对象,使用复制算法。
  • 老年代(Old Generation): 存放生命周期较长的对象,使用标记-清除或标记-整理算法。
    在这里插入图片描述

年轻代

HotSpot 将新生代划分为一块较大的 Eden (伊甸园)区和两块较小的 Survivor(幸存者)区。

当年轻代中的 Eden 区分配满时,就会触发年轻代的 GC(Minor GC)。具体过程如下:

  • 在 Eden 区执行了第一次 GC 之后,存活的对象会被移动到其中一个 Survivor 分区(以下简称from);
  • Eden 区再次 GC,这时会采用复制算法,将 Eden 和 from 区一起清理。存活的对象会被复制到 to 区;接下来,只需要清空 from 区就可以了。

所以在这个过程中,总会有一个 Survivor 分区是空置的。Eden、from、to 的默认比例是 8:1:1,所以只会造成 10% 的空间浪费。

在这里插入图片描述

老年代

老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。

那么,对象是怎么进入老年代的呢?有多种途径。

(1)提升(Promotion)

如果对象够老,会通过“提升”进入老年代。

关于对象老不老,是通过它的年龄(age)来判断的。每当发生一次 Minor GC,存活下来的对象年龄都会加 1。直到达到一定的阈值,就会把这些“老顽固”给提升到老年代。

这些对象如果变的不可达,直到老年代发生 GC 的时候,才会被清理掉。

这个阈值,可以通过参数 ‐XX:+MaxTenuringThreshold 进行配置,最大值是 15,因为它是用 4bit 存储的(所以网络上那些要把这个值调的很大的文章,是没有什么根据的)。

(2)分配担保

看一下年轻代的图,每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。

(3)大对象直接在老年代分配

超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的。默认为 0,意思是全部首选 Eden 区进行分配。

(4)动态对象年龄判定

有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。比如,如果幸存区中相同年龄对象大小的和,大于幸存区的一半,大于或等于 age 的对象将会直接进入老年代。

这些动态判定一般不受外部控制,我们知道有这么回事就可以了。通过下图可以看一下一个对象的分配逻辑。

在这里插入图片描述

卡片标记

你可以看到,对象的引用关系是一个巨大的网状。有的对象可能在 Eden 区,有的可能在老年代,那么这种跨代的引用是如何处理的呢?由于 Minor GC 是单独发生的,如果一个老年代的对象引用了它,如何确保能够让年轻代的对象存活呢?

其实,老年代是被分成众多的卡页(card page)的(一般数量是 2 的次幂)。

卡表(Card Table)就是用于标记卡页状态的一个集合,每个卡表项对应一个卡页。

如果年轻代有对象分配,而且老年代有对象指向这个新对象, 那么这个老年代对象所对应内存的卡页,就会标识为 dirty,卡表只需要非常小的存储空间就可以保留这些状态。

垃圾回收时,就可以先读这个卡表,进行快速判断。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值