JVM 垃圾回收机制详解
JVM 的垃圾回收(Garbage Collection, GC)是 Java 区别于 C/C++ 最显著的特征之一——开发者不需要手动 free() 内存,JVM 会自动判断哪些对象已经"死了"并回收它们占用的空间。但"自动"不代表"免费":GC 会引入停顿(Stop-The-World),不同的回收器在吞吐量、延迟、内存占用之间做着截然不同的取舍。理解 GC 的底层原理,是 Java 性能调优的必修课。
比喻:GC 就像一个大楼的物业保洁系统。大楼(堆内存)走廊里堆满了各种废弃物(垃圾对象),保洁团队需要定期清理无人认领的垃圾。问题在于:为了避免扫地乱套,有时清理需要让所有人站着别动(STW),而不同的清理策略(算法和回收器)决定了停工多久、清理得多干净、耗费多少人力。
JVM 堆内存的分代模型
绝大多数 JVM 实现(HotSpot)将堆内存分为新生代和老年代,这个设计基于一个经验观察——弱分代假说(Weak Generational Hypothesis):
- 绝大多数对象朝生暮死:临时变量、方法内局部对象,创建后很快就不再被引用
- 熬过多次 GC 的对象,往往能存活更久:静态缓存、单例对象、长生命周期的数据结构
基于这两个观察,HotSpot 将堆划分为不同区域,对"短命对象"和"长寿对象"采用不同的回收策略:
堆内存 (Heap)
┌──────────────────────────────────────────────────────────────────┐
│ 新生代 (Young Generation) ≈ 1/3 堆 │
│ ┌──────────────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Eden 区 (8/10) │ │ S0 (1/10)│ │ S1 (1/10)│ │
│ │ (新对象在这里分配) │ │ From │ │ To │ │
│ └──────────────────┘ └─────────┘ └─────────┘ │
├──────────────────────────────────────────────────────────────────┤
│ 老年代 (Old Generation) ≈ 2/3 堆 │
│ ┌──────────────────────────────────────────────────────────────┐│
│ │ 存放经过多次 GC 仍然存活的对象、大对象 ││
│ └──────────────────────────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────┘
非堆区域:
┌─────────────────────────────────┐
│ 方法区 / Metaspace │
│ (类元数据、常量池、静态变量) │
└─────────────────────────────────┘
各区域的角色
| 区域 | 大小比例 | 存放内容 | 回收频率 |
|---|---|---|---|
| Eden | 新生代的 80% | 新创建的对象 | 最高(Minor GC) |
| Survivor 0/1 | 各占新生代的 10% | 经过一次 GC 存活的对象 | 随 Minor GC |
| 老年代 | 堆的 2/3 | 长期存活的对象、大对象 | 较低(Major/Full GC) |
| Metaspace | 堆外(本地内存) | 类元数据(JDK 8+ 替代永久代) | 极少 |
为什么 Survivor 需要两个?
这和新生代使用的复制算法直接相关。复制算法要求一个"From 区"和一个"To 区":每次 Minor GC,Eden 和当前 From 区中存活的对象被复制到 To 区,然后 From 和 To 角色互换。这样保证 To 区始终是空的,不会产生内存碎片。
比喻:这就像你有两个书桌(S0 和 S1)。每次整理时,把还在用的书从一个桌子搬到另一个,刚腾出来的桌子就干净了。两个桌子轮流当"干净的目标桌"。
JDK 8 移除永久代
在 JDK 7 及以前,方法区的实现叫永久代(PermGen),它是堆内存的一部分,大小固定(默认 64MB-256MB),容易出现 java.lang.OutOfMemoryError: PermGen space。
JDK 8 用 Metaspace 替代了永久代:
// JDK 7: 永久代(开始退化,字符串常量池被移到堆内存)
-XX:PermSize=256m -XX:MaxPermSize=512m
// JDK 8+: 彻底废除永久代,引入 Metaspace(使用本地内存)
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m
核心变化:
- JDK 7 的过渡:预感到永久代容易 OOM,JVM 团队率先把字符串常量池和静态变量从永久代剥离,直接放进了堆(Heap)中。
- JDK 8 的彻底变革:剩下的类元数据(Class Metadata)被移出堆内存,放入使用物理机本地内存(Native Memory)的 Metaspace 中。
- 参数避坑:注意,
-XX:MetaspaceSize并不像-Xms那样是“初始分配容量”,它其实是触发第一次 Full GC 清理无用类的阈值(高水位线)。如果不设置,默认往往只有 21MB,极其容易在启动时引发极其耗时的 Full GC。线上务必将其与 Max 设为一致(如都设为 256m)。
内存是怎么分配的?
有了内存模型,对象是怎么“塞”到这些空间里的?为了极致的分配性能,JVM 使用了两项核心技术。
1. TLAB (Thread Local Allocation Buffer)
在多线程环境下,如果所有线程都抢着在 Eden 区申请内存,就需要进行同步锁定(Locking),这会大大降低效率。
JVM 的策略是:化整为零。它从 Eden 区中为每个线程预先分配一小块私有缓冲区,称为 TLAB。
保洁比喻:行政部(JVM)如果每张 A4 纸都要员工排队申领,效率极低。于是行政部给每个员工预发了一盒 A4 纸(TLAB)。员工用自己的纸写字(分配对象)不需要和别人商量,只有当这一盒纸用光了,才去行政部申请下一盒(这叫 TLAB 重填,此时才需要同步锁)。
2. 指针碰撞 (Pointer Bumping)
如果内存是规整的(比如新生代),分配内存简直快到难以置信:只需要把一个指针向后挪动对象大小的距离即可。
比喻:就像在公园长椅上坐人,大家排排坐,新来的人只要坐在最后一个人旁边的空位即可,不需要四处找空位。
什么时候会触发垃圾回收(GC)?
不论是哪一种垃圾回收器,GC 的触发核心往往都逃不开**“内存不够了”或“预感到内存快不够了”**这两大根本原因。
通常来说,我们把触发的时机从宏观上分为三类:
1. 内存真的塞满了(最普遍的被动触发)
这是绝大多数(尤其是早期和中期)GC 被触发的直接原因:
- Minor GC (新生代回收):当你的代码不断执行
new Object()时,JVM 发现当前负责分配的区域(新生代的 Eden 区)已经没有足够的连续空间了。这时候它会被迫“停下来打扫卫生”。 - Full GC (全堆回收):如果老年代空间也被塞满,或者存活的对象太多导致新生代晋升老年代时发现老年代接纳不下,就会触发代价极大、停顿最长的 Full GC。
2. 未雨绸缪的抢跑(现代并发回收器的通用机制)
像 CMS、G1 甚至是 ZGC 这些现代回收器,由于要在“业务线程正常运行”的同时去清理垃圾(并发清理),所以它们绝对不能等到内存 100% 耗尽才动手,否则新产生的垃圾将无处安放(引发 Allocation Stall)。
- 因此,只要老年代的内存占用率达到某个红线阈值(比如 CMS 默认 92%,G1 的
InitiatingHeapOccupancyPercent默认 45%),或者底层的自适应算法预判接下来内存会不足,系统就会主动抢跑,提前触发并行的 GC 长周期。
3. 外界干预(人为或系统环境强制触发)
- 代码显式调用:程序员在代码里显式调用了
System.gc()(虽然仅是建议,但通常也会立刻触发一次最昂贵的 Full GC,生产环境极不推荐)。 - 元空间满:存放类元数据的 Metaspace(元空间)达到了高水位线,也会强制触发 Full GC 来卸载无用的类加载器和类。
- 超大对象:分配极其巨大的单体对象时(比如好几百兆的大数组),新生代根本装不下,老年代可能也没有那么多连续的大块碎片,系统会直接触发一次清理整理。
垃圾判定:怎么知道一个对象"死了"?
在回收之前,JVM 首先要回答一个问题:哪些对象是垃圾?
方案一:引用计数法(JVM 不使用)
给每个对象维护一个计数器,每被引用一次 +1,引用失效 -1,计数为 0 则回收。
// 引用计数法的致命缺陷:循环引用
class Node {
Node next;
}
Node a = new Node(); // a 引用计数 = 1
Node b = new Node(); // b 引用计数 = 1
a.next = b; // b 引用计数 = 2
b.next = a; // a 引用计数 = 2
a = null; // a 引用计数 = 1(≠0,不会被回收!)
b = null; // b 引用计数 = 1(≠0,不会被回收!)
// a 和 b 互相引用,永远不会被回收 → 内存泄漏
引用计数法简单高效,Python 的 CPython 实现就使用它(配合周期检测来解决循环引用)。但 JVM 没有选择它,主要原因就是循环引用问题,以及维护计数器本身的开销。
方案二:可达性分析(JVM 实际使用)
HotSpot JVM 使用可达性分析算法(Reachability Analysis):从一组称为 GC Roots 的起始节点出发,沿着引用链遍历,所有可达的对象都是存活的;不可达的对象就是垃圾。
GC Roots
│
├──→ 对象 A ──→ 对象 B ──→ 对象 C ← 全部可达,存活
│
└──→ 对象 D ← 可达,存活
对象 E ──→ 对象 F ──→ 对象 E ← 不可达(循环引用也能回收)
对象 G ← 不可达
比喻:GC Roots 就像"组织关系网"的根节点。你从公司 CEO(GC Root)开始,顺着汇报关系能找到的所有员工都是"在职的";找不到的就是"游离人员",可以清退。
GC Roots 到底有哪些?
GC Roots 是一组在当前执行上下文中必然存活的引用起点:
| GC Root 类型 | 具体含义 | 示例 |
|---|---|---|
| 虚拟机栈中的局部变量 | 当前正在执行的方法中引用的对象 | 方法中的 Object obj = new Object() |
| 方法区中类静态属性 | static 变量引用的对象 |
static Map cache = new HashMap() |
| 方法区中常量引用 | static final 常量引用的对象 |
static final String NAME = "test" |
| 本地方法栈中 JNI 引用 | native 方法中使用的对象 | JNI NewGlobalRef() 创建的引用 |
| 活跃线程 | 所有存活的 Thread 对象 | new Thread(() -> {...}).start() |
| 同步锁持有的对象 | synchronized(obj) 中的 obj |
持锁对象在锁释放前不会被回收 |
| JVM 内部引用 | 基本类型的 Class 对象、系统类加载器等 | String.class、Object.class |
💡 深入思考:为什么本质上是这些对象作为 GC Roots?
判定一个对象能不能作为起点,底层逻辑是看它此刻是否正在被外接系统(或当前执行流)硬性依赖。
- 为什么「虚拟机栈中的局部变量」是 GC Root? 虚拟机栈体现的是当前线程正在执行的方法行为。你的代码跑到了哪一行,栈帧就在哪里。如果一个局部变量正在当前方法的作用域中参与运算(比如刚
new出来还没return),说明程序此刻正在实打实地使用它。既然正在使用,就绝对不可能是垃圾。相反,如果方法执行完毕,栈帧出栈,这个局部变量就随之灰飞烟灭,它所指向的堆内存对象也就成了无源之水(垃圾)了。- 为什么「本地方法栈中 JNI 引用」也是 GC Root? Java 有时需要通过 JNI 去调用 C/C++ 写的 Native 本地方法。C/C++ 代码自己管理内存(
malloc/free),JVM 的垃圾回收器是无法扫描 C/C++ 的内存空间的。如果一个 Java 对象被传递给了 C/C++ 环境中使用,或者 C/C++ 主动创建了一个 Java 对象的引用(如GlobalRef),JVM 不知道 C/C++ 什么时候会用完这个对象。为了保证这个 Java 对象在被 C 语言端访问时不会被意外清空导致段错误或宕机,JVM 只能强制把它标记为 GC Root——只要 C/C++ 那边不主动释放引用,JVM 就绝不动它。
/**
* 演示:代码中的哪些变量/对象属于 GC Roots?
*/
public class GCRootsDemo {
// 1. 方法区中的静态变量引用(GC Root)
private static Map<String, Object> cache = new HashMap<>();
// 2. 方法区中的常量引用(GC Root)
private static final String APP_ID = "DEMO_1001";
public void start() {
// 3. 虚拟机栈(栈帧中的局部变量)中的引用(GC Root)
// list 本身是一个引用,存储在当前方法的局部变量表中,指向堆中的 ArrayList 对象
List<String> list = new ArrayList<>();
list.add("Hello");
// 4. 线程(GC Root)
// 存活状态下的线程对象及其引用的对象也是 GC Root
new Thread(() -> {
// 线程执行期间,其栈帧内的局部变量是 GC Root
System.out.println("Processing...");
}).start();
synchronized (this) {
// 5. 同步锁(synchronized)持有的对象(GC Root)
// 在锁释放前,当前对象 this 作为锁 ID 的持有者,不会被回收
doProcess();
}
}
private void doProcess() {
// 方法调用形成的栈帧嵌套,堆栈深处的局部变量依然构成 GC Roots 链条
}
}
对象的"缓刑":finalize()
即使可达性分析判定对象不可达,它也不会立即被回收。JVM 给了对象一次"自救"的机会——finalize() 方法:
- 第一次标记:对象不可达,被放入 F-Queue
- JVM 启动低优先级 Finalizer 线程执行
finalize() - 如果在
finalize()中重新建立了引用链(比如SomeGCRoot.ref = this),对象"复活" - 如果没有复活,第二次标记后被真正回收
public class FinalizeDemo {
static FinalizeDemo SAVE = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
// 在 finalize 中自救:重新引用自己
SAVE = this;
System.out.println("finalize() 被调用,对象自救成功");
}
public static void main(String[] args) throws Exception {
SAVE = new FinalizeDemo();
// 第一次:finalize 会被调用,对象自救成功
SAVE = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE != null ? "存活" : "已回收"); // 存活
// 第二次:finalize 不会再被调用(每个对象只调用一次),对象被回收
SAVE = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE != null ? "存活" : "已回收"); // 已回收
}
}
为什么不推荐使用 finalize():执行时机不确定、可能永远不被调用、有性能开销。JDK 9 中已被标记为
@Deprecated,推荐使用try-with-resources或Cleaner。
三大回收算法
判定完垃圾之后,下一步是"怎么回收"。JVM 使用三种基础算法,每种都是不同场景下的最优解。
标记-清除(Mark-Sweep)
最基础的算法,分两个阶段:
- 标记阶段:从 GC Roots 遍历,标记所有可达对象
- 清除阶段:遍历堆内存,回收未标记的对象
标记前: [A] [B] [_] [C] [_] [D] [E] [_] (A, C, D 可达)
标记后: [A✓][B] [_] [C✓][_] [D✓][E] [_]
清除后: [A] [_] [_] [C] [_] [D] [_] [_] (B, E 被清除)
↑ ↑
碎片! 碎片!
优点:实现简单,不需要移动对象。
缺点:
- 内存碎片:清除后留下大量不连续的空闲空间,无法分配大对象
- 效率不稳定:存活对象多时,标记和清除工作量都很大
复制算法(Copying)
将内存分为大小相等的两半,每次只使用其中一半。GC 时将存活对象复制到另一半,然后把整个已使用的半区一次性清空。
使用中: [A] [B] [C] [D] [E] | [空空空空空] (A, C, D 存活)
↓ 复制存活对象
复制后: [清空清空清空清空清空] | [A] [C] [D] (整齐排列,无碎片)
优点:无碎片、分配快(指针碰撞即可)、实现简单。
缺点:浪费一半内存空间。
HotSpot 的优化:新生代不是简单的 1:1 分割,而是 Eden : S0 : S1 = 8:1:1。因为研究表明新生代中 98% 的对象在第一轮 GC 就会死亡,所以实际只浪费了 10% 的空间(一个 Survivor 区)。当 Survivor 不够时,老年代作为分配担保。
标记-整理(Mark-Compact)
标记阶段与标记-清除相同,但清除阶段改为:将所有存活对象向一端移动,然后直接清理边界以外的空间。
标记前: [A] [_] [B] [_] [C] [_] [D] [_] (A, B, D 存活)
整理后: [A] [B] [D] [_] [_] [_] [_] [_] (存活对象压缩到一端)
↑
分界点,右侧全部清空
优点:没有碎片问题,空间利用率高。
缺点:移动对象需要更新所有引用,开销大(尤其是老年代存活对象多时)。
深度对比:复制算法 vs 标记-整理算法
这两种算法解决了“标记-清除”留下的内存碎片问题,但它们走的是截然不同的技术路线。理解它们的差异是理解新生代、老年代划分的基础:
| 对比维度 | 复制算法 (Copying) | 标记-整理 (Mark-Compact) |
|---|---|---|
| 核心动作 | 搬家:直接把存活对象搬到隔壁一栋全新空房子里,旧房子直接推平时清空。 | 大平移:在同一栋房子里,把所有存活对象都往左边墙角推挤,直到排得严严实实,右边空出来的部分作废。 |
| 空间开销 | 极高 (空间换时间)。必须切出一块完全空白、足够大的内存(如空置的 Survivor 区或一半的内存),平时处于闲置浪费状态。 | 极低。不需要额外的内存作担保,直接在当前使用的这块内存里“原地内部消化”。 |
| 时间开销 | 与「存活对象数量」成正比。如果绝大部分对象都死了,只需要复制极少几个活着的,效率飞快。 | 与「存活对象数量 + 内存总大小」成正比。需要计算大量对象偏移后的新地址,并在原有空间里大量滑动对象,改写引用,非常沉重。 |
| 适用场景 | 新生代(朝生暮死,尸横遍野,存活极少,搬运成本低)。如果给老年代用,存活对象太多,内存不够切,移动也极度消耗性能。 | 老年代(老油条多,大部分都活着,几乎没有剩余空间)。没有多余空间去作两半切分,宁愿整理时慢一点,也好过放不下。 |
比喻:
- **复制算法(新生代)**像是在大学食堂吃完饭收盘子:大部分盘里的饭都倒进了垃圾桶(死亡),只有极少数干净的盘子放到回收车里(复制到 Survivor)。操作非常麻利。
- **标记-整理(老年代)**像是图书馆理书架:书架满了而且绝大部分书都是有用的(存活对象多),你没办法凭空腾出一个新书架,只能一格一格地把书紧紧挨在一起排好(向一端平移),把空隙一点点挤出来。操作非常吃力。
GC 核心概念解析
在深入了解各代垃圾回收器之前,我们需要理解几个在架构设计与 JVM 调优中绕不开的核心概念。
1. STW (Stop-The-World):为什么要暂停世界?
GC 发生时,JVM 会暂停所有的用户线程(你写的业务代码),这个短暂的停顿被称为 STW (Stop-The-World)。为什么不能一边产垃圾一边打扫?
保洁比喻:想象保洁阿姨在打扫一间坐满程序员的办公室。如果大家都在走动、扔垃圾,阿姨刚扫完的地方又脏了;或者阿姨刚看中一个空瓶子正要去捡,你突然把它拿走喝水了(引用关系颠覆)。为了算清楚到底哪些是真垃圾,阿姨只能大吼一声:“所有人,放下手里的活,站在原地别动!”——这就是 STW。
所有回收器都在努力减少 STW 的时间,但至今没有谁能完全消除它。
2. Safepoint (安全点):大家在哪停?
当 JVM 发起 STW 时,并不是强行把所有线程立刻冻结(这不安全且难以实现),而是设置一个标志位。所有线程运行到某个安全点 (Safepoint) 时,会主动去轮询这个标志位,如果发现需要 GC,就主动挂起自己。
保洁比喻:阿姨喊“停”的时候,如果你正端着一杯滚烫的咖啡走在过道上,立刻原地定住可能会把咖啡洒出来。所以大家有一个约定:听到喊停,你必须走到且只能走到最近的工位坐下(进入安全点),然后再停住。
安全点通常设置在:方法调用、循环跳转、异常跳转等“长时间执行”的边界处。如果在 GC 发起时某个线程迟迟不到达 Safepoint(比如它正在狂跑一个毫无停顿的超大循环),整个大部队就得干等它,导致 STW 还没开始打扫,停顿时间就已经被拖长了。这就叫“Safepoint 等待停顿”。
3. 并发 (Concurrent) vs 并行 (Parallel)
这两个词在操作系统的语境里和在 GC 的语境里略有不同:
- 并行 (Parallel):描述的是 GC 线程之间的关系。多名保洁员同时在一个房间里打扫,但此时房间依然是关闭的(用户线程依然处于 STW 停顿状态)。
- 并发 (Concurrent):描述的是 GC 线程与用户(业务)线程之间的关系。保洁员在打扫的同时,员工还在继续办公。互相不耽误(或者说让员工误认为没受多大影响)。
4. 吞吐量 vs 延迟
这是评价 GC 性能的两个核心指标,它们往往是“鱼与熊掌不可兼得”:
- 吞吐量 (Throughput) = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)。
- 就像一家餐厅追求一天能接待多少客人。不管中途为了彻底大清扫关门了半小时,只要总接待量大就行。适合后台离线计算、跑批任务。
- 延迟 (Latency) = 单次 STW 的最大停顿时间。
- 就像餐厅要保证每个客人进来都能在 5 分钟内吃上饭,绝不能有被冷落半小时的情况。为此,即使保洁员要更频繁地进来稍微扫扫(影响一点总接待量和业务效率),也要保证任何时候客人的响应体验。适合在线交易、Web 服务等对响应时间敏感的系统。
理解了这点,你就会明白:没有最好的回收器,只有最适合当前业务的回收器。
垃圾回收器:一场与 STW 的百年战争
JVM 垃圾回收器的演进史,本质上是硬件从单核走向多核、从几兆内存走向 TB 级内存,同时业务对“停顿时间”容忍度越来越低的发展史。我们可以将其划分为四个世代。
第一代:早期,单核时代的单打独斗 (Serial 家族)
在 Java 诞生的早期(上世纪 90 年代),最主流的机器还只有单核 CPU,内存只有几十兆。这时候,最简单的策略反而是最高效的。
Serial / Serial Old
-
角色分工:Serial 管新生代(复制算法),Serial Old 管老年代(标记-整理算法)。
-
工作机制:全程 STW 的单线程执行。
用户线程: ──────────┤ STW ├──────────
GC 线程: [1个保洁员]
比喻:孤单的保洁员 只有一个人打扫几十平米的小房子,打扫时房间锁门(STW)。期间大家什么也干不了,但因为房子小,阿姨扫得飞快,一瞬间就弄完了,大家觉得也没啥。
- 现状:它虽然古老,但并没有被淘汰。在微服务云原生时代,很多运行在 Docker 里的内存只有一两百兆的 Client 级小应用,如果给容器只分了极少核,使用 Serial 反而最高效,因为它完全没有多线程上下文切换的开销。
- 参数:
-XX:+UseSerialGC
第二代:中期,多核时代的吞吐量之王 (Parallel 家族)
随着硬件性能爆发,CPU 进入多核时代,服务器动辄几个甚至几十个核心。这时候再让一个保洁员打扫,其他人全闲着看,太暴殄天物了。
Parallel Scavenge / Parallel Old(JDK 8 默认)
-
核心理念:堆人力并充分利用多核并发,追求极致的吞吐量。
-
工作机制:全程 STW 的多线程执行。
用户线程: ──────────┤ STW ├──────────
GC 线程: [保洁员 A]
[保洁员 B]
[保洁员 C]
比喻:专业突击保洁队 大楼变大了,一个人扫不动。于是请来了一个团队!打扫时依然要锁门停业(STW),但好在该团队人多手快,突击一波几秒钟搞定。虽然顾客在门外等了一下,但一天下来,营业时间是最长的(吞吐量最高)。
- 独门绝技:自适应调节策略 (Adaptive Size Policy)。你不需像老司机那样去精细调优(调
-Xmn、Survivor 比例等),只需发号施令“我希望最大停顿不超过多少毫秒(-XX:MaxGCPauseMillis)”或者“我希望吞吐量达到多少(-XX:GCTimeRatio)”,底层引擎会动态微调各区域大小来尽量逼近你的目标。 - 适用场景:后台计算、数据分析、大批量报表生成等——不在乎偶尔卡顿几秒,只在乎总计算能多快算完。
- 参数:
-XX:+UseParallelGC
第三代:现代,大型 Web 服务的低延迟先驱 (CMS)
进入互联网时代,电商、社交平台等 Web 服务对系统响应时间(Latency)的容忍度急剧下降。如果此时由于老年代满了触发 Full GC,导致系统长时间 STW(比如卡顿几秒钟),用户的请求就会大面积阻断或超时,这是业务不可接受的。于是,业界首次尝试了让 GC 线程和用户线程同时运行(并发)。
CMS(Concurrent Mark Sweep)
-
最高指令:尽最大努力缩短单机最长停顿时间。
-
算法选择:老年代使用标记-清除算法。
-
新生代搭档:一般配合 ParNew(本质上是个能与 CMS 默契配合的 Parallel 版新生代收集器)。
CMS 的跨时代意义在于它将复杂的垃圾清除工作进行了更为精细的拆分,从而允许大部分清理工作与业务线程并行。它分为四个阶段:
用户线程: ────┤STW├────────────────────┤STW├────────────
CMS 线程: [初始] [并发标记] [并发清除]
[标记] [重新标记]
- 初始标记 (STW):只找 GC Roots “直接关联”的第一层对象,极快极度轻量。(大门锁死 0.1 秒,保洁员飞速扫视一眼所有部门经理桌上放了什么,然后马上开门放行)
- 并发标记 (Concurrent):顺藤摸瓜遍历整个如同迷宫般的对象图,耗时极长,但无需暂停用户业务。(保洁员顺着经理的吩咐,在大楼里四处游走找垃圾。此时大门开着,员工照常走动办公办公。这是划时代的进步!)
- 重新标记 (STW):为什么还要 STW?因为在“并发阶段”员工到处乱走,导致垃圾关系发生了颠覆性变化。(大门再缩死 0.5 秒。确认刚才走动时,“有没有人把原本扔进垃圾篓的文件又当宝贝捡回桌面了?” 修复这些动态变化错漏。)
- 并发清除 (Concurrent):将判死刑的垃圾全部清理掉,与用户完全并发。(把确定的垃圾装袋移出系统外,完全不影响楼里面的人作业)
比喻:边工作边打扫(但不搬家具) 保洁员在你工作时顺着过道扫地(并发)。为了快,她只把地面的垃圾捡走,绝不触碰和挪移你的任何办公桌(坚持使用有碎片缺陷的标记-清除算法)。因为在你不避让(不停机)的情况下,如果她硬移你的桌子,你放在桌上的水(也就是应用层持有的指针)绝对要倒泼一地。
CMS 的三大致命伤(了解这些痛点,你才知道为什么 G1 会横空出世):
- CPU 敏感:在极长并发段,它虽然没锁死,但占用了大量 CPU 内核资源(默认占用 $25%$)。核数少的机器会明显感觉到变卡。
- 浮动垃圾:在最后的“并发清除”阶段,员工又制造了新垃圾,这一次来不及倒了,只能等下次(这叫浮动垃圾)。要是垃圾激增撑爆了内存,发生 Concurrent Mode Failure,CMS 就会当场罢工,并召唤出最古老的 Serial Old 来救场。此时你的系统将迎来地狱般漫长且绝望的单线程大号 STW!
- 内存碎片:由于坚持不搬桌子(不整理),久而久之内存就像千疮百孔的马蜂窝,最后连一个稍大点的内容(大数组连续空间)都塞不进,无奈触发 Full GC 重新大洗牌。
(注:英雄迟暮,CMS 这种先天带缺陷的方案在 JDK 9 被标记废弃,JDK 14 被彻底剔除,光荣退役。)
第四代:当代,大内存时代的 Region 革命 (G1 与 ZGC)
当堆内存跃迁到十几 GB 甚至上百 GB 的时代,CMS 的并发标记就算跑断腿也太慢了,且一旦发生 Full GC 碎片整理,其耗时可达几十秒!这个时候,传统把内存非黑即白地劈成新老两块(物理隔离)的设计哲学,走进了死胡同。
G1(Garbage First,JDK 9+ 默认王者)
G1 石破天惊地打破了物理的代际边界墙,它将巍峨雄伟的整个堆内存,切成了成百上千个大小统一的 Region(独立小棋格)。
G1 棋盘式内存布局:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ E │ S │ O │ O │ E │ H │ H │空闲 │
├─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ O │空闲 │ E │ O │空闲 │ O │ S │ E │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
E = Eden(新生代), S = Survivor, O = Old(老年代)
H = Humongous(巨型对象特区,针对超大对象连租几个格子)
注意:一个格子不仅这一秒可以当新生代,下一秒被清空后,也可以摇身变成老年代!
- 颠覆性理念:局部性胜利。既然一口气扫不完一整栋楼,那我就不强求一次全局大清理了!我只要建立一个可量化的停顿预测模型。你给我定个死线(比如默认停机最多只能 200ms),我倒排工期,这 200ms 够我扫几个最脏的屋,我就只扫那几个!
- 工作机制:后台维护一张优先级算力列表,精准记录每个 Region 里的“垃圾占比有多大”以及“打扫它预计耗时多少毫秒”。当触发混合回收 (Mixed GC) 时,谁家垃圾最多、扫起来最划算,就优先回收谁(这就是 Garbage First 这个霸气名字的由来)。
- 底层算法组合拳:看似大局上它是“标记-整理”,但在微观层面——当它决定清理某些 Region 时,它使用的却是复制算法:把 302 室里仅剩的活干部的座位,以 STW 的方式整体合并抬到一个崭新的空闲格子去,然后把原 302 室连锅端炸平。不仅解决了 CMS 的痛点,更彻底消灭了内存碎片!
比喻:智能的划区特种保洁 面对比迪拜塔还雄伟的巨型大楼,经理不再憨憨地要求从 1 楼扫到顶楼。而是把大楼切成了 2000 个房间(Region 小隔间)。系统探头一扫:“302 室有 90% 都是垃圾,扫只要 2 毫秒;501 室只有 5% 是垃圾,里面的活人太多扫起来巨麻烦,要 10 毫秒”。
经理(G1)瞅了眼老板给的 KPI(即
-XX:MaxGCPauseMillis必须 200ms 内),大手一挥:挑出性价比最高最脏的那些个房间!派特种兵进去,把里面为数不多的几个活人强请到一间隔壁完全干净的新房间(复制转移),然后一键引爆把原来那些脏乱房间的地板连根拔起(无碎片清空)!
ZGC(Z Garbage Collector,极低延迟时代的王者)
技术的发展终究无止境。当人类把内存拉高到几十上百 TB 的“天神级别”,即便强大如 G1 也显得力不从心了:在 G1 转移幸存对象(Region 复制)的过程中,因为涉及对象地址的实物搬离切换,这可是实打实需要 STW 而且往往成了 G1 停顿最长耗时的阶段!如果在极其庞大的内存中需要搬运海量对象,系统将怎样保证低延迟?ZGC 给出了答案。
作为 JDK 11 引入、JDK 15 转正的现代垃圾回收器,ZGC 的设计目标极其激进:无论你的堆是在跑 8 MB 的树莓派,还是跑 16 TB 的超级计算机,停顿时间永远不超过 1 毫秒(亚毫秒级)! 不受堆大小牵制,更不受存活对象多寡制约。
它使用了两项颠覆传统垃圾回收器设计的核心技术:
- 染色指针 (Colored Pointers):自古以来,区分一个对象“是死是活、被没被动过”,都是在对象实例头自身的结构里做标记暗号。ZGC 大胆逆流而上:利用 64 位操作系统的强大寻址空间,直接抽取 64 位对象指针中的几个高位空闲比特(如 4 个标志位),直接在指针自身的引用比特位上记录该指针的状态戳(无需跳转到内存对象头部去查验,指针里就自带了对这个对象操作的状态,快如闪电)。
- 读屏障 (Load Barrier) 与 指针自愈机制:ZGC 为什么敢在程序员代码高强度运行的同时,偷偷把对象实体从 A 坑强制物理拖拽到 B 坑?按常理,指针如果没跟着马上更新落空,程序绝对直接空指针段错误宕机。在 ZGC 的结界内,每次用户层视图“通过引用抓取真实对象”的瞬间,底层系统都会自动插入并触发极速的“屏障”。如果这个屏障捕捉到你要抓的对象前一秒刚被忍者搬走,它不仅顺滑地瞬间为你路由到该对象在新地点的真实位置,它还自带灵性——顺手把你手里的这把旧钥匙信息,原地自动打磨改写翻新成最新坐标的数据!底层技术名为“读屏障自愈机制”。
比喻:降维打击的隐形时空忍者(乾坤大挪移) 你正在办公桌 A 前发疯般高速敲着键盘做着极重的运算(业务线程狂奔)。隐形的保洁忍者(ZGC)觉得这一带太乱,决定必须此刻把你连人带桌转移到极其遥远的写字楼 B 去。
以前老牌的做法(G1转移阶段):必须用麻醉针把你放倒定身(漫长 STW!),雇起重机把你搬过去,再全网发通告更新花名册。在这个过程结束前,谁也别想见到你。
ZGC 的科幻做法:忍者运转神功,就在你大拇指敲击空格键那微小间隙,直接通过时空扭曲把你全套实体瞬移到了遥远的写字楼 B 且你依然在敲击键盘毫无中断感!(真正的全量并发大转移)。那别的同事用老地址找你咋办?此时忍者早已顺风在你桌子 A 的原址下了一道超维坐标系!当其他部门同事拿着旧路牌信誓旦旦冲向桌子 A 寻你时(瞬间踩中触发读屏障!),原地的结界不仅毫不迟疑地将他通过虫洞直通送到写字楼 B 的真身前,还带着不可思议的魔力,神奇地把他手里捏着的那张旧路牌上的墨迹,原地蒸发重写成写字楼 B 的崭新门牌号(指针瞬间自愈更新完毕)!
下次该同事或者其他拿着新门牌号的人再来,就直接畅通无阻去写字楼 B 了,连结界都不用再触发。所有的打扫在这套精妙至极的法术前,顺畅入幻影。
灵魂拷问:ZGC 什么时候会进行 GC?
与传统 GC 的“等 Eden 区塞满再打扫”(被动触发)截然不同,由于 ZGC 的核心大戏——“对象转移”是与你的真实业务长线“并发”赛跑的,它绝对不能等到内存真正耗尽时才开始清理。如果等没内存了再打扫,业务线程新创建的对象就会彻底无处安放,继而发生可怕的 Allocation Stall(分配阻塞)——业务线程被迫挂起漫长等待,这将彻底粉碎“亚毫秒停顿”的神话。
因此,ZGC 像一个算无遗策的精算师,设计了极为精密的多维抢跑触发机制:
- 自适应算法触发 (Allocation Rate):最主力的常规手段。ZGC 的控制大脑会以微秒级采集应用当前的“对象分配车速(消耗率)”,并实时评估“打扫完毕这一波预计要花多久”。只要它算出:按照你们现在的造垃圾速度,如果此时我还不动身跑去清理,等你们把剩余的空地占满时我都还没打扫完!——这时它就会立刻提前起跑触发 GC。
- 主动触发 (Proactive GC):当系统处于平稳或空闲状态,ZGC 也会“闲着也是闲着”,主动启动一次低频打扫(默认开启)。目的是防患于未然,始终保持最充裕的空闲内存阵列去迎接未知的突发流量洪峰。
- 预热触发 (Warmup GC):仅在应用刚启动的初期阶段。由于自适应引擎里一片空白,ZGC 会在堆内存首次占用达到 10%、20%、30% 等节点连续主动触发几次 GC,不为别的,只为以最快速度收集系统在真实运行环境下的各项真实性能开销数据,以此去“喂饱”大脑,建立精准的自适应算力预判模型。
- 定时触发 (Timer GC):如果距离上一次跑 GC 的时间间隔过久(可通过
-XX:ZCollectionInterval调整,默认不开启),将强制发车一次清扫兜底。 - 阻塞分配触发 (Allocation Stall):这是最绝望的底线。当业务洪峰突如其来,远远超出自适应机制的算力预判极限,导致内存在这场“并发赛跑”中被提前彻底干光。此刻,所有试图在堆上分配新对象的业务线程全部撞墙式宕机挂起等待,ZGC 会陷入最高优先级的暴力清扫狂暴模式(这也是 JVM 极力避免的末日场景)。
回收器综合对比盘点
| 回收器阶段 | 核心算法选择 | 并发/并行策略 | 核心设计理念 | 预期 STW 停顿 | 适配堆范围 | JDK 默认配置 |
|---|---|---|---|---|---|---|
| Serial (早期) |
局部复制 + 整体整理 | 单线程 | 简单就是王道、追求极致单核效率 | 较长 | < 100MB | — |
| Parallel (中期) |
局部复制 + 整体整理 | 并行线程 | 充分利用多核资源、追求极高吞吐量 | 中间 | 中等容量 | JDK 8 默认 |
| CMS (近代前奏) |
局部复制 + 原地清除 | 并发清理 | 尽早让业务线程恢复,消除长卡顿 | 能接受 | 大容量 | 已光荣废弃 |
| G1 (现代霸主) |
局部跨区复制消碎片 | 启发式并发 | 建立可量化模型,停顿时间精准可控 | 精准定标 | 中大(4~64G) | JDK 9+ 默认 |
| ZGC (极低延迟) |
指针染色+全量并发挪移 | 极短 STW | 解除堆大小和停顿时间的耦合关联 | 恒定亚毫秒 | 星辰大海(16T) | JDK 21 LTS 重点特性 |
Minor GC、Major GC 与 Full GC
这三个术语经常混淆,需要准确区分:
| 类型 | 回收区域 | 触发条件 | 停顿时间 |
|---|---|---|---|
| Minor GC (Young GC) | 仅新生代 | Eden 区满 | 通常 < 50ms |
| Major GC | 仅老年代 | 取决于回收器 | 较长 |
| Full GC | 整个堆 + 方法区 | 多种条件(见下) | 最长,秒级 |
Minor GC 的执行过程
GC 前:
Eden: [AAABBCCDDEE] Survivor From: [FF] Survivor To: [空] Old: [OO]
① 从 GC Roots 标记存活对象(假设 A, C, F 存活)
② 将 Eden + From 中存活对象复制到 To
Eden: [清空] Survivor From: [清空] Survivor To: [A,C,F] Old: [OO]
③ From 和 To 角色互换
Eden: [空] Survivor From(原To): [A,C,F] Survivor To(原From): [空] Old: [OO]
④ 存活对象年龄 +1。到达阈值(默认 15)→ 晋升到老年代
Full GC 的触发条件
Full GC 是性能杀手,要尽量避免。其触发条件包括:
- 老年代空间不足:新生代晋升过来的对象放不下
- Metaspace 空间不足:类元数据太多
- 显式调用
System.gc():建议 JVM 执行 Full GC(可被-XX:+DisableExplicitGC禁止) - CMS 并发失败(Concurrent Mode Failure):CMS 并发清除阶段老年代空间不够放新晋升的对象,退化为 Serial Old
- 空间分配担保失败:Minor GC 前检查老年代剩余空间,不够则直接触发 Full GC
- 大对象直接分配到老年代但空间不足
内存分配遇阻时的四个应对阶段
当程序执行 new Object() 遭遇内存不足时,JVM 不会立即崩溃,而是会触发一套严密的应急处理流程:
- 第一阶段:Minor GC (小修 小补)
- 触发:Eden 区满了,放不下新对象。
- 处理:清理新生代。如果清完够了,分配成功;如果还是没位子,进入下一阶段。
- 第二阶段:Full GC (大扫除)
- 触发:Minor GC 也救不了场,或者因为分配担保逻辑预判老年代装不下。
- 处理:全堆(新生代+老年代+方法区)重度清理。
- 第三阶段:堆扩容 (弹性伸缩)
- 触发:Full GC 扫完后,发现空闲内存比例太小(例如低于
-XX:MinHeapFreeRatio默认的 40%),而且当前堆大小 <-Xmx(最大堆上限)。 - 处理:JVM 会向操作系统申请物理内存进行扩容。注意,它不是“抠搜”地只扩容出那个对象所需的一丁点空间,而是会批量扩充步长,试图让空闲内存比例重新回到安全线(只要不超过
-Xmx限制)。
- 触发:Full GC 扫完后,发现空闲内存比例太小(例如低于
- 最终审判:OutOfMemoryError (宣告失败)
- 触发:已经扩容到
-Xmx极限,且刚刚做完 Full GC 依然放不下。 - 结局:JVM 彻底绝望,抛出
java.lang.OutOfMemoryError: Java heap space。
- 触发:已经扩容到
深入解读常见的 GC 现象与预警日志:
- Allocation Failure (正常现象):你往往会在 GC 日志频繁看到它。它并不代表报错!它仅仅代表“JVM 尝试在新生代(通常是 Eden 区)分配内存,但空间不够了,于是主动触发一次 Minor GC”。这是最标准、最健康的 GC 触发缘由。
- Concurrent Mode Failure (严重警告):在使用 CMS 等并发收集器时,业务线程产生新垃圾的速度超过了 GC 并发清理的速度,导致可用内存彻底枯竭。此时系统会被迫挂起所有业务,退化使用单线程的 Serial Old 收集器进行漫长的抢救式清扫(引发剧烈的全系统停顿)。
- GC Overhead Limit Exceeded:系统将超过 98% 的时间用于 GC,但仅仅回收了不到 2% 的堆内存。这是一种预警机制,表明系统发生了严重的内存泄漏,或者分配给 JVM 的堆容量已远远无法支撑业务运算。
对象从新生代晋升到老年代
一个对象从"新生"变"老"有几条路径:
1. 年龄达到阈值
对象在 Survivor 区每经历一次 Minor GC,年龄 +1。达到 -XX:MaxTenuringThreshold(默认 15)后晋升到老年代。
对象年龄存储在对象头的 Mark Word 中,占 4 个 bit,最大值为 15(2⁴ - 1 = 15),这也是默认阈值为 15 的原因。
2. 动态年龄判定
即使没达到年龄阈值,如果 Survivor 区中相同年龄的所有对象大小总和 > Survivor 空间的一半,则年龄 >= 该年龄的对象直接晋升。
// 伪代码:动态年龄判定
long totalSize = 0;
for (int age = 1; age <= MaxTenuringThreshold; age++) {
totalSize += sizeOfObjectsWithAge(age);
if (totalSize > survivorCapacity / 2) {
// 年龄 >= age 的对象全部晋升到老年代
tenuringThreshold = age;
break;
}
}
3. 大对象直接进入老年代
超过 -XX:PretenureSizeThreshold 的对象(仅 Serial 和 ParNew 有效)直接在老年代分配,避免在 Eden 和 Survivor 之间来回复制大对象。
4. Survivor 空间不足(分配担保)
Minor GC 后存活对象太多、Survivor 装不下时,多余的对象直接晋升到老年代。
堆内存的动态伸缩机制
JVM 的堆并不是一块铁板,它会像呼吸一样随业务负荷“动态伸张”。
1. 扩容与缩容的触发点
JVM 会通过 -Xms (初始堆) 和 -Xmx (最大堆) 划定范围。在满足以下比例时会触发调整:
- 扩容:如果某次 GC 后,空闲内存占堆的比例低于 40% (
-XX:MinHeapFreeRatio),JVM 就会向系统“要内存”,直到达到目标空闲比。 - 缩容:如果某次 GC 后,空闲内存占堆的比例高于 70% (
-XX:MaxHeapFreeRatio),JVM 就会把内存“还给系统”,以节省资源。
2. 生产环境调优建议:为什么建议 -Xms = -Xmx?
在很多高并发生产环境(如双 11、秒杀系统),你常会看到这两个参数设为一致。
- 理由:扩容和缩容是一项耗时的操作。JVM 需要与操作系统交互、重新分配地址。如果内存频繁在 40% ~ 70% 之间跳动,会导致严重的性能震荡。
- 结论:为了保持服务的极致稳定,建议一次性给够堆内存,不给 JVM “反复横跳”的机会。
GC 调优:JVM 参数实战
GC 调优的核心方法论是:先监控,再分析,最后调整。不要盲目修改参数。
核心参数速查
# ===== 堆大小 =====
-Xms4g # 初始堆大小(建议与 -Xmx 相同,避免动态扩容)
-Xmx4g # 最大堆大小
-Xmn1g # 新生代大小(使用 G1 时不建议设置)
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
# ===== 选择回收器 =====
-XX:+UseSerialGC # Serial + Serial Old
-XX:+UseParallelGC # Parallel Scavenge + Parallel Old(JDK 8 默认)
-XX:+UseConcMarkSweepGC # ParNew + CMS(JDK 9+ 弃用)
-XX:+UseG1GC # G1(JDK 9+ 默认)
-XX:+UseZGC # ZGC(JDK 15+)
# ===== G1 调优 =====
-XX:MaxGCPauseMillis=200 # 目标最大停顿(默认 200ms)
-XX:G1HeapRegionSize=16m # Region 大小
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的阈值
# ===== GC 日志 =====
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
# JDK 9+(统一日志框架)
-Xlog:gc*:file=gc.log:time,level,tags
调优思路
-
确定目标:是追求吞吐量(批处理)还是低延迟(在线服务)?
- 吞吐量优先 → Parallel 或 G1
- 低延迟 → G1 或 ZGC
-
设置堆大小:一般设置为物理内存的 50%-75%,
-Xms和-Xmx相同 -
开启 GC 日志:分析 GC 频率、停顿时间、各代空间使用情况
-
观察 Full GC:
- 频繁 Full GC → 老年代太小,或对象晋升太快
- Minor GC 后存活对象太多 → Survivor 太小,调整
SurvivorRatio - 大对象频繁进入老年代 → 调整
PretenureSizeThreshold或使用 G1
-
避免的常见错误:
- ❌ 不要同时指定
-Xmn和-XX:NewRatio(会冲突) - ❌ 不要将
-Xmx设得太接近物理内存(给 OS 和 Metaspace 留空间) - ❌ 不要使用
System.gc(),用-XX:+DisableExplicitGC禁止
- ❌ 不要同时指定
深度原理解释与辨析
Q1:为什么新生代用复制算法而不是标记-整理?
因为新生代中 90%+ 的对象在第一轮 GC 就会死亡。复制算法只需要复制少量存活对象(通常 < 5%),无脑撞击指针就可以分配空间,效率极高。就像食堂收盘子,大部分全倒入垃圾桶,只顺手把极少数干净盘子移走放到回收车。而“标记-整理”需要一寸一毫地移动所有存活对象并同步更新整个引用树,在对象大面积死亡的场景下极大地降低了性能。
Q2:CMS 和 G1 的本质区别是什么?
- 内存布局:CMS 是传统的物理隔断(新生代、老年代各自在内存中物理连续);G1 是把整块内存切分为两千多个大小均匀的物理区块(Regions),但对每个 Region 赋予不同的逻辑身份。
- 算法细节:CMS 坚持“不移动存活对象”(标记-清除),久了不可避免地产生具有破坏性的内存碎片;G1 则是在回收时将存活对象转移到新的 Region 并立即清空旧 Region(局部跨区复制整合),从根源上消除了内存碎片。
- 停顿控制模型:CMS 追求单机最快的垃圾扫描,但无法控制发生碎片整理或浮动垃圾撑爆时的极端长时间停顿;G1 引擎底层建立了一套“多维预计算耗时评估体系”。依据设定的目标时间(如
-XX:MaxGCPauseMillis),它会优先选择垃圾占比最高、打扫耗时最短的 Region 进行回收,从而实现了极其硬核的长期停顿红线管控(这也是 Garbage First 名字的由来)。
Q3:ZGC 为什么能实现极低(亚毫秒级)停顿?
传统 GC 的重量级 STW 往往源自“转移并更新指向大对象/海量存活对象的引用关系”。ZGC 祭出了两大颠覆性设计:
- 越级染色指针:不再笨重地跑去对象头上刻标记位,而是直接反向操作截获那用不完的 64 位空余指针位!这就等于直接在每个门牌号自身的墨水里写入了生化状态。
- 读屏障的瞬间自愈:当强悍的用户狂奔代码试图捏住旧指引跟进拿对象拿数据的当口,ZGC 下的封印结界捕获了这一举动,发现对方前一秒恰好被系统偷偷移动了,它不但不会报空指针段崩溃,而是瞬间从平行宇宙返回新地址所在的真身数据,同时——极其逆天地利用屏障魔力顺手把业务代码捏着的那张失效旧门卡翻新改写成了最潮的新版本!
所以,ZGC 内繁如牛毛的“全量级对象转移大搬迁”也是完全不用让你停下手中的活儿跟你一起赛跑的!(并发大转移)。整场戏只有最初极其短暂的锁定 Roots 看一眼的刹那在锁大门(STW),因此能横推把停卡时间压在 1 毫秒以下,不可战胜!
Q4:什么情况下对象会直接进入老年代?
- 大对象超过
-XX:PretenureSizeThreshold(仅 Serial / ParNew 有效) - Survivor 装不下时的分配担保
- 动态年龄判定直接晋升
- G1 中超过 Region 50% 的 Humongous 对象直接分配到 Humongous Region(直属老年代管控大棋盘)