🗑️ Young GC & Full GC:垃圾回收的时机大揭秘!

89 阅读9分钟

GC什么时候来收垃圾?这不是随机的,而是有严格的触发条件!让我们一起揭秘~


🎬 开场:GC的两种模式

想象一个小区的垃圾清理 🏘️

Young GC(年轻代GC) = 日常清理 🧹

  • 每天清理垃圾桶(Eden区)
  • 速度快,几十毫秒搞定
  • 频率高,一天多次

Full GC(全堆GC) = 大扫除 🧽

  • 整个小区大扫除(整个堆)
  • 速度慢,可能几秒钟
  • 频率低,但一次很累
时间轴:
Young GC: ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓
          |小|小|小|小|小|小|小|小|小|小|
          |扫|扫|扫|扫|扫|扫|扫|扫|扫|扫|

Full GC:                ↓
                        |===大扫除===|
                        (所有人都停下)

🎯 Young GC的触发条件

条件1:Eden区满了 📦

这是最常见的触发条件!

堆内存结构:
┌─────────────────────────────────────┐
│         Young Generation            │
│  ┌──────────┬──────┬──────┐         │
│  │  Eden    │ S0   │ S1   │         │
│  │ ████████ │      │ ████ │         │
│  │  满了!  │      │      │         │
│  └──────────┴──────┴──────┘         │
│            ↓                        │
│       触发Young GC!                │
└─────────────────────────────────────┘

工作流程:

// 每次new对象都在Eden区分配
User user = new User();  // 在Eden分配
Order order = new Order();  // 在Eden分配
...
// 当Eden区空间不足时
new BigObject();  // Eden放不下了!
                  ↓
             触发Young GC
                  ↓
         清理Eden区的垃圾
                  ↓
         存活对象移到Survivor区

详细过程:

GC前:
Eden区: [对象A] [对象B] [对象C] [对象D] [对象E] ← 满了
S0区:   [对象X(age=1)]
S1区:   空

GC中(复制算法):
1. 扫描Eden + S0区
2. 标记存活对象:B、D、X
3. 复制到S1区:
   - B(age=1)
   - D(age=1)
   - X(age=2)

GC后:
Eden区: 空 ← 清空了
S0区:   空 ← 清空了
S1区:   [B(age=1)] [D(age=1)] [X(age=2)]

条件2:分配担保失败 🎰

场景:
1. Young GC前,检查老年代剩余空间
2. 如果老年代空间 < 历史晋升到老年代的平均大小
3. 提前触发Full GC(为了安全)

为什么?
- Young GC时,存活对象可能晋升到老年代
- 如果老年代空间不够,就会失败
- 所以提前做Full GC腾出空间

🔥 Full GC的触发条件

条件1:老年代空间不足 🏚️

场景1:大对象直接进老年代

// 创建超大对象
byte[] bigArray = new byte[10 * 1024 * 1024];  // 10MB
// 这个对象太大,直接分配到老年代

// 如果老年代空间不足
         ↓
   触发Full GC

大对象阈值

# JVM参数
-XX:PretenureSizeThreshold=3145728  # 3MB以上直接进老年代

场景2:长期存活对象晋升

对象年龄增长:
Young GC次数:  1    2    3    4    5    ...   15
对象年龄:     age1 age2 age3 age4 age5  ...  age15
                                              ↓
                                         晋升到老年代

当老年代满了:
Old区: ████████████████████████ ← 满了!
              ↓
        触发Full GC

晋升年龄阈值

-XX:MaxTenuringThreshold=15  # 默认15次Young GC后晋升

条件2:动态年龄判断 🎂

不一定要等到15岁才晋升!

动态年龄判断规则:
如果 Survivor区中,相同年龄的所有对象大小总和 > Survivor空间的50%
那么:年龄 >= 该年龄的对象直接进入老年代

举例:
Survivor总大小:10MB
age=1的对象:2MB
age=2的对象:3MB
age=3的对象:4MB  ← 前面累加已经 > 5MB (50%)
age=4的对象:2MB

结果:age >= 3的对象全部晋升到老年代!

条件3:元空间(Metaspace)不足 📚

元空间用途:
- 存储类的元数据
- 类加载器加载的类信息
- 常量池

元空间满了:
Metaspace: ████████████ ← 达到阈值
              ↓
        触发Full GC
              ↓
        回收无用的类

典型场景:

// 场景1:大量使用CGLIB代理
for (int i = 0; i < 100000; i++) {
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(MyClass.class);
    enhancer.setCallback(...);
    Object proxy = enhancer.create();  // 每次生成新类
}
// 生成10万个代理类 → 元空间爆满 → Full GC

// 场景2:频繁加载和卸载类(如热部署)
ClassLoader loader = new MyClassLoader();
Class<?> clazz = loader.loadClass("com.example.MyClass");
// ... 使用
loader = null;  // 卸载类加载器
// 触发Full GC回收类元数据

相关参数:

# 元空间大小设置
-XX:MetaspaceSize=128m          # 初始大小
-XX:MaxMetaspaceSize=256m       # 最大大小
-XX:MinMetaspaceFreeRatio=40    # 最小空闲比例
-XX:MaxMetaspaceFreeRatio=70    # 最大空闲比例

条件4:System.gc() 调用 🎮

// 程序主动调用
System.gc();  // 建议JVM进行Full GC

// 注意:这只是"建议",不是"命令"
// JVM可以选择忽略

实际场景:

// ❌ 不好的做法
public void processLargeData() {
    // 处理大量数据
    List<BigData> dataList = loadBigData();
    process(dataList);
    
    dataList = null;
    System.gc();  // 想立即回收内存,但这样不好!
}

// ✅ 正确做法
public void processLargeData() {
    // 让JVM自己决定何时GC
    List<BigData> dataList = loadBigData();
    process(dataList);
    // dataList在方法结束后自然会被回收
}

禁用System.gc():

# 生产环境建议禁用
-XX:+DisableExplicitGC

条件5:空间分配担保失败 🎯

什么是空间分配担保?

Young GC前的检查机制:
1. JVM检查:老年代最大连续可用空间 > 新生代所有对象总大小
   ✅ 是 → 安全,执行Young GC
   ❌ 否 → 继续检查

2. JVM检查:老年代最大连续可用空间 > 历次晋升到老年代的对象平均大小
   ✅ 是 → 冒险尝试Young GC
   ❌ 否 → 改为Full GC(保险起见)

图解:

场景1:空间足够
Young: 100MB (对象总大小)
Old:   200MB (可用空间)
       ↓
   200MB > 100MB ✅
       ↓
  执行Young GC

场景2:空间不足,但历史数据OK
Young: 100MB
Old:   50MB (可用空间)
历史平均晋升: 30MB
       ↓
   50MB < 100MB ❌
但 50MB > 30MB ✅ (历史平均)
       ↓
  冒险执行Young GC

场景3:太危险了
Young: 100MB
Old:   50MB
历史平均晋升: 80MB
       ↓
   50MB < 80MB ❌
       ↓
  改为Full GC(安全第一)

相关参数:

# JDK 6 Update 24之前
-XX:+HandlePromotionFailure=true  # 允许担保失败

# JDK 6 Update 24之后
# 这个参数被废弃,JVM自动处理

条件6:并发模式失败(CMS专属)🏃‍♂️

CMS GC的并发问题:

CMS的并发标记阶段:
1. CMS在后台并发回收老年代
2. 同时,应用程序继续运行
3. 应用程序继续产生新对象

问题场景:
Old区: ████████░░░░ (正在GC中)
       ↓ CMS正在清理
       ↓ 但应用继续分配对象
Old区: ██████████░░ (新对象进来了)
       ↓ CMS还没清理完
       ↓ 应用又分配对象
Old区: ████████████ (满了!)
       ↓
   Concurrent Mode Failure!
       ↓
   降级为Serial Old (STW)
       ↓
   停顿时间巨长!💥

解决方案:

# 提前触发CMS GC
-XX:CMSInitiatingOccupancyFraction=70  # 老年代70%时触发CMS
-XX:+UseCMSInitiatingOccupancyOnly     # 只用这个阈值

# 增大老年代
-Xmx4g  # 增大堆大小
-XX:NewRatio=2  # 老年代是新生代的2倍

📊 GC触发条件总结表

GC类型触发条件频率停顿时间影响范围
Young GCEden区满几十ms新生代
Full GC老年代满几百ms-几秒整个堆+元空间
Full GC元空间满很低几百ms整个堆+元空间
Full GCSystem.gc()取决于代码几百ms整个堆
Full GC空间担保失败几百ms整个堆
Full GCCMS失败罕见几秒老年代

🎪 实战案例分析

案例1:频繁Young GC 🔥

现象:

# jstat监控
jstat -gcutil <pid> 1000

  S0     S1     E      O      M     YGC    YGCT    FGC
  0.00  50.00  99.99  30.00  90.00  1000   50.00   5
  0.00  50.00  99.99  30.05  90.00  1001   50.05   5
  0.00  50.00  99.99  30.10  90.00  1002   50.10   5

# 问题:
# - E(Eden区)一直在99.99%
# - YGC(Young GC次数)疯狂增长
# - 1秒就触发1次Young GC!

原因分析:

// 问题代码
@RestController
public class UserController {
    @GetMapping("/users")
    public List<User> getUsers() {
        // 每次请求都创建10万个对象
        List<User> users = new ArrayList<>();
        for (int i = 0; i < 100000; i++) {
            users.add(new User("user" + i));  // 疯狂创建对象
        }
        return users;
    }
}

// 问题:
// - Eden区太小,放不下这么多对象
// - 每次请求都触发Young GC

解决方案:

# 方案1:增大新生代
-Xmn512m  # 新生代512MB

# 方案2:调整新生代比例
-XX:NewRatio=2  # 老年代:新生代 = 2:1

# 方案3:优化代码(最根本)
@GetMapping("/users")
public List<User> getUsers(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "100") int size) {
    // 分页查询,不要一次加载所有数据
    return userService.getUsers(page, size);
}

案例2:频繁Full GC 💥

现象:

jstat -gcutil <pid> 1000

  S0     S1     E      O      M     YGC    FGC    FGCT
  0.00   0.00  20.00  99.99  95.00  100    50    250.00
  0.00   0.00  25.00  99.99  95.00  100    51    255.00
  0.00   0.00  30.00  99.99  95.00  100    52    260.00

# 问题:
# - O(老年代)一直99.99%
# - FGC(Full GC次数)疯狂增长
# - FGCT(Full GC总耗时)已经250秒!

可能原因:

原因1:内存泄漏
// 问题代码
public class CacheService {
    private static Map<String, byte[]> cache = new HashMap<>();
    
    public void addCache(String key) {
        // 不断添加,never清理
        cache.put(key, new byte[1024 * 1024]);  // 每次1MB
    }
}

// 结果:老年代被占满,频繁Full GC
原因2:老年代太小
# 查看当前配置
java -XX:+PrintFlagsFinal -version | grep HeapSize

# 发现:
InitialHeapSize = 256MB  # 太小了!
MaxHeapSize = 256MB

# 解决:增大堆大小
-Xms2g -Xmx2g
原因3:大对象太多
// 问题代码
public void processData() {
    // 创建大量大对象
    for (int i = 0; i < 1000; i++) {
        byte[] data = new byte[5 * 1024 * 1024];  // 5MB
        process(data);
    }
}

// 5MB的对象直接进老年代
// 1000个对象 = 5GB → 老年代爆满

🎯 GC调优建议

1. Young GC优化 🎯

# 目标:降低Young GC频率和停顿时间

# 策略1:增大新生代
-Xmn1g  # 新生代1GB

# 策略2:调整Eden和Survivor比例
-XX:SurvivorRatio=8  # Eden:S0:S1 = 8:1:1

# 策略3:使用G1 GC(自动调整)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200  # 目标停顿时间200ms

2. Full GC优化 🎯

# 目标:减少或避免Full GC

# 策略1:增大老年代
-Xmx4g  # 总堆4GB
-XX:NewRatio=2  # 老年代占2/3

# 策略2:调整晋升阈值
-XX:MaxTenuringThreshold=15  # 延迟晋升

# 策略3:使用CMS或G1
-XX:+UseConcMarkSweepGC  # CMS GC
-XX:+UseG1GC  # G1 GC

# 策略4:元空间调优
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# 策略5:禁用System.gc()
-XX:+DisableExplicitGC

3. 监控和诊断 📊

# 启用GC日志
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/path/to/gc.log

# 使用GC日志分析工具
# - GCEasy (https://gceasy.io)
# - GCViewer
# - JProfiler

🎓 总结:GC触发条件口诀

┌────────────────────────────────────┐
│      Young GC触发条件              │
├────────────────────────────────────┤
│ 1. Eden区满了(最常见)✅          │
│ 2. 空间分配担保检查                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│      Full GC触发条件               │
├────────────────────────────────────┤
│ 1. 老年代空间不足 🏚️               │
│ 2. 元空间不足 📚                   │
│ 3. System.gc()调用 🎮              │
│ 4. 空间分配担保失败 🎯             │
│ 5. CMS并发模式失败 🏃‍♂️            │
│ 6. 晋升失败(Promotion Failed)     │
└────────────────────────────────────┘

记住三句话:

  1. Young GC是常态,Full GC是异常 🚨

    • Young GC频繁很正常
    • Full GC频繁说明有问题
  2. Eden满触发Young GC,老年代满触发Full GC 🎯

    • 最基本的触发条件
  3. 优化目标:减少Full GC,缩短停顿时间 ⏱️

    • Full GC才是性能杀手

下次面试官问GC触发条件,你就说

"Young GC主要在Eden区满时触发,采用复制算法,速度快,停顿时间短。Full GC的触发条件更复杂:老年代空间不足、元空间不足、System.gc()调用、空间分配担保失败都可能触发。Full GC会回收整个堆,停顿时间长,是性能优化的重点。生产环境要避免频繁Full GC,可以通过增大堆内存、优化代码减少大对象创建、使用G1 GC等方式优化!" 🎓

🎉 掌握GC触发条件,性能调优不再迷茫! 🎉