垃圾回收算法-通用的分代垃圾回收机制
垃圾回收算法-通用的分代垃圾回收机制
概要
分代垃圾回收机制是基于这样一个事实:不同对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。因此,分代垃圾回收机制是一种基于对象生命周期的假设对堆内存进行划分的回收策略。
一、判断对象是否可回收
首先思考一个问题,内存堆中那么多对象,回收器要回收哪些对象?怎么判断出这些要回收的对象呢?因此对于垃圾回收,判断并标识对象是否可回收是第一步。从理论层面来说,判断对象是否可回收一般两种方法。
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 区。如果经过第一次Minor GC后仍然存活,并且能被survivor容纳的话,该对象会被移动到survivor空间中。并且将其对象年龄设为1岁。对象在survivor区中每熬过一次Minor GC,年龄就增加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次垃圾回收后仍存活的对象。
说明:
如果一个对象在 Survivor 区的年龄是 15,并且它在下一次 Minor GC 中仍然存活,那么它将被移动到老年代。
五、动态对象年龄判定
为了能更好地适应不同程序的内存状况,HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX:MaxTenuringThreshold才能晋升到老年代,如果在survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到-XX:MaxTenuringThreshold中要求的年龄。
六、什么是分配担保机制?
分配担保(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 中的接口一样,而垃圾收集器是接口的具体实现。所以,不同的厂商,不同版本的虚拟机实现的方式都有所不同。甚至是很大的差别。
参考链接:
https://javaguide.cn/java/jvm/jvm-garbage-collection.html
https://xie.infoq.cn/article/9d4830f6c0c1e2df0753f9858
https://juejin.cn/post/7034487823386279966