垃圾回收算法-通用的分代垃圾回收机制
垃圾回收算法-通用的分代垃圾回收机制
概要
分代垃圾回收机制,是基于这样一个事实:不同对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。
一、判断对象是否可回收
首先思考一个问题,内存堆中那么多对象,回收器要回收哪些对象?怎么判断出这些要回收的对象呢?因此对于垃圾回收,判断并标识对象是否可回收是第一步。从理论层面来说,判断对象是否可回收一般两种方法。
1. 引用计数器算法
每当对象被引用一次计数器加 1,对象失去引用计数器减 1,计数器为 0 是就可以判断对象死亡了。这种算法简单高效,但是对于循环引用或其他复杂情况,需要更多额外的开销,因此 Java 几乎不使用该算法。
2. 根搜索算法-可达性分析算法
所谓可达性分析是指,顺着 GCRoots 根一直向下搜索,整个搜索的过程就构成了一条“引用链”,只要在引用链上的对象叫做可达,在引用链之外的(说明跟 GCRoots 没有任何关系)叫不可达,不可达的对象就可以判断为可回收的对象。 哪些对象可作为 GCRoots 对象呢? 包括如下:
1)虚拟机栈帧上本地变量表中的引用对象(方法参数、局部变量、临时变量)
2)方法区中的静态属性引用类型对象、常量引用对象
3)本地方法栈中的引用对象(Native 方法的引用对象)
4)Java 虚拟机内部的引用对象,如异常对象、系统类加载器等
5)所有被同步锁(synchronize)持有的对象
6)Java 虚拟机内部情况的注册回调、本地缓存等
GCRoots可达性分析,如下图:
二、堆空间的基本结构
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是堆内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
1) 新生代(Young Generation)
2)老年代(Old Generation)
3)永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
说明:JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存。
三、分代垃圾回收
垃圾收集器将堆做了进一步划分,但具体的划分方式取决于所使用的垃圾收集器。大多数垃圾收集器(如 Serial GC、Parallel GC、CMS)会将堆内存划分为不同的代,主要是新生代(Young Generation)和老年代(Old Generation)。
堆空间结构如下图:
1. 新生代
所有新生成的对象首先都是放在Eden区。新生代的目标就是尽可能快速地收集到那些生命周期短的对象,对应的是Minor GC,每次Minor GC会清理新生代的内存。算法采用效率较高的复制算法,频发的操作,但是会浪费内存空间,当“新生代”区域存放满对象后,就将对象存放到老年代区域。
1) 空间比例
新生代使用的垃圾回收算法是复制算法,所以新生代又被分为了 Eden 和Survivor;空间大小比例默认为8:2
Survivor又被分为了S0、S1,这两个的空间大小比例为1:1
2) 为什么 Survivor 分区是 2 个?
如果 Survivor 分区有 2 个分区,我们就可以把 Eden、From Survivor、To Survivor 分区内存比例设置为 8:1:1 ,那么任何时候新生代内存的利用率都为90% ,这样空间利用率基本是符合预期的。再者就是虚拟机的大部分对象都符合“朝生夕死”的特性,所以每次新对象的产生都在空间占比比较大的 Eden 区,垃圾回收之后再把存活的对象方法存入 Survivor 区,如果是 Survivor 区存活的对象,那么“年龄”就 +1 ,当年龄增长到 15 (可通过 -XX:+MaxTenuringThreshold 设定)对象就升级到老生代。
2. 老年代
在新生代中经历了N(默认值为15)次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。老年代对象越来越多,我们就需要启动Major GC和Full GC(全量回收),来一次大扫除,全面清理新生代区域和老年代区域。
3. 永久代
用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响。JDK7以前就是“方法区”的一种实现。
JDK8 以后已经没有"永久代”了,使用metaspace元数据空间和堆替代。
四、垃圾收集算法
不同的垃圾回收算法都有各自的优缺点,适应于不同的垃圾回收场景
1. 标记-清除算法(Mark-Sweep)
根据名称就可以理解改算法分为两个阶段:首先标记出所有需要被回收的对象,然后对标记的对象进行统一清除,清空对象所占用的内存区域,下图展示了回收前与回收后内存区域的对比,红色的表示可回收对象,橙色表示不可回收对象,白色表示内存空白区域。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。
这种垃圾收集算法会带来两个明显的问题:
1) 效率问题:标记和清除两个过程效率都不高。试想一下如果堆中大部分的对象都可回收的,收集器要执行大量的标记、收集操作。
2)空间问题:标记清除后会产生大量不连续的内存碎片。当有大对象要分配而找不到满足大小的空间时,要触发下一次垃圾收集。
如下图:
2. 标记-复制算法(Mark-Copying)
针对标记-清除算法执行效率与内存碎片的缺点,计算机科学家又提出了一种“半复制区域”的算法。
标记-复制算法将内存分为大小相同的两个区域:运行区域和预留区域。所有创建的新对象都分配到运行区域,当运行区域内存不够时,将运作区域中存活对象全部复制到预留区域,然后再清空整个运行区域内存,这时两块区域的角色也发生了变化,每次存活的对象就像皮球一下在运行区域与预留区域踢来踢出,而垃圾对象会随着整个区域内存的清空而释放掉。
如下图:
标记-复制算法改进了标记-清除算法,在大量垃圾对象的情况下,只需复制少量的存活对象,并且不会产生内存碎片问题,新内存的分配只需要移动堆顶指针顺序分配即可,很好的兼顾了效率与内存碎片的问题。但依然存在下面这些问题:
1) 可用内存变小:可用内存缩小为原来的一半,空间浪费有点多。
2) 不适合老年代:如果存活对象数量比较大,复制性能会变得很差:比如内存中大量对象是存活状态,只有少量的垃圾对象,收集器要执行更多次的复制操作才能释放少量的内存空间,得不偿失。
3. 标记-整理算法(Mark-Compact)
标记-复制算法要浪费一半内存空间,且在大多数状态为存活状态时使用效率会很低,针对这一情况计算机科学家又提出了一种新的算法“标记-整理算法”。标记整理算法的标记阶段与其他算法一样,但是在整理阶段,算法将存活的对象向内存空间的一端移动,然后将存活对象边界以外的空间全部清空,如下图所示:
标记整理算法解决了内存碎片问题,也不存在空间的浪费问题。但是,当内存中存活对象多,并且都是一些微小对象,而垃圾对象少时,要移动大量的存活对象才能换取少量的内存空间。
缺点:由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
四、针对不同分代的垃圾收集
1. Minor GC(也称Young GC)
用于清理新生代区域。
1) 对象优先在Eden区进行分配,如果Eden区满了之后会触发一次Minor GC
2)Minor GC之后从Eden存活下来的对象将会被移动到S0区域,当S0内存满了之后又会被触发一次Minor GC,S0区存活下来的对象会被移动到S1区,S0区空闲;S1满了之后在Minor GC,存活下来的再次移动到S0区,S1区空闲,这样反反复复GC,每GC一次,对象的年龄就涨一岁,默认达到15岁之后就会进入老年代。
对于晋升到老年代的年龄阈值可以通过参数 -XX:MaxTenuringThreshold 设置
3)在Minor GC之后需要发送晋升到老年代的对象没有空间安置,那么就会触发Full GC (这步非绝对,视垃圾回收器决定)
4)采用垃圾收集算法:标记-复制算法
5)在JVM的垃圾收集机制中,为什么对象从新生代晋升到老年代的年龄上限通常是 15?
- 标记位限制:
HotSpot JVM使用一个对象头中的一个4位的字段(age)来存储对象的年龄。
4位可以表示的最大值是15(即2^4-1 )。
- 性能权衡:
新生代的垃圾收集(Minor GC)通常采用的是复制算法(Copying),它的速度很快。
大多数对象在新生代中很快就会被回收,少部分长寿命对象才会晋升到老年代。
将年龄上限设定为15,可以在新生代中进行多次Minor GC,从而在对象晋升到老年代之前,有更多的机会被回收。
如果年龄上限设置得过高,会导致更多长寿命对象停留在新生代,增加新生代的垃圾收集开销;而如果设置得过低,可能会导致老年代过早充满,增加Full GC的频率。
- 经验与优化
经过多年的实践和调优,15 这个值被认为在大多数应用场景下是合理和有效的。
它在保证新生代垃圾收集效率的同时,也能够有效地将长寿命对象晋升到老年代,从而平衡了不同代之间的垃圾收集压力。
2. Major GC
用于清理老年代区域。
触发条件:Major GC的触发条件通常是老年代的内存空间不足,需要进行扩容,或者是根据一定的策略和阈值来触发。
采用垃圾收集算法:标记-清除或标记-整理算法
3. Full GC
用于清理新生代、老年代区域。Full GC 通常会导致应用的暂停时间更长,成本较高,会对系统性能产生影响。
触发条件:Full GC的触发条件通常是整个堆内存空间不足,或者是由于某些特定的垃圾收集器算法需要执行Full GC操作。但是,在某些情况下,例如老年代内存不足,也会触发Full GC。
4. 总结
Eden区:存储了从未通过垃圾回收的新对象
Survivor区:存放垃圾回收后,仍然有用的对象。循环存放,小于15次垃圾回收次数。
Tenured区:老年代区域中存放在新生代区域中经历了15次垃圾回收后仍存活的对象。
五、什么是分配担保机制?
分配担保(Allocation Guarantee)是指在进行Minor GC之前,JVM会检查老年代是否有足够的空间来存放Survivor区和Eden区中所有存活的对象。
这个机制确保即使在最坏的情况下,即Survivor区和Eden区中的所有对象都需要移动到老年代,老年代也有足够的空间来容纳这些对象。
为什么说通过分配担保机制直接进入老年代是安全的?
1. 防止空间不足导致程序崩溃
在进行Minor GC时,如果Survivor区没有足够的空间来存放Eden区和另一个Survivor区中的所有存活对象,这些对象将被移动到老年代。如果没有分配担保机制,老年代没有足够的空间容纳这些对象时,程序可能会崩溃或者抛出OutOfMemoryError异常。
2. 保证GC的成功
分配担保机制确保在进行Minor GC时,不会因为内存不足而导致GC失败。JVM在进行Minor GC之前,会先检查老年代的空间,如果发现老年代空间不足,它会跳过Minor GC,直接触发Full GC(Full Garbage Collection)。Full GC会进行更彻底的垃圾回收,包括对老年代的回收,从而释放更多的空间。
3. 提升性能和稳定性
通过分配担保机制,JVM能够在Minor GC时更高效地处理内存分配和回收,避免频繁的Full GC,从而提升应用程序的性能和稳定性。
六、JVM调优和Full GC
在对JVM调优的过程中,很大一部分工作就是对于Full GC的调节。有如下原因可能导致Full GC:
1. 年老代(Tenured)被写满
2. 永久代(Perm)被写满
3. System.gc()被显式调用(通知虚拟机调用Full GC,相当于建议,能不能被调用由系统决定)
4. 上一次GC之后Heap的各域分配策略动态变化
七、垃圾收集器
垃圾收集算法就像是 Java 中的接口一样,而垃圾收集器是接口的具体实现。所以,不同的厂商,不同版本的虚拟机实现的方式都有所不同。甚至是很大的差别。
下图是常见的 HotSpot 虚拟机中的垃圾收集器:
其中,新生代有 Serial、ParNew、Parallel Scavenge,老年代包括 CMS、MSC、Parallel old,收集器之间的连线说明两者可以搭配使用。
垃圾收集器的选择和工作方式通常依赖于堆的分代模型,以下是常见的垃圾收集器在新生代和老年代的分工情况:
1. Serial 垃圾收集器
Serial 是最基本,历史最悠久,也是最简单的一个收集器。它是一个单线程的收集器。
新生代:使用 Serial GC,单线程执行,回收新生代中的垃圾。
老年代:使用 Serial Old GC,Serial Old 收集器是 Serial 收集器的老年代版本,同样是单线程的,使用的是”标记-整理算法“。
适用场景:适用于单处理器的环境,或者内存较小、应用简单的场景。
配置:-XX:+UseSerialGC
2. Parallel Scavenge 垃圾收集器
新生代:使用 Parallel GC,多线程回收,称为 Parallel Scavenge 或 Minor GC,目标是通过多线程提高回收效率。
老年代:使用 Parallel Old GC,多线程回收老年代的对象。同样,Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法”。需要注意的是,如果老年代使用 Parallel Old 那么新生代就只能使用 Parallel Scavenge 与之配合。
适用场景:追求高吞吐量的应用,如批处理和后台任务。
配置:-XX:+UseParallelGC(开启年轻代并行回收),-XX:+UseParallelOldGC(开启老年代并行回收)
3. CMS(Concurrent Mark-Sweep)垃圾收集器
新生代:使用 Parallel Scavenge,多线程并行回收新生代中的对象。
老年代:使用 CMS 垃圾收集器,主要通过并发标记和清理来回收老年代对象,减少长时间的暂停。
适用场景:对延迟敏感的场景,适合响应时间优先的应用(如 Web 应用服务器)。
配置:-XX:+UseConcMarkSweepGC
4. G1(Garbage First)垃圾收集器
新生代和老年代:G1 GC 打破了传统的分代垃圾收集模式,将堆内存划分为多个Region,这些 Region 可以动态分配给新生代和老年代。G1 通过并发标记和回收来优先处理垃圾最多的区域,并能够同时处理新生代和老年代中的对象。G1 会在执行 Young GC 时专门处理新生代区域,在Mixed GC时同时回收老年代和新生代区域。
适用场景:适合大内存、低延迟的应用场景,尤其是服务器端应用。
配置:-XX:+UseG1GC
5. ZGC(Z Garbage Collector)
新生代和老年代:ZGC 和 G1 类似,打破了传统的分代结构,基于 Region 管理整个堆内存,采用并发标记和压缩技术,实现低延迟(目标是将停顿时间控制在 10ms 内)。
适用场景:非常适合超大内存和对延迟极其敏感的场景。
配置:-XX:+UseZGC
6. Shenandoah 垃圾收集器
新生代和老年代:Shenandoah 同样不使用传统的分代模式,采用 Region 划分堆内存,针对整个堆内存进行并发标记、回收,旨在实现极低的停顿时间。
适用场景:与 ZGC 类似,适合对低延迟有极高要求的场景。
配置:-XX:+UseShenandoahGC
7. Epsilon 垃圾收集器
新生代和老年代:Epsilon 是一个“无操作”的垃圾收集器,不进行垃圾回收,因此不会区分新生代和老年代。这主要用于性能基准测试。
适用场景:测试或调试,不适用于生产环境。
配置:-XX:+UseEpsilonGC
总结:
Serial GC(新生代/老年代)
- 新生代:Serial GC(单线程)
- 老年代:Serial Old GC(单线程)
Parallel GC(新生代/老年代)
- 新生代:Parallel Scavenge(多线程)
- 老年代:Parallel Old GC(多线程)
CMS GC(新生代/老年代):
- 新生代:Parallel Scavenge(多线程)
- 老年代:CMS(并发标记清理)
G1 GC:管理新生代和老年代,通过区域划分实现并发回收,适合大内存、低延迟的应用场景,尤其是服务器端应用。
ZGC 和 Shenandoah:打破分代模式,通过区域划分并发回收,适合超大内存和低延迟场景。
选择合适的垃圾收集器,取决于应用的特性,如响应时间、吞吐量和可用内存大小。
参考链接:
https://javaguide.cn/java/jvm/jvm-garbage-collection.html
https://xie.infoq.cn/article/9d4830f6c0c1e2df0753f9858
https://juejin.cn/post/7034487823386279966