JVM内存模型与垃圾回收器深度解析

引言

去年双十一大促前夜,我们一个核心订单服务突然出现诡异现象:QPS 从 8000 骤降到 1200,接口 P99 从 50ms 飙到 3.2s。运维同学第一反应是"机器不够",加了两台机器后问题依旧。我登上机器 jstat -gcutil 一看:

  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  99.87  99.99  99.98  95.21  93.12  18234  312.451  1523  891.234  1203.685

老年代(O)利用率 99.98%,Full GC 高达 1523 次,累计 GC 时间 891 秒——也就是说,这台机器有 近 15 分钟 的时间在"打扫卫生",而不是在处理业务。更诡异的是,堆内存总共才 4G,但监控显示 RSS 占了 6G。

这就是典型的 JVM 内存模型理解不透 + GC 选型错误 导致的线上事故。很多工程师写了 5 年代码,对 JVM 的认知还停留在"堆分新生代老年代"这种教科书级别。但当你要做性能调优、排查 OOM、设计高吞吐服务时,这种认知是远远不够的。

这篇文章,我会带你从 源码级别 拆解 JVM 内存布局和主流垃圾回收器的工作机制,并用三个可运行的实战代码帮你把理论落到地上。


核心概念:把 JVM 想象成一家"餐厅"

理解 JVM 内存模型,最好的类比是一家 连锁餐厅:

餐厅元素 JVM 对应 说明
厨师私人工作台 程序计数器、虚拟机栈、本地方法栈 每个厨师(线程)独享,用完即走
中央厨房大仓库 堆(Heap) 所有厨师共享,放食材(对象)
菜单配方墙 方法区 / 元空间 放类结构、常量、静态变量
传菜窗口 直接内存(Direct Memory) NIO 绕过堆,直接从后门送菜
保洁团队 垃圾回收器 不定时清理过期食材

关键点在于:餐厅的翻台率取决于保洁团队的效率。保洁员怎么找垃圾、什么时候清、用扫帚还是吸尘器,直接决定了餐厅能接待多少客人。

技术定义:JVM 运行时数据区

根据《Java 虚拟机规范》,运行时数据区分为:

  • 线程私有:程序计数器(PC Register)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)
  • 线程共享:堆(Heap)、方法区(Method Area,JDK 8 后由元空间 Metaspace 实现)
graph TD subgraph JVM["JVM 运行时数据区"] subgraph Private["线程私有"] PC[程序计数器] VS[虚拟机栈] NMS[本地方法栈] end subgraph Shared["线程共享"] HEAP[堆 Heap] MA[方法区/元空间] end end subgraph Direct["堆外内存"] DM[直接内存 Direct Memory] end TH1[Thread-1] --> PC TH1 --> VS TH2[Thread-2] --> PC TH2 --> VS HEAP --> YG[新生代] HEAP --> OG[老年代] YG --> Eden YG --> S0[Survivor0] YG --> S1[Survivor1]

堆的默认比例(以 JDK 8 为例,Parallel GC 默认):

  • 新生代 : 老年代 = 1 : 2(-XX:NewRatio=2)
  • Eden : S0 : S1 = 8 : 1 : 1(-XX:SurvivorRatio=8)

一家餐厅如果 2/3 的空间用来放"长期存货",1/3 用于"当日周转",这就是 JVM 默认的策略——假设你的对象大部分活不过一轮 GC,这就是著名的弱分代假说(Weak Generational Hypothesis)。


源码深度分析:从对象分配到 GC 触发

1. 对象分配的 TLAB 机制

你以为 new Object() 直接落到 Eden?实际上 HotSpot 会先在 TLAB(Thread Local Allocation Buffer) 里分配。TLAB 是每个线程在 Eden 里预占的一小块内存(默认占 Eden 的 1%),避免多线程分配时的指针碰撞锁竞争。

源码在 hotspot/src/share/vm/gc_implementation/shared/ 下,核心逻辑在 CollectedHeap::allocate_new_tlab 和 ThreadLocalAllocBuffer::allocate:

// hotspot/src/share/vm/memory/threadLocalAllocBuffer.cpp (简化)
HeapWord* ThreadLocalAllocBuffer::allocate(size_t size) {
  // 1. 检查 TLAB 剩余空间是否够
  if (_top + size <= _end) {
    HeapWord* obj = _top;
    _top += size;
    return obj;  // 指针碰撞,无锁,极快
  }
  return NULL;   // TLAB 用完了
}

如果 TLAB 分配失败,会走 refill_waste_limit 策略:要么在 Eden 里重新申请一块 TLAB,要么直接退化到共享 Eden 分配(此时需要 CAS 或加锁)。

这就是为什么高并发下有些对象的分配会比平时慢——TLAB 频繁用尽后触发重分配。

2. 对象何时进入老年代

对象晋升老年代有 4 条路径,对应源码 hotspot/src/share/vm/gc_implementation/parallelScavenge/psPromotionManager.cpp:

  1. 年龄达到阈值:-XX:MaxTenuringThreshold(默认 15),每次 Minor GC 存活 +1
  2. 动态年龄判定:Survivor 中相同年龄对象总和 > Survivor 一半,直接晋升
  3. 大对象直接进老年代:-XX:PretenureSizeThreshold(只对 Serial/ParNew 有效)
  4. Survivor 空间不足:晋升时发现装不下,直接进老年代

动态年龄判定是最容易被忽略的坑。源码逻辑:

// hotspot/src/share/vm/gc_implementation/shared/ageTable.cpp
void AgeTable::compute_tenuring_threshold(size_t survivor_capacity) {
  size_t desired_survivor_size = (size_t)(survivor_capacity * 
                                          TargetSurvivorRatio / 100.0);
  size_t total = 0;
  uint age = 1;
  for (; age < table_size; age++) {
    total += sizes[age];
    if (total > desired_survivor_size) break;  // 累计超过目标就停
  }
  // 最终晋升年龄取 min(age, MaxTenuringThreshold)
  _tenuring_threshold = MIN2(age, MaxTenuringThreshold);
}

TargetSurvivorRatio 默认 50%。也就是说,如果 Survivor 里年龄为 1 的对象占了超过 50%,那么所有年龄 ≥1 的对象下次 GC 直接进老年代。这会导致老年代对象暴增,提前触发 Full GC。

3. GC Roots 与可达性分析

JVM 判断对象存活用的是 可达性分析,从 GC Roots 出发遍历引用链。GC Roots 包括:

  • 虚拟机栈中局部变量表引用的对象
  • 方法区静态属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈 JNI 引用的对象
  • 同步锁 synchronized 持有的对象
  • JVM 内部引用(基本类型 Class 对象、常驻异常对象、系统类加载器)

注意:不是所有对象都能被 GC 掉,即使不可达,如果重写了 finalize() 且未被调用过,会先进入 F-Queue 等待一次"死缓"。


实战代码:三个可运行示例

示例 1:亲眼见证对象晋升老年代

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.util.ArrayList;
import java.util.List;

/**
 * 演示:对象年龄晋升与动态年龄判定
 * 
 * 启动参数建议:
 * java -Xms200m -Xmx200m -Xmn100m -XX:SurvivorRatio=8 
 *      -XX:+PrintGCDetails -XX:+PrintTenuringDistribution 
 *      -XX:MaxTenuringThreshold=15 PromotionDemo
 */
public class PromotionDemo {

    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws InterruptedException {
        printMemoryPools("初始状态");
        
        // 分配 3 个 1MB 的对象,反复分配触发 Minor GC
        // 每个对象经历一次 Minor GC 年龄 +1
        byte[] obj1 = new byte[_1MB];
        byte[] obj2 = new byte[_1MB];
        byte[] obj3 = new byte[_1MB];
        
        // 手动触发多次 Minor GC 来增加对象年龄
        for (int i = 0; i < 16; i++) {
            byte[] temp = new byte[_1MB];
            // 让 temp 快速死掉,制造 GC 压力
            temp = null;
            System.gc(); // 提示 GC(仅用于演示,生产环境禁用)
            Thread.sleep(100);
            
            if (i % 4 == 0) {
                printMemoryPools("第 " + (i + 1) + " 轮 GC 后");
            }
        }
        
        // 观察 obj1~obj3 是否已晋升到老年代
        printMemoryPools("最终状态");
        System.out.println("obj1=" + obj1.length + ", obj2=" + obj2.length 
                           + ", obj3=" + obj3.length);
    }

    private static void printMemoryPools(String stage) {
        System.out.println("\n===== " + stage + " =====");
        List<MemoryPoolMXBean> pools = ManagementFactory.getMemoryPoolMXBeans();
        for (MemoryPoolMXBean pool : pools) {
            if (pool.getName().contains("Eden") 
                || pool.getName().contains("Survivor")
                || pool.getName().contains("Old")) {
                System.out.printf("%-30s used=%-10d committed=%-10d max=%d%n",
                        pool.getName(),
                        pool.getUsage().getUsed(),
                        pool.getUsage().getCommitted(),
                        pool.getUsage().getMax());
            }
        }
    }
}

观察要点:运行后看 PS Eden Space 的 used 是否周期性归零(Minor GC),PS Old Gen 的 used 是否逐步上升(对象晋升)。配合 -XX:+PrintTenuringDistribution 能看到每次 GC 后各年龄段的对象大小。

示例 2:模拟 Metaspace OOM(方法区溢出)

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;

import java.util.ArrayList;
import java.util.List;

/**
 * 演示:JDK 8 元空间(Metaspace)OOM
 * 
 * 依赖:cglib-nodep:3.3.0
 * 
 * 启动参数:
 * java -XX:MaxMetaspaceSize=32m -XX:+HeapDumpOnOutOfMemoryError MetaspaceOOMDemo
 * 
 * 预期输出:java.lang.OutOfMemoryError: Metaspace
 */
public class MetaspaceOOMDemo {

    public static void main(String[] args) {
        List<Class<?>> classes = new ArrayList<>();
        int count = 0;
        
        try {
            while (true) {
                // 每次用 CGLib 动态生成一个新类,永久驻留在 Metaspace
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(MetaspaceOOMDemo.class);
                enhancer.setUseCache(false); // 关键:禁用缓存,每次生成新类
                enhancer.setCallback((MethodInterceptor) (obj, method, 
                        args1, proxy) -> proxy.invokeSuper(obj, args1));
                
                Class<?> clazz = enhancer.create().getClass();
                classes.add(clazz);
                count++;
                
                if (count % 500 == 0) {
                    System.out.println("已生成 " + count + " 个类");
                }
            }
        } catch (OutOfMemoryError e) {
            System.err.println("OOM 出现!共生成 " + count + " 个类");
            e.printStackTrace();
        }
    }
}

关键知识点:JDK 7 之前字符串常量池在永久代,JDK 8 后字符串常量池移到堆,而类元数据放到本地内存的 Metaspace。所以 java.lang.OutOfMemoryError: PermGen space 在 JDK 8 已经消失,取而代之的是 Metaspace。

示例 3:G1 GC 参数调优实测

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
 * 演示:模拟真实业务场景下的内存分配
 * 
 * 对比测试参数:
 * 
 * [Parallel GC]
 * java -Xms4g -Xmx4g -XX:+UseParallelGC 
 *      -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
 *      -Xloggc:gc-parallel.log G1TuningDemo
 * 
 * [G1 GC]
 * java -Xms4g -Xmx4g -XX:+UseG1GC 
 *      -XX:MaxGCPauseMillis=200 
 *      -XX:InitiatingHeapOccupancyPercent=45 
 *      -XX:+PrintGCDetails -XX:+PrintGCDateStamps 
 *      -Xloggc:gc-g1.log G1TuningDemo
 * 
 * 观察两个日志文件的吞吐量和停顿时间差异
 */
public class G1TuningDemo {

    // 模拟订单对象:一部分短命,一部分长命
    static class Order {
        long orderId;
        String userId;
        double amount;
        byte[] payload = new byte[1024]; // 1KB 模拟业务数据
    }

    private static final List<Order> longLivedCache = new ArrayList<>();
    private static final Random RANDOM = new Random();

    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        long totalAllocated = 0;
        
        // 模拟 60 秒业务流量
        for (int second = 0; second < 60; second++) {
            // 每秒处理 5000 个订单
            for (int i = 0; i < 5000; i++) {
                Order order = new Order();
                order.orderId = RANDOM.nextLong();
                order.userId = "user-" + RANDOM.nextInt(10000);
                order.amount = RANDOM.nextDouble() * 1000;
                totalAllocated += 1024;
                
                // 5% 的订单进入长生命周期缓存(模拟热点数据)
                if (RANDOM.nextInt(100) < 5) {
                    longLivedCache.add(order);
                }
                // 其他 95% 处理完即丢,成为垃圾
            }
            
            // 控制缓存大小,避免真正的 OOM
            if (longLivedCache.size() > 100_000) {
                longLivedCache.subList(0, 50_000).clear();
            }
            
            Thread.sleep(1000); // 模拟 1 秒的时间片
        }
        
        long cost = System.currentTimeMillis() - start;
        System.out.printf("总耗时: %d ms, 总分配: %.2f GB, 缓存对象数: %d%n",
                cost, totalAllocated / 1024.0 / 1024 / 1024, longLivedCache.size());
    }
}

如何分析结果:用 GCViewer 或 gceasy.io 打开生成的 gc 日志。G1 通常表现为停顿更均匀(每次 50-200ms),而 Parallel GC 是吞吐量优先,单次停顿可能 500ms+ 但总 GC 时间更短。

选择依据:

  • 追求低延迟(P99 < 200ms)→ G1 / ZGC
  • 追求高吞吐(批处理、离线计算)→ Parallel GC
  • 堆 > 32G 且停顿要求 < 10ms → ZGC / Shenandoah

方案对比:主流垃圾回收器全景

graph LR subgraph 分代式["分代式 GC"] Serial[Serial
单线程,新生代] ParNew[ParNew
多线程,新生代] ParallelScavenge[Parallel Scavenge
吞吐优先] SerialOld[Serial Old
单线程,老年代] ParallelOld[Parallel Old
吞吐优先] CMS[CMS
并发标记清除,低延迟] end subgraph 分区式["分区式 GC"] G1[G1
Region 化,可预测停顿] end subgraph 全并发["全并发 GC"] ZGC[ZGC
染色指针,<10ms] Shenandoah[Shenandoah
Brooks 指针] end
回收器 停顿模型 适用堆大小 吞吐量 延迟 JDK 版本
Serial STW 全停顿 < 100M 中 高(秒级) 全版本
Parallel STW 全停顿 1G ~ 8G 最高 高 JDK 8 默认
CMS 并发标记 + STW 清理 4G ~ 16G 中 低(< 100ms) 9 已废弃,14 移除
G1 Region + 可预测停顿 4G ~ 32G 中高 低(可设目标) JDK 9+ 默认
ZGC 全并发,染色指针 8G ~ 16T 中 极低(< 10ms) JDK 15+ 生产就绪
Shenandoah 全并发,Brooks 指针 8G ~ 1T 中 极低 JDK 12+

CMS 为什么被淘汰:三大致命问题——浮动垃圾(并发清理时新产生的垃圾)、Concurrent Mode Failure(并发失败退化为 Serial Old 全停顿)、内存碎片(标记清除不整理)。

ZGC 的核心创新:染色指针(Colored Pointers)。在 64 位指针里,高 4 位存储 GC 状态信息(Marked0/Marked1/Remapped/Finalizable),GC 阶段只需修改指针颜色,无需遍历对象。这就是它能把停顿压到 10ms 以内的原因。

源码层面看,ZGC 的指针布局:

// hotspot/src/hotspot/share/gc/z/zAddress.hpp
// 64位指针布局:
// [63-46] 预留 | [45-42] 元数据位 | [41-0] 对象地址
//
// 42位地址空间 = 4TB 堆
// 元数据位:Marked0, Marked1, Remapped, Finalizable

最佳实践与避坑指南

1. 参数配置黄金法则

# 生产环境推荐模板(G1, 8G 堆)
java -server \
     -Xms8g -Xmx8g \                    # 堆大小固定,避免动态调整引发 Full GC
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \         # 目标停顿时间
     -XX:InitiatingHeapOccupancyPercent=45 \  # 触发并发标记的阈值
     -XX:G1HeapRegionSize=8m \          # Region 大小,通常自动即可
     -XX:MetaspaceSize=256m \
     -XX:MaxMetaspaceSize=256m \        # 元空间也建议固定
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/data/dump/ \
     -Xloggc:/data/gc/gc-%t.log \
     -XX:+UseGCLogFileRotation \
     -XX:NumberOfGCLogFiles=10 \
     -XX:GCLogFileSize=100M \
     -XX:+PrintGCDetails \
     -XX:+PrintGCDateStamps \
     -XX:+PrintTenuringDistribution

2. 五个经典踩坑点

坑 1:-Xms 和 -Xmx 不相等

JVM 会在运行时动态扩缩堆,每次扩容都会触发 Full GC。生产环境必须固定。

坑 2:堆外内存未限制

Netty 的 DirectByteBuffer、Metaspace、JVM 自身开销都会占用堆外内存。容器环境下 JVM 感知不到 cgroup 限制(JDK 8u191 之前),导致容器被 OOM Killer 干掉。解决方案:-XX:+UseContainerSupport(JDK 10+ 默认开启)。

坑 3:System.gc() 被显式调用

代码中任何 System.gc() 或 Runtime.getRuntime().gc() 都会触发 Full GC。用 -XX:+DisableExplicitGC 屏蔽,但注意 NIO 的 DirectMemory 依赖 GC 回收,屏蔽后需要手动 Cleaner。

坑 4:finalize() 方法滥用

finalize() 会拖慢 GC 速度,且可能让对象"复活"。JDK 9 起标记为 deprecated,JDK 18 起彻底禁用。用 Cleaner 或 AutoCloseable 替代。

坑 5:日志框架的字符串拼接

// 错误:每次日志都会创建 StringBuilder + String
logger.debug("user " + userId + " order " + orderId);

// 正确:占位符延迟求值
logger.debug("user {} order {}", userId, orderId);

在高 QPS 服务里,这种字符串拼接能贡献 20% 以上的垃圾对象。

3. 排查工具链

场景 工具 命令示例
实时 GC 统计 jstat jstat -gcutil 1000
堆对象直方图 jmap + jhat jmap -histo:live
堆转储分析 MAT / JProfiler jmap -dump:live,format=b,file=heap.hprof
火焰图 async-profiler ./profiler.sh -d 30 -f out.html
GC 日志可视化 GCViewer / gceasy.io 上传 gc.log

4. 容器化环境的特殊处理

在 Kubernetes 里跑 Java 服务,务必:

# Pod 资源配置
resources:
  requests:
    memory: "4Gi"
  limits:
    memory: "4Gi"    # 与 requests 相同,避免被驱逐
# JVM 参数(JDK 11+)
-XX:MaxRAMPercentage=70.0 \   # 堆占容器内存 70%,剩下 30% 给堆外
-XX:InitialRAMPercentage=70.0 \
-XX:+UseContainerSupport

为什么是 70%:Metaspace + 线程栈 + DirectMemory + JVM 内部结构通常占 20-30%。设成 80% 以上容易触发容器 OOM Killer。


总结与延伸思考

回到文章开头那个事故——最终我们做了什么?

  1. 换 GC:从 CMS 切到 G1,MaxGCPauseMillis=150
  2. 调参数:-Xms/-Xmx 从 4G 调到 8G 并固定,InitiatingHeapOccupancyPercent 从 45 调到 35
  3. 改代码:找到一段缓存用户画像的 HashMap,没有淘汰策略,用 Caffeine 替换后老年代对象下降 60%
  4. 加监控:接入 Prometheus + Micrometer,对 GC 停顿、堆使用率做告警

P99 从 3.2s 降到 85ms,Full GC 从每天 1500 次降到 3 次。

核心要点回顾:

  • JVM 内存模型的分代设计基于弱分代假说,理解它才能理解 GC 行为
  • 对象晋升路径有 4 条,动态年龄判定是最容易踩的坑
  • TLAB 是分配性能的关键,高并发场景要关注
  • 选 GC 不是"越新越好",而是匹配业务特征:延迟敏感选 G1/ZGC,吞吐优先选 Parallel
  • 容器环境下必须用 UseContainerSupport + MaxRAMPercentage
  • 调优的终极手段是减少对象分配,而不是调参数

延伸思考方向:

  1. Project Lilliput:JDK 正在研究把对象头从 12 字节压缩到 4 字节,未来堆内存利用率能提升 20%+
  2. Project Valhalla:值类型(Value Types)落地后,List 将不再有对象头开销,GC 压力会大幅下降
  3. ZGC 的分代化:JDK 21 已引入分代 ZGC(-XX:+UseZGC -XX:+ZGenerational),吞吐量提升 30%+,延迟仍保持 <1ms
  4. GraalVM Native Image:AOT 编译后无需 JVM,启动时间从秒级降到毫秒级,但 GC 策略受限

JVM 是一个 30 年持续进化的工程奇迹。理解它,不只是为了面试,更是为了在线上出问题时,你能在 5 分钟内定位到根因,而不是盲目加机器。

愿你写的每一行代码,都不再被 GC 拖累。