JVM垃圾收集器、内存分配和回收策略深入分析

222 阅读17分钟

知其然要知其所以然,探索每一个知识点背后的意义,你知道的越多,你不知道的越多,一起学习,一起进步,如果文章感觉对您有用的话,关注、收藏、点赞,有困惑的地方可以评论,我们一起探讨!


JVM垃圾收集器、内存分配与回收策略深度解析


一、JVM内存模型与垃圾回收概览

JVM内存主要分为 堆(Heap)非堆(Non-Heap) 区域,垃圾回收(GC)主要针对堆内存。

  • 堆内存结构
    • 新生代(Young Generation):存放新创建的对象。
      • Eden区:对象首次分配的区域。
      • Survivor区(From/To):存放Minor GC后存活的对象。
    • 老年代(Old Generation):存放长期存活的对象。
    • 元空间(Metaspace,JDK 8+):存储类元数据(替代永久代)。
pie
    title 堆内存结构
    "Eden区" : 80
    "Survivor区 (From/To)" : 10
    "老年代" : 10

二、内存分配策略

1. 对象分配规则
  1. 对象优先在Eden区分配
    • 大多数对象生命周期短,Eden区设计为快速分配与回收。
  2. 大对象直接进入老年代
    • 通过 -XX:PretenureSizeThreshold 设置阈值(如2MB)。
  3. 长期存活对象晋升老年代
    • 对象每经历一次Minor GC,年龄加1,默认15次晋升(-XX:MaxTenuringThreshold)。
  4. 动态年龄判断
    • Survivor区中相同年龄对象的总大小超过Survivor区一半时,年龄≥该值的对象直接晋升。
2. 空间分配担保
  • 触发条件:Minor GC前,检查老年代剩余空间是否大于新生代对象总大小。
  • 担保机制:若不足,检查是否允许担保失败(-XX:HandlePromotionFailure),若允许则尝试Minor GC;否则触发Full GC。

三、垃圾回收机制

1. 垃圾回收基础知识

  • 判断对象存活的必要性:在垃圾回收过程中,首要任务就是识别哪些对象是存活的,哪些对象已经死亡。只有确定了死亡对象,垃圾收集器才能对这些对象所占用的内存进行回收操作。因此,对象死亡算法判断是垃圾回收的前置步骤和重要基础。
  • 影响垃圾回收策略:不同的对象死亡判断算法可能会影响垃圾回收器所采用的回收策略。例如,采用引用计数算法时,只要对象的引用计数为 0 就可以立即回收;而采用可达性分析算法时,需要考虑对象与 GC Roots 之间的引用关系,可能会有一些对象处于暂时不可达但仍有复活机会的状态,这就会影响垃圾回收的时机和方式。

2. 可达性分析算法

  • 核心判断方法:目前主流的 JVM 采用可达性分析算法来判断对象是否存活。该算法以一系列被称为 “GC Roots” 的对象为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连(即从 GC Roots 到该对象不可达)时,则证明此对象是不可用的,可判定为死亡对象。
  • GC Roots 的选取:GC Roots 包括虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中 JNI(即一般说的 Native 方法)引用的对象等。这些 GC Roots 的选取和确定与 JVM 的内存区域划分和对象引用关系密切相关,是可达性分析算法的关键组成部分。

3. 引用类型与对象死亡

  • 不同引用类型对对象存活的影响:Java 中的引用类型分为强引用、软引用、弱引用和虚引用。不同的引用类型在对象死亡判断中有着不同的作用。例如,强引用是最常见的引用类型,只要强引用存在,对象就不会被回收;而软引用在系统内存不足时会被回收;弱引用在下次垃圾回收时就会被回收;虚引用主要用于跟踪对象被垃圾回收的状态,对对象的存活与否没有实际影响,但会影响垃圾回收的具体过程。
  • 引用类型与垃圾回收策略的结合:JVM 在进行对象死亡判断时,会综合考虑对象的引用类型和可达性分析结果,从而制定不同的垃圾回收策略。例如,对于软引用和弱引用对象,在内存紧张或垃圾回收时会有不同的处理方式。

四、垃圾回收算法

1. 标记-清除(Mark-Sweep)
  • 步骤:标记存活对象 → 清除未标记对象。
  • 缺点:内存碎片化,大对象分配困难、效率不高,清除后产生了很多不连续的内存碎片,如果来一个大一些的对象,又要触发一次垃圾回收。
  • 适用场景:老年代回收(如CMS收集器)。
2. 标记-整理(Mark-Compact)
  • 步骤:标记存活对象 → 存活对象向一端移动 → 清理边界外内存。
  • 优点:解决内存碎片问题。
  • 缺点:对象移动开销大
  • 适用场景:老年代回收(如Serial Old、Parallel Old)。
3. 复制(Copying)
  • 步骤:将存活对象复制到另一块内存 → 清空原内存。
  • 优点:无碎片,分配高效。
  • 缺点:内存利用率低(需保留一半空间),浪费10%空间。
  • 适用场景:新生代回收(如Serial、ParNew)。

五、垃圾收集器分类与对比

收集器区域算法线程特点适用场景
Serial新生代复制单线程简单高效,停顿时间长客户端应用
ParNew新生代复制多线程Serial的多线程版配合CMS使用
Parallel Scavenge新生代复制多线程吞吐量优先(-XX:GCTimeRatio后台计算型应用
Serial Old老年代标记-整理单线程Serial的老年代版客户端应用
Parallel Old老年代标记-整理多线程Parallel Scavenge的老年代搭档吞吐量优先应用
CMS老年代标记-清除并发低延迟,但内存碎片化Web服务、实时系统
G1全堆分区+标记-整理并发可预测停顿时间,兼顾吞吐与延迟大堆内存、服务端应用
ZGC/Shenandoah全堆染色指针+并发整理并发亚毫秒级停顿,超大堆支持低延迟、超大规模内存应用

六、主流垃圾收集器详解

1. CMS(Concurrent Mark Sweep)
  • 阶段
    1. 初始标记(Initial Mark):标记GC Roots直接关联对象(STW)。
    2. 并发标记(Concurrent Mark):遍历对象图(与用户线程并发)。
    3. 重新标记(Remark):修正并发标记期间变化的对象(STW)。
    4. 并发清除(Concurrent Sweep):清理未标记对象(并发)。
  • 缺点
    • 无法处理浮动垃圾:因为CMS是并发收集,所以在收集的时候,新的垃圾会不断产生,这一部分垃圾在标记过后无法在当次就处理掉他们,只能等待下一次GC再清理掉,这一部分垃圾就是浮动垃圾,CMS一般在老年代使用了68%的空间就会被激活,当前如果在应用中老年代增长不太快的情况下,可以适当调高参数-XX:CMSInitiatingOccUpanyFraction的值来触发百分比,以便降低内存回收次数来获取更好的性能,要是CMS运行期间预留的内存无法满足程序需要,就会出现一次Concurrent Mode Failure 失败,这个时候虚拟机会启动后背预案,临时启动Serial Old收集器来重新进行老年代的垃圾收集,此时停顿时间就很长 来,所以-XX:CMSInitiatingOccUpanyFraction参数不能设置的过高,设置的过高,性能反而降低。(以空间换时间的思想)。
    • 空间碎片的问题:CMS是基于标记清除的算法,这就意味着收集结束后会有大量的空间碎片,空间碎片过多会对大对象分配带来麻烦,为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,默认开启。用于进行FullGC时进行内存碎片整理过程,内存整理没有办法并发,所以停顿时间会变长,不过虚拟设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数用于设置执行多少次不压缩的FullGC后来一次带压缩的(默认值时0,表示每次进行FullGC都进行碎片整理)
    • JDK5引入的,JDK9标记弃用了,JDK14就删除了,原因可能有两个,1是没有人来维护,2是重点发展G1
  • 思考
    1. CMS垃圾收集器为什么没有使用标记-整理算法呢?:CMS垃圾收集器没有使用标记-整理算法,主要是基于其设计目标和实现方式的考虑。CMS 垃圾收集器的设计目标是以降低停顿时间为重点,尽量减少应用程序的暂停时间,从而提高系统的响应性和吞吐量。为了达到这个目标,CMS 垃圾收集器采用了并发标记和并发清除的方式来进行垃圾回收,以便在大部分的垃圾回收过程中与应用程序并发执行,避免长时间的停顿。标记-整理算法通常包括了标记、清除和整理三个步骤,其中整理阶段会对内存中的对象进行整理,以消除内存碎片并使得内存空间连续化。然而,整理阶段通常需要暂停应用程序的执行,并且可能会导致较长时间的停顿,这与 CMS 垃圾收集器的设计目标相悖。另外,由于 CMS 垃圾收集器是并发执行的,它在标记和清除阶段与应用程序并发执行,这意味着在这两个阶段中内存空间的布局可能会发生变化,而整理阶段需要保证内存的连续性,这与并发执行的特性相冲突。因此,为了实现并发执行和降低停顿时间的目标,CMS 垃圾收集器选择了不使用标记-整理算法,而是采用了并发标记和并发清除的方式来进行垃圾回收。尽管这样可能会带来内存碎片化的问题,但 CMS 垃圾收集器在一定程度上通过精心的设计和优化来减轻了内存碎片化带来的影响。
    2. 什么情况回退化串行 GC 算法?
      • 晋升失败:顾名思义,晋升失败就是指在进行 Young GC 时,Survivor 放不下,对象只能放入 Old,但此时 Old 也放不下。直觉上乍一看这种情况可能会经常发生,但其实因为有 concurrentMarkSweepThread 和担保机制的存在,发生的条件是很苛刻的,除非是短时间将 Old 区的剩余空间迅速填满。另外还有一种情况就是内存碎片导致的Promotion Failed,Young GC 以为 Old 有足够的空间,结果到分配时,晋级的大对象找不到连续的空间存放。
      • 并发模式失败:这一种情况,也是发生概率较高的一种,在 GC 日志中经常能看到 Concurrent Mode Failure 关键字。这种是由于并发 Background CMS GC 正在执行,同时又 有 Young GC 晋升的对象要放入到了 Old 区中,而此时 Old 区空间不足造成的。 为什么 CMS GC 正在执行还会导致收集器退化呢?主要是由于 CMS 无法处理浮动垃圾(Floating Garbage)引起的。CMS 的并发清理阶段,Mutator 还在运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里,无法在本次 GC 被清除掉,这些就是浮动垃圾,除此之外在 Remark 之前那些断开引用脱离了读写屏障控制的对象也算浮动垃圾。所以 Old 区回收的阈值不能太高,否则预留的内存空间很可能不够,从而导致 Concurrent Mode Failure 发生)
2. G1(Garbage-First)
  • 核心思想:将堆划分为多个 Region(1MB~32MB),优先回收垃圾最多的Region。
  • 阶段
    1. 初始标记(Initial Mark):标记GC Roots直接关联对象(STW)。
    2. 并发标记(Concurrent Mark):识别存活对象(并发)。
    3. 最终标记(Final Mark):处理剩余SATB(Snapshot-At-The-Beginning)记录(STW)。
    4. 筛选回收(Evacuation):选择Region进行回收(STW)。
  • 工作模式: 针对新生代和老年代,G1提供2种GC模式,Young GC和Mixed GC,两种会导致Stop The World
    1. Young GC: 当新生代的空间不足时,G1触发Young GC回收新生代空间 Young GC主要是对Eden区进行GC,它在Eden空间耗尽时触发,基于分代回收思想和复制算法,每次Young GC都会选定所有新生代的Region,同时计算下次Young GC所需的Eden区和Survivor区的空间,动态调整新生代所占Region个数来控制Young GC开销

    2. Mixed GC: 当老年代空间达到阈值会触发Mixed GC,选定所有新生代里的Region,根据全局并发标记阶段(下面介绍到)统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内,尽可能选择收益高的老年代Region进行GC,通过选择哪些老年代Region和选择多少Region来控制Mixed GC开销

  • 优势
    • 可预测停顿时间(-XX:MaxGCPauseMillis)。
    • 整体基于标记-整理算法,局部(Region间)基于复制算法。
3. ZGC
  • 核心技术
    • 染色指针(Colored Pointers):在指针中存储元数据(如标记、重定位信息)。
    • 并发整理:无需STW即可移动对象。
  • 优势
    • 停顿时间不超过10ms(与堆大小无关)。
    • 支持TB级堆内存。

七、内存分配与回收调优策略

自动内存管理主要解决两个问题,给对象分配内存以及回收对象的内存(分代收集的思想)

1. 优先在 Eden 区分配

大多数情况下,新创建的对象会优先在新生代的 Eden 区进行分配。这是因为新生代中的对象大多生命周期较短,在 Eden 区分配可以快速利用新的内存空间。当 Eden 区空间不足时,会触发一次 Minor GC(新生代垃圾回收)。

public class EdenAllocationExample {
    public static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        // 此时若Eden区空间不足,会触发Minor GC
        allocation4 = new byte[4 * _1MB]; 
    }
}

2. 大对象直接进入老年代

大对象指需要大量连续内存空间的对象,如很长的数组或大型的对象实例。为避免大对象在 Eden 区和 Survivor 区之间频繁移动带来的性能开销,JVM 会将大对象直接分配到老年代。可以通过-XX:PretenureSizeThreshold参数设置大对象的阈值。

public class BigObjectAllocationExample {
    public static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        // 若设置了PretenureSizeThreshold,此大数组可能直接进入老年代
        byte[] bigArray = new byte[10 * _1MB]; 
    }
}

3. 长期存活的对象进入老年代

对象在新生代的 Survivor 区中每经过一次 Minor GC 且仍然存活,其年龄就会增加 1 岁。当对象的年龄达到一定阈值(默认是 15 岁,可通过-XX:MaxTenuringThreshold参数设置)时,就会被晋升到老年代。

4. 动态对象年龄判定

JVM 并非严格要求对象年龄必须达到MaxTenuringThreshold才能晋升到老年代。如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

回收策略

根据垃圾回收的区域和触发条件,主要分为以下几种回收策略:

1. Minor GC(新生代垃圾回收)

  • 触发条件:当 Eden 区空间不足时,会触发 Minor GC。
  • 回收区域:主要回收新生代(包括 Eden 区和 Survivor 区)的垃圾对象。
  • 特点:发生频率较高,由于新生代中对象的存活率较低,采用复制算法,回收速度通常较快。在 Minor GC 时,会将 Eden 区和一个 Survivor 区中存活的对象复制到另一个 Survivor 区,若 Survivor 区空间不足,部分对象会直接进入老年代。
  • 空间分配担保策略:在进行 Minor GC 之前,JVM 会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。因为在 Minor GC 过程中,新生代中存活的对象需要被复制到 Survivor 区或者老年代,如果 Survivor 区空间不足,这些对象就会被分配到老年代。如果老年代没有足够的空间来容纳这些对象,就可能会导致内存溢出错误。因此,空间分配担保机制就是为了应对这种情况而存在的

2. Major GC(老年代垃圾回收)

  • 触发条件:不同的垃圾收集器对 Major GC 的触发条件定义可能不同,一般当老年代空间不足时会触发。
  • 回收区域:主要回收老年代的垃圾对象。
  • 特点:速度通常比 Minor GC 慢,因为老年代中的对象存活时间较长,垃圾回收的难度相对较大。

3. Full GC(全堆垃圾回收)

  • 触发条件:Full GC 的触发条件较为复杂,可能由多种原因引起,如老年代空间不足、永久代(JDK 8 之前)或元空间(JDK 8 及以后)空间不足、调用System.gc()方法,获取空间分配担保空间不足等。
  • 回收区域:回收整个堆内存,包括新生代、老年代和永久代(JDK 8 之前)或元空间(JDK 8 及以后)。
  • 特点:会导致较长的 Stop - The - World(STW)时间,对应用程序的性能影响较大。
4. 参数调优示例
  • 堆大小
    -Xms4g -Xmx4g  # 初始堆与最大堆设为相同,避免动态调整开销
    
  • 新生代比例
    -XX:NewRatio=2  # 老年代:新生代=2:1
    -XX:SurvivorRatio=8  # Eden:Survivor=8:1:1
    
  • GC日志分析
    -XX:+PrintGCDetails -Xloggc:gc.log
    
2. 场景优化建议
  • 高吞吐场景
    • 使用 Parallel Scavenge + Parallel Old,设置较大堆和较高 -XX:GCTimeRatio
  • 低延迟场景
    • 使用 G1ZGC,设置合理的 -XX:MaxGCPauseMillis
  • 大对象处理
    • 避免频繁分配大对象(直接进入老年代),调整 -XX:PretenureSizeThreshold

八、总结

维度核心要点
内存分配对象优先分配Eden,大对象直入老年代,动态年龄判断优化晋升策略。
回收算法标记-清除(碎片化)、标记-整理(移动开销)、复制(高效但浪费空间)。
收集器选择根据吞吐、延迟、堆大小选择:CMS(低延迟碎片化)、G1(平衡)、ZGC(超低延迟)。
调优核心合理设置堆大小、分代比例、GC参数,结合日志分析优化GC频率和停顿时间。

实践建议

  • 监控工具:使用VisualVM、GC日志分析工具(GCeasy)定位瓶颈。
  • 升级收集器:对延迟敏感应用优先考虑G1或ZGC。
  • 避免内存泄漏:及时清理无引用对象,减少Full GC触发。

九、思考

JDK8采用的是哪种垃圾收集器?:

- **Parallel Scavenge收集器**:新生代收集器,采用复制算法,提高吞吐量,以及有一个GC自适应调节策略
- **Parallel Old收集器**:老年代收集器,采用标记整理算法,也是提高吞吐量,配合Parallel Scavenge收集器

CMS/G1垃圾收集器的选择:建议内存超过4G选择G1,低于4G及以下选择CMS,当堆内存超过 4GB 甚至达到数十 GB 时,G1 收集器优势明显。它将堆划分为多个大小相等的 Region,能够并行处理这些 Region 的垃圾回收,避免了传统收集器在大内存场景下的长停顿问题。G1 会优先回收垃圾最多的 Region,能更有效地管理大内存