JVM 垃圾回收
垃圾回收算法
标记-清除
Mark-Sweep,分为两步:首先标记出需要回收的对象;标记完成后,统一回收被标记的对象。实现简单,但两个过程的执行效率都随对象数量增长而降低,并且会造成内存碎片。
标记-复制
Mark-Copy,核心思想是半区复制,即将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当一块内存用完,就将还存活的对象复制到另一块上面;然后把已使用的内存块一次性清理掉;最后交换两个内存块的角色。
优点是实现简单,运行速度快,复制保证了空间的连续性,没有内存碎片;但缺点也很明显,将可用内存缩小为原来的一半。适合对象快速迭代,垃圾对象较多的场景,可以减少复制操作。
标记-整理
Mark-Compact,分为两步:首先标记出需要回收的对象;然后让所有存活对象都向内存空间的一端移动,最后直接清理掉边界以外的内存。
优点是不会产生内存碎片,缺点是需要移动大量对象,处理效率比较低,需要 STW。是一种适合老年区的移动式算法。
分代收集
分代收集的理论基于两个假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
因此,JVM GC的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储:
- 新生代:每次垃圾收集都会有大批对象死去(适合标记-复制算法)
- 老年代:新生代中每次回收后存活的少量对象,将会逐步晋升到老年代(适合标记-清除/整理算法)
针对不同的分代,可以分别进行垃圾回收,例如新生代收集Minor GC
,老年代收集Major GC
,混合收集 Mixed GC
,以及整堆收集 Full GC
。
Hotspot 的堆内存结构就是基于分代的设计:
- 新生代划分为一块较大的
Eden
空间和两块较小的Survivor
空间,每次分配只是用Eden
和其中一块Survivor
- GC时,将
Eden
和Survivor
中存活对象一次性复制到另一块Survivor
,然后清理Eden
和原先的Survivor
- 最后交换两个
survivor
区的角色 - 逃生门设计:当
Survivor
不足以容纳一次GC的存活对象时,需要依赖其它内存区域(如老年代)进行分配担保
垃圾回收实现
基于上述的垃圾回收算法,结合分代回收的理论,针对不同分代的特点,设计出了不同的垃圾收集器:
- 新生代收集器:Serial、ParNew、Parallel Scavenge
- 老年代收集器:Serial old、Parallel old、CMS
- 整堆收集器:G1
- 新一代收集器:Shenandoah、ZGC
Safe Point:垃圾回收的安全点是指程序执行时,程序能够安全地停止下来进行垃圾回收的位置。在Java虚拟机中,安全点通常是指一组特定的指令位置,在这些位置上,虚拟机能够保证对象引用关系的一致性,即对象引用关系不会发生变化。常见的安全点选择策略包括轮询(Polling)、中断(Interrupt)和内存屏障(Memory Barrier)等。
Serial
Serial 是经典的串行垃圾收集器,主要用于新生代,采用标记-复制算法。垃圾回收时 STW 直到收集结束。
Serial Old 是 Serial 的老年代版本,同样是单线程串行并且会 STW,但采用标记-整理算法。
ParNew
ParNew 是 Serial 的多线程版本,它充分利用多核处理器的优势,通过并行的方式进行垃圾回收,专注于提供更低的垃圾收集停顿时间。主要用于新生代,采用标记-复制算法。另外,除Serial
外只有ParNew
可以和CMS
配合使用。
Parallel
Parallel Scavenge 是一种以吞吐量为主要设计目标的并行垃圾收集器,对于单次垃圾收集造成的暂停时间可能会略高于ParNew收集器,但在整体吞吐量上会更加高效。主要用于新生代,采用标记-复制算法。
$$ \text{吞吐量} = \frac{\text{运行用户代码时间}}{\text{运行用户代码时间} + \text{运行垃圾收集时间}} $$
Parallel Old 是 Parallel 的老年代版本,同样是多线程并行,注重吞吐量,但采用标记-整理算法。
ParNew 更适合那些对垃圾收集停顿时间要求较高的应用场景,例如需要与用户交互的程序,良好的响应速度能提升用户体验。 Parallel 更适合那些对整体吞吐量要求较高的场景,尽快完成程序的运算任务,例如后台运算而不需要太多交互
CMS
Concurrent Mark Sweep,是一种以低停顿时间为目标的垃圾收集器,其最大特点是不会 STW,而是通过并发的方式来进行标记和清除,以尽量减少应用程序的停顿时间。主要用于老年代,采用标记-清除算法。
四个步骤:
- 初始标记:标记
GC Roots
能直接关联的对象。暂停用户线程 - 并发标记:遍历对象图。与用户线程并发
- 重新标记:修正并发标记阶段产生的标记变动(因用户线程的并发执行)。暂停用户线程
- 并发清除:清理标记死亡的对象。与用户线程并发
CMS 的优点是并发收集,低停顿。但也存在缺点:
- 对处理器资源非常敏感
- 无法处理浮动垃圾
- 容易产生大量内存碎片空间
浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾(产生了新对象),这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,CMS 收集需要预留出一部分内存,不能等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现
Concurrent Mode Failure
,这时虚拟机将临时启用 Serial Old 来替代 CMS,导致很长的停顿时间。
G1
G1 是一种面向服务端应用的垃圾收集器,它的设计目标是在尽量短的停顿时间内达到尽可能高的吞吐量。G1 采用分代收集和区域化的思想,将整个堆空间划分为多个小块(Region),并根据各个区域的垃圾回收效率来动态调整回收策略,从而实现更加均衡和可控的垃圾回收。G1 同时应用于新生代和老年代,采用标记-整理算法,JDK9 以后已经默认使用 G1。
Region:
- 连续的Java堆划分为多个大小相等的独立Region,每个Region根据需要扮演Eden/Survivor/老年代
- Humongous区域专门存储大对象
- G1 跟踪各个Region里面的垃圾堆积的价值大小(价值即回收所获得的空间大小以及回收所需时间的经验值,后台维护一个优先级列表)
四个步骤:
- 初始标记:标记
GC Roots
能直接关联的对象,并修改TAMS指针以便正确分配对象。暂停用户线程。 - 并发标记:遍历对象图。与用户线程并发。(用SATB原始快照处理引用变化)
- 最终标记:处理并发阶段结束后遗留的少量SATB记录。暂停用户线程。
- 筛选回收:更新Region的统计数据,根据优先级选择Region回收集,移动存活对象并清理旧Region的全部空间。暂停用户线程。
Shenandoah
Shenandoah 是一种由Red Hat开发的低延迟垃圾收集器,旨在降低Java应用程序的停顿时间。它是一种全新的垃圾收集器,旨在解决在大内存堆上的垃圾收集延迟问题。Shenandoah的设计目标是在提供低延迟的同时,保持高吞吐量。特点是支持并发,默认不使用分代收集,改用Connection Matrix
全局数据结构来记录跨Region的引用关系
九个步骤:
- 初始标记:标记
GC Roots
能直接关联的对象。暂停用户线程 - 并发标记:遍历对象图。与用户线程并发(时间取决于堆中存活对象的数量和对象图的复杂程度)
- 最终标记:处理剩余的SATB扫描,并统计回收价值最高的Region构成回收集。短暂停顿
- 并发清理:清理没有存活对象的Region
- 并发回收:核心特点。借助
读屏障
、Brooks Pointers
把回收集里的存活对象复制到未被使用的Region - 初始引用更新:把堆中所有指向旧对象的引用修正到复制后的新地址(引用更新)。短暂停顿
- 并发引用更新:真正开始引用更新。与用户线程并发。时间取决于涉及的引用数量
- 最终引用更新:修正存在于GC Roots中的引用。短暂停顿
- 并发清理:回收剩余的回收集中的Region,供以后对象分配使用
ZGC
Z Garbage Collector,由Oracle开发的一种低延迟的垃圾收集器,旨在降低Java应用程序的停顿时间。它是一种全新的垃圾收集器,于JDK 11中首次发布。ZGC 基于Region内存布局,分大、中、小三类容量,不设分代,使用读屏障、染色指针、内存多重映射等技术。ZGC 可并发,采用标记-整理算法。
四个过程:
- 并发标记:遍历对象图做可达性分析,特殊点在于标记是在指针上而不是在对象上。需短暂停顿
- 并发预备重分配:根据特定查询条件确定回收集
- 并发重分配:核心。把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系(用于指针自愈)
- 并发重映射:修正整个堆中指向重分配集中旧对象的所有引用