Skip to content

JVM 初探:你的代码到底怎么跑的

难度:中级 | 预计时间:45 分钟 | 前置:多线程入门


📖 同一段代码,为什么有时快有时慢?

故事背景

你的服务上线后表现良好,但偶尔会出现几秒钟的"卡顿"——日志显示这段时间没有任何处理。过了几秒,一切又恢复正常。你排查了网络、数据库、线程池,都没问题。最后发现元凶是——JVM 在做垃圾回收(GC)。作为前端,你可能习惯了浏览器帮你管理内存(JS 引擎也做 GC)。但 Java 的 GC 更复杂、更可配置、也更需要你理解。这节课带你走进 JVM 内部——理解你的代码到底运行在什么样的"操作系统"之上。

💻 代码演示

观察 JVM 内存使用和垃圾回收的简单演示:

java
import java.util.ArrayList;

public class JVMDemo {
    public static void main(String[] args) {
        // 查看 JVM 内存信息
        Runtime runtime = Runtime.getRuntime();
        long mb = 1024 * 1024;
        System.out.println("最大堆内存: " + runtime.maxMemory() / mb + " MB");
        System.out.println("初始堆内存: " + runtime.totalMemory() / mb + " MB");

        // 不断创建对象,观察 GC
        var list = new ArrayList<byte[]>();
        for (int i = 0; i < 20; i++) {
            list.add(new byte[10 * mb]);  // 每次分配 10MB
            System.out.println("第" + (i + 1) + "次: 已用="
                + (runtime.totalMemory() - runtime.freeMemory()) / mb + "MB");
        }
    }
}

// 运行: java -Xmx256m JVMDemo
// -Xmx256m 限制最大堆为 256MB,让 GC 更早触发</byte[]></byte[]>

运行输出:

最大堆内存: 256 MB
初始堆内存: 16 MB
第1次: 已用=11MB
第2次: 已用=21MB
...
第10次: 已用=101MB
第11次: 已用=21MB   ← GC 发生了!内存被回收
第12次: 已用=31MB
...

🎯 三个核心问题

这是什么?

JVM 内存分几大区域:(Heap,存放对象实例,GC 主战场)、(Stack,每个线程独立,存放局部变量和方法调用帧)、方法区/元空间(Method Area/Metaspace,存放类定义、静态变量、常量池)。

GC 的核心思想:找出不再被引用的对象,释放它们占用的内存。现代 JVM 使用分代收集——年轻代(频繁 GC,快)和老年代(较少 GC,慢)。

为什么需要它?

为什么要理解 JVM 内存?①排查 OOM(内存溢出)——知道是堆满了还是元空间满了,解法完全不同 ②理解 GC 停顿——选择适合场景的 GC 策略(低延迟 vs 高吞吐)③写出内存友好的代码——避免无意中创建大量临时对象。面试中 JVM 是高频考点。

如果没有它会怎样?

如果没有 GC(像 C/C++ 手动 malloc/free),开发者需要自己管理每一块内存——忘记 free 导致内存泄漏,提前 free 导致悬挂指针(use-after-free)。这是 C/C++ 程序 bug 的最大来源。GC 解放了开发者的大脑,但代价是有时会出现不可预测的停顿(Stop-The-World)。

📝 原理讲解

JVM 内存结构速览:

┌──────────────────────────────────────┐
│              堆 (Heap)               │
│  ┌───────────┐  ┌──────────────────┐ │
│  │  年轻代    │  │     老年代       │ │
│  │ Eden+S0+S1│→│  (长期存活对象)    │ │
│  │  频繁GC   │  │     较少GC       │ │
│  └───────────┘  └──────────────────┘ │
├──────────────────────────────────────┤
│  方法区/元空间 (存放类定义、常量池)      │
├──────────────────────────────────────┤
│  栈 (每个线程独立)  │ 程序计数器 │ 本地方法栈│
└──────────────────────────────────────┘
  • :所有 new 出来的对象都在这里。通过 -Xmx 设置上限。

  • :方法调用的局部变量(基本类型、对象引用)在这里。方法结束自动弹出,无需 GC。

  • 年轻代 GC(Minor GC):频繁但快——大部分对象朝生夕死("朝生夕灭假说")。

  • 老年代 GC(Major/Full GC):慢但少——对象在年轻代熬过多次 GC 后晋升到老年代。

  • 前端类比:V8 引擎也有类似的分代 GC——新生代(Scavenge)和老生代(Mark-Sweep & Mark-Compact)。

🎨 生活类比

类比理解

堆(Heap)像一个大型自动化仓库——你申请货物(new 对象),仓库自动找空位放。不用的货(不可达对象),清洁工(GC)定时来回收空位。 栈(Stack)像你的工作台——每次接新任务(方法调用),在台面上铺开需要的工具和材料(局部变量)。任务完成后台面清空(栈帧弹出),不留下任何东西。 年轻代 GC 像每天扫地——只扫办公区(年轻代),快得很。老年代 GC 像年终大扫除——全仓库翻一遍,费时但彻底。 Stop-The-World 像消防演习——所有人停下工作,等 GC 做完再继续。你希望演习越短越好。

✏️ 动手练习

练习 1

运行上面的 JVMDemo,用不同 -Xmx 值(64m, 128m, 256m)观察 GC 触发时机。加上 -XX:+PrintGC 参数查看 GC 日志。

<details> <summary>💡 查看提示</summary>

完整命令:java -Xmx128m -XX:+PrintGC JVMDemo。输出中 [GC ...] 表示 Minor GC,[Full GC ...] 表示 Full GC。

</details>

练习 2

写一个程序故意制造内存溢出:无限向 List 中添加字符串,直到抛 OutOfMemoryError。用 try-catch 捕获并打印此时堆的状态。

<details> <summary>💡 查看提示</summary>

List<String> list = new ArrayList<>(); while(true) { list.add(new String(new char[1000000])); }。OOM 是 Error 不是 Exception。

</details>

练习 3

jps 找到 Java 进程 PID,再用 jmap -heap PID 查看堆内存详情。

<details> <summary>💡 查看提示</summary>

终端里 jps -l 列出 Java 进程,找到你的程序 PID,然后 jmap -heap PID。注意:需要 JDK 自带工具,JRE 可能没有 jmap。

</details>

✅ 自检站

<details> <summary><strong>JVM 内存中,堆和栈分别存什么?为什么栈不需要 GC?</strong></summary>

堆存所有对象实例(new 出来的东西),GC 的主战场。栈存局部变量(基本类型值 + 对象引用)和方法调用信息。栈不需要 GC 因为它的内存管理是确定性的——方法调用时压入栈帧,方法返回时弹出,内存自动释放,不需要"找垃圾"。

</details>

<details> <summary><strong>Minor GC 和 Full GC 有什么区别?为什么 Minor GC 快得多?</strong></summary>

Minor GC 只回收年轻代(Eden + Survivor),范围小。年轻代的对象大部分已经死亡("朝生夕灭"),存活对象直接复制到 Survivor 区或晋升到老年代——复制算法效率高。Full GC 回收整个堆(包括老年代),通常用标记-清除或标记-整理算法,需要扫描所有存活对象,且会引发 Stop-The-World 暂停。

</details>

<details> <summary><strong>类加载机制大致是怎样的?</strong></summary>

类加载分三步:①加载(Loading)——找到 .class 文件读入内存 ②链接(Linking)——验证、准备(分配静态变量内存并赋默认值)、解析(符号引用转直接引用)③初始化(Initialization)——执行静态初始化块和静态变量赋值。类加载器采用双亲委派模型——先问父加载器能不能加载,避免重复加载核心类。

</details>