JVM 初探:你的代码到底怎么跑的
难度:中级 | 预计时间:45 分钟 | 前置:多线程入门
📖 同一段代码,为什么有时快有时慢?
故事背景
你的服务上线后表现良好,但偶尔会出现几秒钟的"卡顿"——日志显示这段时间没有任何处理。过了几秒,一切又恢复正常。你排查了网络、数据库、线程池,都没问题。最后发现元凶是——JVM 在做垃圾回收(GC)。作为前端,你可能习惯了浏览器帮你管理内存(JS 引擎也做 GC)。但 Java 的 GC 更复杂、更可配置、也更需要你理解。这节课带你走进 JVM 内部——理解你的代码到底运行在什么样的"操作系统"之上。
💻 代码演示
观察 JVM 内存使用和垃圾回收的简单演示:
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>