60、垃圾回收算法
上一节我们讲解了垃圾回收的第一步:如何判定对象是否可以被回收,也就是可达性分析
本节我们讲解垃圾回收的第二步:如何对死亡对象进行回收,也就是垃圾回收算法,分代垃圾回收算法是主流的垃圾回收算法
虚拟机将堆空间分为年轻代和老年代,针对不同的分代单独进行垃圾回收
- 针对年轻代的垃圾回收叫做 YoungGC
- 针对老年代的垃圾回收叫做 FullGC
- FullGC 要比 YoungGC 慢很多
那么请你思考,同为垃圾回收,为什么 FullGC 要比 YoungGC 慢很多呢?带着这个问题,我们开始本节的学习
1、基础垃圾回收算法
基础的垃圾回收算法有三种,分别是:标记 - 清除、标记 - 整理、标记 - 复制
从名称上我们也可以看出,无论哪种算法,第一步总是先标记,也就是使用可达性分析找出需要被回收的死亡对象
它们的不同点在于第二步(清除 / 整理 / 复制),接下来我们依次讲解一下这三个算法
1.1、标记 - 清除
在标记-清除算法中,虚拟机将死亡对象所占用的内存空间释放,放入到空闲空间列表中
当创建对象时,虚拟机从空闲空间列表中,查找大小合适的空闲空间分配给对象
标记 - 清除算法存在明显的内存碎片问题,经过标记 - 清除之后得到的空闲空间不连续
- 在创建对象时需要在空闲空间列表中,查找大小合适的空闲空间,比较耗时
- 大对象有可能无法找到大小合适的存储空间,即便空闲空间总和超过对象的大小,因此单纯的标记 - 清除算法比较少用
1.2、标记 - 整理
标记 - 整理算法也叫做标记 - 压缩算法,在标记 - 清除算法的基础之上,标记 - 整理算法额外增加了 "整理" 的环节
先使用可达性分析标记存活对象所占用的内存空间,然后顺序遍历内存空间,将存活对象移动到内存的一端,从而解决了内存碎片问题
如上图所示,空闲空间为一片连续的内存空间,相对于标记 - 清除算法,标记 - 整理算法不需要存储空闲空间列表,只需要记录空闲空间的起止地址即可
虚拟机从空闲空间的起始地址依次为对象分配内存空间,这样分配的效率更高
1.3、标记 - 复制
标记 - 复制算法将整个内存分为轮流使用的两块内存
- 一块内存为:对象分配内存空间,称为工作内存
- 一块内存为:复制时备用,称为备用内存
当工作内存使用完之后,标记 - 复制算法将这块内存中的存活对象,逐一复制到备用内存中,然后两块内存互换角色
标记 - 复制算法解决了标记 - 清除算法的内存碎片问题,但是又引入了新的问题
- 内存只能有一半的利用率
- 如果对象的存活时间比较长,那么对象会在两块内存之间来回复制多次,比较浪费时间,如何来解决这个问题,我们稍后讲解
2、分代垃圾回收算法
上上一节我们讲了 JVM 内存模型,JVM 将所使用的内存,根据功能的不同,划分为不同的分区,它们分别是:方法区、程序计数器、堆、虚拟机栈、本地方法栈
- 程序计数器使用的内存非常少,并且随着线程的创建而创建,随着线程的销毁而销毁
- 虚拟机栈和本地方法栈存储的是方法对应的栈帧,栈帧随着方法的调用而入栈,随着方法的退出而出栈
- 因此:程序计数器、虚拟机栈、本地方法栈这三个分区中的垃圾,会随着生命周期的结束而被立刻回收,不需要经过虚拟机的垃圾回收线程的处理
堆中存储的是对象,对象供所有线程共享,作用域范围大,生命周期长,在使用完成之后并不会被立刻回收,因此堆是虚拟机进行垃圾回收的重点工作分区
不过除了堆之外,方法区也会涉及垃圾回收,方法区中的一些无用的类或者无用的 String 常量对象也需要被回收
- 无用的 String 常量对象指的是:存储在字符串常量池中的并且没有变量引用的 String 对象
- 无用的类指的是要同时满足以下几个条件的类
- 该类的所有对象都已经被回收
- 类的 Class 对象没有任何变量在引用
- 加载该类的类加载器已经卸载
综上所述,虚拟机中的垃圾回收针对的内存分区是:堆和方法区,并且以堆为主,接下来我们重点看下堆
应用程序所创建的对象的生命周期并不相同
- 有的对象的生命周期比较短,比如:方法内的对象
对于生命周期较短的对象,我们希望以较高的频率执行垃圾回收,尽快释放所占用的内存空间 - 有的对象的生命周期比较长,比如:使用 Spring 框架创建的单例对象
对于生命周期较长的对象,我们希望以较低的频率执行垃圾回收,避免无效的垃圾回收
因此虚拟机将堆分为年轻代(Young Generation)和老年代(Old Generation)两个分区
- 年轻代存储生命周期比较短的对象(比如:方法内的对象)
- 老年代存储生命周期比较长的对象(比如:使用 Spring 框架创建的单例对象)
虚拟机针对不同的分代(年轻代和老年代),使用不同的基础垃圾回收算法(标记 - 清除、标记 - 整理、标记 - 复制)
在基础垃圾回收算法之上增加分代之后的垃圾回收算法,叫做分代垃圾回收算法
接下来我们就来详细讲解一下年轻代和老年代,以及对应的基础垃圾回收算法
3、年轻代和 YoungGC
新创建的对象会分配在年轻代,因此年轻代也叫新生代(New Generation)
根据统计数据发现,在应用程序中,大部分对象都朝生夕死,存活率比较低,因此年轻代适合使用标记 - 复制算法进行垃圾回收
在进行垃圾回收时,虚拟机只需要复制少量存活对象,复制耗时少,执行效率比较高
前面提到标记 - 复制算法会将整个内存平均分为两块,同一时间只有一块内存在使用,内存利用率只有 50%
为了提高内存利用率,虚拟机对标记 - 复制算法进行了优化,将年轻代分为不均等的三个分区:一个 Eden 区和两个大小相等的 Survivor 区
- 虚拟机将 Eden 区和其中一个 Survivor 区作为分配对象所用,也就是工作分区
- 将另一个 Survivor 区作为复制备用,也就是备用分区
- 除此之外,我们将供分配对象所用的 Survivor 区叫做 From Survivor 区,将复制备用的 Survivor 区叫做 To Survivor 区
当工作分区(Eden 区和 From Survivor 区)满了之后,虚拟机便执行标记 - 复制算法,将 Eden 区和 From Survivor 区中的存活对象,复制到 To Survivor 区
当一次垃圾回收结束之后,两个 Survivor 区的角色互换,原来充当 From Survivor 的现在充当 To Survivor,原来充当 To Survivor 的现在充当 From Survivor
一般情况下 Eden 区比较大,Survivor 区比较小
默认情况下,虚拟机根据每次垃圾回收之后存活对象的比例,动态调整 Eden 区和 Survivor 区的大小比例,我们也可以通过 JVM 参数 -XX:SurvivorRatio 设置固定的比例
比如:-XX:SurvivorRatio=8 表示 Eden 区和一个 Survivor 区的大小比例为 8,也就是说,Eden 区占年轻代大小的 80%,From Survivor 和 To Survivor各占 10%
工作内存为 Eden 区加 From Survivor,因此内存利用率为 90%
尽管 To Survivor 区比较小,基于前面讲到的 "大部分对象都朝生夕死",大部分情况下,都足够存下一次垃圾回收之后的存活对象
细心的你应该已经开始有疑问:万一 To Survivor 区存不下一次垃圾回收之后的存活对象,该怎么办呢?
对于这种情况,虚拟机会借用老年代的部分空间,将存不下的对象存储在老年代中,老年代起到一个担保的作用,因此这种处理机制叫做 "空间分配担保机制"
当然这也会导致一定的问题:部分生命周期比较短的对象存储到了老年代,即便这些对象早早已经死亡,依然需要等待很长时间才能被回收
你可能还会有疑问:如果老年代也没有足够的空间用于分配担保,那么又该怎么办呢?
对于这种情况,虚拟机会执行 FullGC(待会会讲到),对整个堆空间进行一次大规模的垃圾回收,腾出更多的空闲空间来
如果 FullGC 之后,老年代仍然没有足够的空间,那么就只能抛出 OOM(内存溢出)Error 了
4、老年代和 FullGC
刚刚讲到,基于空间分配担保机制,新生代存不下的对象会进入老年代,除此之外,大对象和长期存活对象也会进入老年代
- 大对象指的是:占用大量连续内存空间的对象,比如大的字符串或数组,默认情况下,不管对象有多大,都会在年轻代创建
但是如果我们设置了 JVM 参数 -XX:PretenureSizeThreshold,那么当对象的大小超过这个预设的阈值之后,对象会直接在老年代创建
这样做的目的是避免生命周期比较长的大对象在年轻代中被反复复制 - 长期存活的对象指的是:经过多次年轻代的垃圾回收仍然存活的对象,虚拟机在对象的对象头中记录对象的 GC 年龄,每经过一次 GC,GC 年龄就增一
当 GC 年龄超过一定阈值(默认为 15,可以通过 -XX:MaxTenuringThreshold 来设置)之后,对象便会从年轻代移动到老年代
不过人为设定一个固定值显然不够灵活,于是虚拟机设计了动态年龄判定机制,结合存活对象的多少来动态的设置最大 GC 年龄
动态年龄判定机制具体的做法是:统计 YoungGC 后,处于每个 GC 年龄值的对象占 To Survivor 区的比例
按照年龄从小到大累加对象所占比例,当累加到年龄为 X 的对象时,如果累计对象所占比例超过 50%(此值可以通过 JVM 参数 -XX:TargetSurvivorRatio来设置)
那么 GC 年龄 >= X 的对象都将直接进入老年代,并会不等到 GC 年龄大于 15
- 我们举个例子解释一下:根据统计我们发现
GC 年龄 = 3 的对象占 To Survivor 区大小的 20%,GC 年龄 = 6 的对象占 To Survivor 区大小的 40%,GC 年龄 = 8 的对象占 To Survivor 区大小的 15%
在这个例子中,GC 年龄 = 3 和 GC 年龄 = 6 的对象占 To Survivor 比例为 60%,超过 50%,因此 GC 年龄 = 6 和 GC 年龄 = 8 的对象会从年轻代移动到老年代 - 前面讲到,年轻代使用标记 - 复制算法进行垃圾回收,原因是每次对年轻代垃圾回收之后,存活对象比较少,复制耗时少
而老年代正好相反,老年代中的对象生命周期比较长,每次垃圾回收之后,存活对象比较多
如果使用标记 - 复制算法进行垃圾回收,那么就会涉及到大量对象的复制,执行效率比较低
因此老年代一般不采用标记 - 复制算法,而是采用标记 - 整理算法或标记 - 清除算法进行垃圾回收 - 老年代的垃圾回收叫做 OldGC,但是在主流虚拟机的实现中,比如 HotSpot JVM,在对老年代进行垃圾回收的同时
虚拟机会一并对年轻代和永久代(或元空间)进行垃圾回收,我们把这种大规模的垃圾回收叫做 FullGC
我们再来看下本节开头的问题:同为垃圾回收,为什么 FullGC 要比 YoungGC 慢很多?
有了以上知识的铺垫,这个问题就比较简单了
- YoungGC 只针对年轻代进行垃圾回收,年轻代中的对象的存活率比较低,可达性分析需要遍历的对象和需要进行复制的对象比较少
所以 YoungGC 比较快速,因此 YoungGC 也被称为 MinorGC(Minor 轻微的) - FullGC 针对年轻代、老年代、永久代(或元空间)进行垃圾回收,并且老年代和永久代(或元空间)中的对象的存活率比较高
可达性分析需要遍历的对象和垃圾回收需要处理的对象比较多,所以 FullGC 比较慢,因此 FullGC 也被称为 MajorGC(Major 主要的)
5、课后思考题
本节中讲到,我们可以通过设置 JVM 参数 -XX:MaxTenuringThreshold,来指定对象在年轻代的最大 GC 次数,不过参数值不能超过 15,这是为什么呢?
GC 次数存储在对象头中的 Mark Word 中的 GC age 字段,Java 仅为此字段分配了 4 bits 大小的存储空间,因此 GC age 字段可以表示的最大值为 15
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17498613.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步