垃圾收集详解
1、垃圾收集算法
1.1 标记清除算法
过程:标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。
优点:标记-清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效
缺点:由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

1.2 复制算法
过程:它开始时把堆分成两块:对象块和空闲块。程序从对象块为对象分配空间。GC时遍历引用链,一旦发现存活对象就复制到空闲块,然后清理掉整块旧内存,这样空闲块变成了对象块,原来的对象块变成了空闲块,程序会在新的对象块中分配内存。
优点:无内存碎片,效率高。
缺点:内存利用率低(只有50%可用,实际优化后可达90%)。

1.3 标记整理算法
过程:标记-整理算法采用标记-清除算法一样的方式对存活对象进行标记,但在清除时不同,它会先将所有的存活对象往一端空闲空间移动,并更新对应的空闲指针,再清理空闲指针外的内存。
优点:无内存碎片,无需像复制算法那样预留双倍空间。
缺点:标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高

1.4 分代收集算法
核心思想:分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象生命周期不同,将堆划分为新生代(Young)和老年代(Old)(JDK 8后已移除永久代,改为元空间Metaspace,且元空间不在此常规GC范围内)。
收集策略:新生代使用复制算法;老年代使用标记-整理或标记-清除。
2、分代收集的算法选择
2.1 新生代适合“复制算法”
1. 场景特征
- 高死亡率:每次GC时,只有约2%的对象是存活的,98%都是垃圾。
- 高频GC:新生代填充速度快,GC触发非常频繁。
2. 选择理由:用空间换时间
效率极高:
- 无需标记:不需要像标记-清除那样遍历所有对象去标记谁死谁活。只需要遍历那2%的存活对象,把它们搬走即可。
- 清理彻底:不需要逐个清理垃圾对象,直接一键清空整个对象区,效率极高。
- 无碎片:复制过去的对象是连续排列的,内存天然紧凑,不会产生内存碎片,分配新对象时只需移动空闲指针,速度快。
空间浪费可接受:
- 虽然理论上需要预留50%的空间作为备用,但实际上因为存活对象极少(2%),JVM进行了优化(Eden:Survivor1:Survivor2 = 8:1:1),实际空间利用率可达90%,仅浪费10%。对于新生代这种频繁GC的区域,这点空间代价换取极低的停顿时间是完全值得的。
2.2 老年代适合“标记-整理”或“标记-清除”
1. 场景特征
- 高存活率:进入老年代的对象,绝大多数都会长期存活。如果在这里用复制算法,意味着每次GC都要搬运90%以上的对象,开销巨大。
- 低频GC:老年代空间大,填满慢,GC触发频率低。
- 空间敏感:老年代占据了堆的大部分内存,不能像新生代那样浪费50%的空间做备份。
2. 选择理由:用时间换空间
这是目前主流收集器(如G1、Parallel Old)在老年代的主要算法。
过程:
- 标记:遍历所有对象,标记出存活对象(这一步和标记-清除一样)。
- 整理:将所有存活对象向内存的一端移动,然后直接清理掉边界以外的内存。
优势:
- 无碎片:通过移动对象,解决了标记-清除算法产生的内存碎片问题,避免了因碎片导致的大对象分配失败(提前触发Full GC)。
- 空间利用率高:不需要预留备用空间,100%利用。
劣势:移动对象需要更新引用地址,有一定开销。但在老年代低频率GC和高存活率的背景下,这个开销远小于复制算法的搬运开销。
3. 什么时候用“标记-清除”
- 代表收集器:CMS (Concurrent Mark Sweep)。
- 原因:CMS追求的是最低停顿时间。标记-整理算法中的“整理(移动对象)”阶段必须暂停用户线程(Stop-The-World)。为了做到“并发”回收,CMS选择了不移动对象,只进行标记和清除。
- 代价:会产生内存碎片。为了解决碎片问题,CMS在特定条件下会退化回“标记-整理”算法(Serial Old),或者需要定期进行一次Full GC来整理碎片。
- 现状:由于碎片问题和JDK版本的演进,CMS已逐渐被G1和ZGC取代,但在理解算法选型逻辑上,它代表了“对停顿时间极度敏感,宁愿牺牲空间连续性”的极端情况。
4. 总结对比
新生代因为“死的比活的多”,所以直接把活的抄走最快(复制算法);
老年代因为“活的比死的多”,搬来搬去太累且浪费空间,所以原地标记后把活的挤到一起(标记-整理)最划算。
| 特性 | 新生代 (Young Gen) | 老年代 (Old Gen) |
|---|---|---|
| 对象特征 | 朝生夕死,死亡率~98% | 长命百岁,存活率>90% |
| GC频率 | 非常高 | 较低 |
| 核心诉求 | 速度 (减少单次STW) | 空间利用率 & 避免大停顿 |
| 首选算法 | 复制算法 (Copying) | 标记-整理 (Mark-Compact) |
| 备选算法 | 无 | 标记-清除 (Mark-Sweep, 如CMS) |
| 空间策略 | 空间换时间 (浪费少量空间换取极速) | 时间换空间 (容忍一定计算开销换取紧凑内存) |
| 内存碎片 | 无 (天然连续) | 标记-整理无碎片;标记-清除有碎片 |
3、对象的生命周期
3.1 新生期:内存分配
当Java代码执行 new 指令创建对象时,JVM首先判断对象大小:
- 小对象(默认小于 -XX:PretenureSizeThreshold,通常为几KB):优先在新生代的Eden区分配。
- 大对象(如长数组、大字符串):直接进入老年代,避免在新生代频繁复制。
新生代的内存模型示例: 
3.2 青年期:新生代垃圾回收
1. 触发条件
当Eden区空间不足,无法为新对象分配内存时,触发YoungGC。
2. 回收过程
- STW(Stop-The-World):暂停所有应用线程。
- 标记存活对象:从GC Roots(如栈帧中的局部变量、静态变量等)出发,标记Eden区和当前Survivor区(From区)中所有存活对象。
- 复制存活对象:
- 将Eden区和From区中存活对象复制到另一个空的Survivor区(To区)。
- 对象年龄(Age)+1(每经历一次Young GC,年龄+1)。
- 清理:直接清空Eden区和From区(无需逐个回收)。
- 角色互换:From区和To区角色交换,下次GC时使用新的From区。

3. 对象晋升策略
- 年龄阈值晋升:对象年龄达到阈值(默认15,可通过 -XX:MaxTenuringThreshold 调整)后,晋升到老年代。
- 动态年龄判断:若Survivor区中同年龄对象总大小超过该区域50%,则大于等于该年龄的对象提前晋升。
- 担保失败:若Minor GC后存活对象过多,Survivor区无法容纳,则直接晋升到老年代;若老年代也无足够空间,则触发Full GC。
3.3 成熟期:老年代对象与Major/Full GC
1. 老年代特点
- 存放长期存活对象(从新生代晋升而来)或直接分配的大对象。
- 空间大,回收频率低,但单次耗时长。
2. Major GC vs Full GC
- Major GC:仅回收老年代(如CMS收集器)。
- Full GC:回收整个堆(新生代+老年代)及方法区(元空间),通常伴随长时间STW。
3. Full GC触发场景
- 老年代空间不足(Young GC后晋升失败)。
- 元空间(Metaspace)不足(类加载过多)。
- 显式调用 System.gc()。
- CMS收集器出现Concurrent Mode Failure。
- G1收集器中Humongous Region填充或Mixed GC未能及时回收。
4、重点垃圾收集器详细介绍
4.1 Parallel收集器
Parallel Scavenge(新生代) + Parallel Old(老年代)收集器组合,是 JDK 8 版本服务端模式下的默认收集器(JDK 9+ 默认改为 G1)。它的核心设计目标是达到一个可控制的吞吐量。
1. 收集过程
(1)年轻代回收
- 暂停用户线程 (STW):JVM 停止所有正在运行的 Java 线程。
- 并行标记与复制:启动多个 GC 线程(数量通常等于 CPU 核数),并行地扫描 Eden 区和 Survivor 区,找出存活对象。
- 对象复制:将存活对象复制到空的 Survivor 区(或晋升到老年代)。
- 恢复线程:GC 完成后,唤醒用户线程继续执行。
(2)老年代回收
- 暂停用户线程 (STW):再次停止所有用户线程。
- 并行标记:GC 线程并行遍历整个堆(包括新生代和老年代),标记所有存活对象。
- 并行整理 (Compaction):这是最耗时的步骤。GC 线程并行地将存活对象向内存一端移动,消除碎片,并更新所有引用指针。
- 恢复线程:整理完成后,恢复用户线程。
Parallel 收集器在进行 GC 时,必须暂停所有用户线程(STW)。它的优化重点不在于减少单次停顿时间,而在于最大化单位时间内用户代码的运行时间。
2. 优点
- 高吞吐量:在多核 CPU 环境下,能充分利用计算资源,非常适合后台运算任务(如大数据处理、科学计算)。
- 自适应调节:无需繁琐的参数调优,JVM 能根据负载自动调整内存区域大小,省心省力。
- 无内存碎片:老年代使用标记 - 整理,长期运行后内存依然紧凑。
3. 缺点
- 停顿时间不可控且较长:由于追求吞吐量,它不关心单次 GC 的停顿时间。在大堆内存或对象存活率高时,STW 时间可能长达数百毫秒甚至秒级,不适合对延迟敏感的系统(如 Web 服务、实时交易)。
- 缺乏并发能力:所有 GC 阶段均需要 STW,无法像 CMS 或 G1 那样与用户线程并发执行。
4.2 CMS收集器
CMS (Concurrent Mark Sweep) 收集器是 HotSpot 虚拟机在 JDK 1.4 引入、JDK 5 成熟,并在 JDK 8 及之前版本中作为低延迟场景首选的老年代收集器。它的核心设计目标是获取最短回收停顿时间。
CMS收集器(Concurrent Mark Sweep:并发标记清除)采用标记清除算法,并发收集低停顿。
1. 收集过程
第一阶段:初始标记
- STW,暂停用户线程。
- 扫描并标记根节点直接引用的对象,不涉及深层遍历。停顿时间通常在几毫秒到几十毫秒之间,与堆大小无关,仅与 Root 数量有关。
- 通常伴随着一次 Young GC (ParNew) 同时发生。
第二阶段:并发标记
- 恢复用户线程,与GC线程同时运行
- 从初始标记的对象出发,递归遍历整个对象图,标记所有存活的对象。
- 该阶段耗时最长,需要遍历整个老年代对象图。
- 由于用户线程在运行,对象引用关系可能发生变化(例如:A 引用 B,用户线程断开了 A 对 B 的引用,但 C 又引用了 B)。CMS 通过写屏障 (Write Barrier) 和 增量更新 (Incremental Update) 算法来记录这些变化,确保不会漏标。
第三阶段:重新标记
- STW,暂停用户线程
- 多线程并行处理并发期间产生的引用变更日志,修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
第四阶段:并发清除
- 恢复用户线程,与GC线程同时运行
- 直接清空死亡对象的空间,不移动存活对象,释放内存空间
- 这个阶段造成CMS 最大的副作用。清除后,内存空间变得不连续。如果后续有大对象分配,可能因为没有连续空间而失败,进而触发 Full GC(Serial Old 单线程整理),导致严重的性能抖动。
- 在并发清除阶段,用户线程新产生的垃圾对象无法被本次 GC 清除(因为标记阶段已经结束),只能等到下一次 GC 再清理。这导致 CMS 不能像其他收集器那样等待堆内存完全填满再回收,必须预留一部分空间给用户线程运行。
2. 优点
- 并发收集低停顿
3. 缺点
(1)内存碎片化
- 长期运行后,老年代充满碎片。当需要分配一个大对象(无法放入不连续的小块空间)时,即使总剩余空间足够,也会分配失败。
- 灾难性降级:一旦分配失败,JVM 会触发 Full GC。此时 CMS 失效,转而使用 Serial Old 收集器(单线程、标记 - 整理)。这会导致长时间的 STW(秒级甚至分钟级),完全违背了 CMS 低延迟的初衷。
- 缓解方案:开启 -XX:+UseCMSCompactAtFullCollection(Full GC 时压缩碎片)和 -XX:CMSFullGCsBeforeCompaction(设置几次 Full GC 后压缩一次)。
(2)浮动垃圾与 Concurrent Mode Failure
- 由于并发清除阶段产生的新垃圾无法回收。所以CMS 不能等到堆满了再回收,必须预留空间(-XX:CMSInitiatingOccupancyFraction,默认 68%)给并发期间的用户线程使用。
- 如果在并发回收过程中,用户线程分配的内存速度太快,导致预留空间不够用,就会触发 Concurrent Mode Failure。
- 灾难性降级:同样,一旦发生 Concurrent Mode Failure,CMS 会立即中断,转为 Serial Old 进行 Full GC,导致长时间 STW。
4. 漏标问题解决
(1)为什么要解决漏标
CMS 采用三色标记法来标记存活对象:
- 白色:未被标记的对象(可能是垃圾,也可能是待标记的存活对象)。
- 灰色:自身已标记,但子对象尚未完全扫描的对象。
- 黑色:自身及所有子对象都已标记完成的存活对象。
在并发标记阶段,用户线程仍在运行,可能会修改对象间的引用关系。如果处理不当,会出现漏标,即一个存活的对象被错误地标记为白色(垃圾),导致后续被误回收,引发程序崩溃。
漏标的两个必要条件(同时满足才会发生):
- 插入了一条或多条从黑色对象到白色对象的引用(使得白色对象可达)。
- 删除了从灰色对象到该白色对象的直接或间接引用(使得灰色对象不再指向它,且该白色对象也没有其他灰色/黑色对象指向它,除了刚插入的那条)。

(2)写屏障
写屏障不是一种硬件屏障,而是一段代码逻辑。它被插入到 JVM 执行写操作(如 a.field = b,即修改对象引用字段)的指令序列中。
当用户线程尝试修改对象引用时,写屏障会先于实际的赋值操作(或紧随其后)执行。它的核心作用是拦截这次写操作,并通知垃圾收集器:“这里有一个引用关系发生了变化”。
在 CMS 中,写屏障主要负责记录哪些内存区域(Card)发生了引用变化。它将发生变化的对象所在的内存页(Card)标记为“脏页”(Dirty Card),并记录到卡表(Card Table)中。这样,GC 在后续的重新标记阶段就不需要扫描整个堆,只需要扫描卡表中标记为脏的页即可,大大减少了 STW的时间。
(3)增量更新
增量更新是 CMS 利用写屏障来解决上述“漏标”问题的一种具体策略。
既然无法阻止用户线程在并发阶段修改引用,那就记录下这些修改。具体来说,当一个黑色对象新增了指向白色对象的引用时,增量更新策略会将这个黑色对象重新标记为灰色。
工作流程:
- 拦截:用户线程执行 blackObj.ref = whiteObj。
- 触发写屏障:写屏障检测到这次写操作。
- 记录变化:写屏障发现这是一个从黑色对象到白色对象的引用插入。
- 重新变灰:根据增量更新算法,将 blackObj 的颜色从黑色改回灰色。
- 后续处理:在 CMS 的第四个阶段——重新标记(Remark)(这是一个 STW 阶段),GC 线程会扫描所有被重新标记为灰色的对象,处理它们的引用。由于 blackObj 变回了灰色,GC 会再次扫描它引用的 whiteObj,将其标记为灰色,进而标记为黑色。

4.3 G1收集器
1. G1以前收集器的特点
(1)年轻代和老年代是各自独立且连续的内存块
(2)年轻代收集使用eden+S0+S1进行复制算法
(3)老年代收集使用标记清除/标记整理算法,必须扫描整个老年代区域
2. 什么是G1垃圾收集器
G1(Garbage-First)收集器,是以优先回收垃圾最多的区域为核心的收集器。
有如下特点:
- 它不再区分年轻代和老年代,而是把内存划分成多个独立的子区域(region),打破了物理上的代际隔离,实现了逻辑分代。
- 每个region从1M到32M不等,一个region此刻是Eden区,下一秒GC 后可能变成空闲。
- 某个 Region 此刻是Survivor区,存满后可能晋升为老年代。
- 还有专门存放超大对象的 Humongous Region(如果一个对象占用的空间超过了region容量50%以上,G1收集器就认为这是一个巨型对象)。
- 启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB-32MB,且必须是2的幂),默认将整堆身分为2048个分度。即能够支持的最大内存为:32MB*2048 =65536MB=64G内存

3. 收集过程
(1)年轻代回收
标准的复制算法,步骤如下:
- 当 Eden 区满或达到阈值时,触发 Young GC。
- 所有用户线程暂停 (STW)。
- G1 线程并行地扫描 GC Roots,处理 Remembered Set (RSet) 以解决跨代引用问题。
- 将存活对象从 Eden 和 Survivor 的 Region 复制 到新的空闲 Region 中。
- 更新引用指针,清空旧 Region。
- 恢复用户线程。
(2)老年代回收
标记-整理算法的变种,可以理解为分区式的标记-复制
第一阶段:初始标记
- STW ,暂停用户线程。
- 快速标记出直接从 GC Roots(如栈变量)能访问到的对象。耗时极短,因为它只扫根部,不往下深究。通常伴随一次普通的 Young GC 一起发生。
第二阶段:并发标记
- 用户线程和 GC 线程同时运行。GC线程从初始标记的对象出发,遍历整个堆,标记所有存活对象。
- 利用 SATB 算法记录用户线程产生的引用变化。
- 处理 RSet,梳理跨 Region 的引用。该阶段耗时最长,但因为不暂停用户,所以感知不强。
第三阶段:最终标记
- STW,再次暂停用户线程。
- 处理并发标记期间遗留的少量记录(SATB 队列里的东西)。
- 修正因为并发运行而产生的标记误差。耗时比初始标记稍长,但依然很短。
第四阶段:筛选回收
- STW,暂停用户线程。
- 根据 -XX:MaxGCPauseMillis 设定的时间预算,从所有 Region 中挑出垃圾最多的那一批 Region(包括部分新生代和部分老年代)。
- 把这些 Region 里的存活对象,一次性复制到空的 Region 中。
- 把原来那些 Region 全部清空,变成可用空间。
- 这一步不仅回收年轻代,还会顺带回收一部分老年代(这就是 Mixed GC),从而避免老年代无限膨胀。
4. 特殊对象处理:Humongous 对象
Humongous对象定义:大小超过 单个 Region 容量 50% 的对象。
这种对象直接分配在专门的老年代 Region 中,甚至可能连续占用多个 Region。
它们不会等到 Mixed GC,而是在下一次 Young GC时,如果发现没有引用了,就直接被回收(因为 Young GC 也会扫描 Humongous 区域的引用)。
如果应用产生大量 Humongous 对象,可以考虑调大 Region 的大小(-XX:G1HeapRegionSize),让它们变回“普通对象”,从而利用复制算法的高效性。
5. G1 为什么快
(1)可预测的停顿模型
这是 G1 的名字 Garbage-First 的真正含义:优先回收垃圾最多的区域。
可以通过参数 -XX:MaxGCPauseMillis=200 告诉 G1:“我每次停顿不能超过 200 毫秒”。
G1 会在后台维护一个“优先级列表”,记录每个 Region 里有多少垃圾(回收价值)。
当 GC 开始时,G1 会计算:“在 200ms 内,我能清理完哪些 Region?”然后只挑选那些垃圾最多、回收收益最大的 Region 进行清理。哪怕堆里有 100GB 数据,G1 也绝不一次性全扫,而是每次只扫一点点,确保准时结束。
(2)Remembered Set (RSet) —— 避免“全堆扫描”
在传统 CMS 中,如果要清理老年代,必须知道“新生代有没有引用老年代的对象?”,这通常需要扫描整个新生代,很慢。
G1 给每个 Region 配了一个“小账本”,叫 RSet (Remembered Set),用来记录谁引用了我。
比如:Region A(老年代)里有个对象被 Region B(新生代)引用了,Region A 的 RSet 里就会记上一笔:“B 区的某某位置引用了我”。
当 G1 要回收 Region A 时,根本不用扫描其他所有 Region,直接查 A 的 RSet 账本,就知道有哪些外部引用需要保留。虽然写入 RSet 需要一点开销,但换取了 GC 时的极速扫描。
(3)SATB (Snapshot At The Beginning) —— 解决“漏标”问题
G1 的标记阶段是并发进行的(和用户线程一起跑)。这就带来一个问题:
GC 线程刚标记完对象 A 是“存活”的。下一秒,用户线程修改了引用,让 A 变成了“垃圾”,或者让原本该回收的 B 变成了“存活”。
如果 GC 线程没注意到这个变化,可能会错误地回收掉 B(漏标),导致程序崩溃。
G1 的解决方案:SATB(原始快照)
在并发标记开始时,G1 会给所有对象拍一张“快照”。
只要用户线程修改了引用关系(比如把 A 的引用指向了 B),G1 就会把修改前的旧引用(A)记录下来,放入一个队列。
G1 认为:“不管你现在怎么改,只要在标记开始时你是存活的,我就先把你当成存活的,留到下一次 GC 再处理。”
对比 CMS:CMS 用的是“增量更新”(记录新引用),G1 用的是“原始快照”(记录旧引用)。SATB 在并发效率上通常更高,更适合大堆。
6. G1为什么不会产生内存碎片
内存碎片的产生,通常是因为“原地清除”留下了不连续的空洞,导致后来有一个大对象想进来,虽然总剩余空间够,但找不到一块连续的空闲状态的地方放它。
G1 通过以下机制彻底杜绝了这个问题:
(1)顺序复制
当 G1 决定回收某些 Region(无论是新生代还是老年代)时:
- 它会在堆的其他地方找出一组空的 Region 作为目的地。
- 它将源 Region 中所有存活的对象,按顺序复制到这些空的 Region 中。
- 复制完成后,源 Region 里的所有对象(无论死活)都被视为无效,整个 Region 被标记为“空闲”,可以立即重新分配。
- 当新对象来时,只会找一个空闲状态的Region分配内存
旧 Region:变成了 100% 的空闲大块内存,零碎片。
新 Region:存入的对象是紧密排列的,零碎片。
(2)没有“块内碎片”
在 G1 的视角里,内存分配的最小单位是 Region。
只要一个 Region 是空的,它就可以被用来分配任何大小的对象(只要对象不超过 Region 大小,超大对象有特殊处理)。
因为每次回收都是把整个 Region 腾空,所以永远不会出现“这个 Region 里这里有个洞、那里有个洞”的情况。
(3)对比 CMS(标记 - 清除)
CMS:清理后,老年代变成这样:

如果有大对象需要连续空间,就放不进去,触发 Full GC。
G1:清理后,变成这样:

- 旧区:整个 Region 归还
- 新区:紧凑排列
- 永远没有中间的空洞
(4)半满状态的Region不是碎片
由于GC后,存活的对象会被复制到一个Region中,如果Region的大小大于存活对象的大小,该Region可能为“半满”状态,但由于空闲出来的部分空间并不是空闲空间,不能被用来分配对象,所以该空间不算内存碎片。
等到下一次 Mixed GC 时,G1 会挑选这些“半满”且垃圾比例高的 Region,把里面的存活对象搬走,然后把整个 Region 腾空。一旦腾空,它就变成了“可用资源”,而不是“碎片”。
(5)大对象分配的连续性保证
如果有一个超大对象(比如 50MB),需要连续空间:
CMS:需要在堆里找到连续的 50MB 空闲字节。如果有碎片,就找不到。
G1:只需要找到 连续的 N 个空 Region(假设每个 Region 4MB,那就找 13 个连续的 Region)。
G1 维护了一个空闲 Region 列表,专门管理这些整块的空地。只要列表里有足够的连续空 Region,大对象就能分配。
这比在字节级别找连续空隙要容易得多,概率大得多。
如果真的命中极小概率,找不到连续的Region存放大对象,G1会通过下面的三步来解决:
- 尝试在当前 YGC 中“顺便”解决(概率极低)
当 YGC 因为“分配失败”被触发时,它的主要任务是清空 Eden 区和部分 Survivor 区。
YGC 会把存活对象复制到新的 Survivor 区或老年代 Region。
如果 YGC 恰好回收了一些包含“旧超大对象”的 Region,那么这些 Region 被腾空后,可能会偶然形成连续的空闲块。
但是,YGC 的算法逻辑是“收益优先”,它只选垃圾最多的 Region 回收,并不以“创造连续空间”为目标。所以,依靠 YGC 偶然凑出连续空间的概率很低,且不可控。
- 触发 Mixed GC(主要解决方案)
如果 YGC 结束后,依然没有足够的连续 Region 来分配那个超大对象,JVM 会检测到这个“持续分配失败”的状态。
G1 会立即启动并发标记周期,进而触发 Mixed GC。
Mixed GC 的目标是回收整个堆中垃圾比例最高的 Region 集合,这其中包括了大量的老年代 Region。由于很多旧的超大对象可能已经死亡,Mixed GC 会大规模地腾空老年代 Region,大大增加了“连续Region出现的概率。
注意:Mixed GC 依然不是“为了连续而整理”,而是通过“大规模清扫”让连续空间自然浮现。
- 触发 Full GC(终极手段)
如果连 Mixed GC 都无法腾出足够的连续 Region(比如堆真的满了,或者碎片化极度严重,存活对象正好把连续空间切碎了),G1 会抛出 Allocation Failure 并最终触发 Full GC。
这是 G1 的“核武器”。它会停止所有应用线程(STW),使用单线程的标记 - 整理(Mark-Compact)算法。
Full GC 会刻意地把所有存活对象强行压缩到堆内存的最前端(Region 0, 1, 2...)。执行完 Full GC 后,堆的尾部会腾出一大片绝对连续的空闲 Region。
- OOM(无能为力)
在这种情况下,没有任何 GC 算法能拯救。因为 Full GC 已经做到了物理极限——它把堆里所有存活的对象都压缩到了最前端,后面剩下的空间是整个堆里最大可能的连续空闲块,Full GC 已经消除了所有碎片。
如果这个“最大可能的连续块”都小于你的大对象所需大小,那就说明:你的堆总容量 < 存活对象总量 + 新大对象大小。
JVM将放弃挣扎,抛出OOM,回天乏术。
5、Java8-G1与Java11-G1对比
从 Java 8 到 Java 11,G1 垃圾收集器(Garbage-First)经历了脱胎换骨的进化。
在 Java 8 时代,G1 虽然可用,但常常被诟病“调优困难”、“Full GC 停顿过长”、“混合回收时机不准”。而到了 Java 11,G1 已经成为生产环境的首选默认收集器,其性能、稳定性和智能化程度都有了质的飞跃。
5.1 核心区别概览
| 特性 | Java 8 G1 | Java 11 G1 | 性能影响 |
|---|---|---|---|
| Full GC 实现 | 单线程串行 (Single-threaded) | 多线程并行 (Multi-threaded Parallel) | 巨大提升:大堆下 Full GC 停顿从秒级/分钟级降至毫秒/秒级。 |
| 混合回收 (Mixed GC) | 基于启发式阈值,较保守,易错过最佳时机 | 更智能的触发机制,主动预测,更早启动 | 显著降低:避免堆被填满,减少 Full GC 发生概率。 |
| 字符串去重 (String Dedup) | 支持,但需手动开启且效率一般 | 大幅优化,与 G1 深度集成,开销更低 | 节省内存:大量字符串场景下内存占用可降低 10%-20%。 |
| 未使用内存返还 (Return Unused Memory) | 不支持 (Java 10 引入,11 完善) | 支持 (-XX:MaxGCPauseMillis动态调整) | 弹性伸缩:容器化环境下可自动归还内存给 OS。 |
| 中断混合回收 (Cancel Mixed GC) | 不支持,一旦开始必须做完 | 支持,若超时风险大可提前终止 | 降低延迟:严格保障 MaxGCPauseMillis目标。 |
| 默认状态 | 非默认 (需 -XX:+UseG1GC) | 默认收集器 | 开箱即用:默认参数已针对现代硬件优化。 |
5.2 Java 11 G1 为什么更快
1. 并行 Full GC
这是 Java 8 G1 最大的痛点,也是 Java 11 改进最显著的地方。
Java 8 的问题:
- 当 G1 无法通过 Young GC 或 Mixed GC 回收足够内存时,会触发 Full GC。
- Java 8 的 G1 Full GC 是单线程的。它会停止所有应用线程(STW),然后由一个线程遍历整个堆,标记、整理、压缩。
如果你的堆是 32GB,单线程处理可能需要几秒甚至几十秒。对于高并发系统,这等同于服务假死。
Java 11 的升级:
- 从 Java 10 引入并在 Java 11 中成熟,G1 的 Full GC 变成了多线程并行。
- 利用所有可用的 GC 线程,将堆划分为多个部分,并行地进行标记和整理。
- 停顿时间几乎与线程数成反比。在 32 核机器上,Full GC 的停顿时间可能从 10 秒 缩短到 0.5 秒。这使得 G1 在大堆场景下真正具备了可用性。
2. 更智能的混合回收
Mixed GC 是 G1 的核心,它负责回收老年代 Region。何时启动 Mixed GC 至关重要:太早浪费资源,太晚导致堆满触发 Full GC。
Java 8 的问题:
- 触发逻辑相对简单且保守。主要依赖 -XX:InitiatingHeapOccupancyPercent (IHOP, 默认 45%)。
- 当老年代占用率达到 45% 时才开始并发标记。如果应用分配速度极快,标记还没完成,堆就满了,直接触发 Full GC。
- 无法准确预测“这次 Mixed GC 能回收多少内存”,容易导致回收力度不足。
Java 11 的升级:
- 动态 IHOP 调整:G1 会根据历次 GC 的历史数据,动态调整 IHOP 阈值。如果上次 GC 发现 45% 启动太晚了,下次会自动提前到 40% 或 35% 启动。
- 更精准的预测模型:G1 内部维护了更复杂的数学模型,预测每个 Region 的回收收益和耗时。它不仅看“垃圾比例”,还结合“分配速率”和“标记周期时长”来决策。
- 主动触发:即使未达到 IHOP,如果 G1 预测到按当前速度很快会满,也会提前启动标记周期。
通过这些升级极大地减少了 Full GC 的发生频率,让堆的使用率曲线更加平滑。
3. 严格的停顿时间控制:可中断的混合回收
G1 的设计目标是满足 -XX:MaxGCPauseMillis(最大停顿时间)。
Java 8 的问题:
- 一旦 Mixed GC 开始,G1 会尽可能多地回收选中的 Region 集合(Collection Set)。
- 如果选中的 Region 太多,导致处理时间超过了 MaxGCPauseMillis,Java 8 的 G1 也会硬着头皮做完,导致停顿超时。
Java 11 的升级:
- Mixed GC 被切分成更小的步骤。在每个步骤结束后,G1 会检查:“如果继续做下一个 Region,是否会超过停顿时间预算?”
- 如果预测会超时,G1 会立即停止当前的 Mixed GC,将剩余的 Region 留到下一次 GC 再做。
该设计虽然可能导致需要更多次 GC 才能清理完垃圾,但它严格保证了单次 STW 不会超过设定值,提升了系统的响应可预测性(这对微服务和实时系统至关重要)。
4. 字符串去重的性能优化
在很多企业应用中,字符串占据了堆内存的 20%-30%,且存在大量重复内容(如日志、JSON 键、数据库连接串)。
Java 8 的问题:
- 虽然支持 -XX:+UseStringDeduplication,但实现效率较低,去重过程本身开销较大,有时甚至得不偿失。
Java 11 的升级:
- 深度集成:去重逻辑被深度整合到 G1 的 GC 流程中(特别是在对象复制阶段)。
- 哈希表优化:使用了更高效的数据结构来追踪字符串引用。
在开启该参数后,Java 11 能以极低的 CPU 开销识别并合并重复字符串。对于特定负载,堆内存占用可减少 15% 以上,间接减少了 GC 频率。
5. RSet (Remembered Set) 处理的优化
YGC时 需要知道哪些老年代对象引用了新生代对象(跨代引用),以免误删。这需要扫描 RSet。
Java 8 的问题:
- RSet 的维护和扫描开销较大,尤其是在高并发分配场景下,RSet 的更新(写屏障)会消耗较多 CPU,且扫描效率一般。
Java 11 的升级:
- 优化了 RSet 的数据结构(引入了更精细的卡表逻辑)。
- 优化了 RSet 的扫描算法,减少了 YGC 时扫描跨代引用的时间。
YGC 的 STW 时间中,用于处理跨代引用的部分显著减少。
5.3 其他重要改进细节
内存返还:
- Java 11 的 G1 能更敏锐地感知堆的空闲情况。如果堆长期处于低负载,G1 会主动将未使用的 Region 归还给操作系统(通过 madvise 系统调用)。这对于 Kubernetes/Docker 等容器环境非常重要,可以避免容器因“占用但未使用”的内存而被 OOM Kill。
更合理的默认参数:
- Java 8 的 G1 默认参数比较保守(例如 GC 线程数较少)。
- Java 11 根据现代多核 CPU 的特点,自动增加了并行线程数,优化了 Region 大小的选择策略,使得大多数应用在零调优的情况下也能获得不错的性能。
Humongous 对象处理优化:
- 针对超大对象的分配和回收逻辑进行了微调,减少了在分配失败时触发不必要 GC 的概率,并优化了它们在 Mixed GC 中的处理方式。