面试被问"堆和栈有什么区别",我按网上八股背了一遍:栈存局部变量,堆存对象。面试官追问:"一个方法调用下去,栈里到底发生了什么?局部变量
a和它指向的对象,各在哪儿?"——卡住了。八股只告诉我"东西在哪儿",没告诉我"内存是怎么工作的"。这篇把运行时数据区、堆栈原理、对象的一生和 GC 从头捋一遍,图表为主,关键结论都实测过。
一、解决什么问题:自动内存管理 ≠ 不用懂
Java 的招牌是自动内存管理——不用像 C/C++ 那样手动 malloc/free,垃圾靠 GC 自动回收。但"自动"不等于"不用懂":内存溢出(OOM)怎么排查、GC 为什么卡顿、堆怎么调,都得先搞清楚内存怎么分、对象怎么存、垃圾怎么收。这篇讲清楚这三件事,OOM 的具体类型和排查见《内存溢出(OOM):类型、定位与实战排查》。想看 JVM 整体架构(类加载/执行引擎/跨平台)先看《JVM 是什么:跨平台原理与整体架构》。
二、运行时数据区:内存分几块
JVM 启动后按规范把内存划成几个区域,线程私有的各用各的,线程共享的大家一起用:
| 区域 | 存什么 | 谁持有 | GC 管吗 | 满了抛什么 |
|---|---|---|---|---|
| 程序计数器(PC) | 当前执行到哪一行字节码 | 线程私有 | 不管 | 唯一不 OOM 的区域 |
| 虚拟机栈 | 方法调用的栈帧(局部变量表等) | 线程私有 | 不管(方法结束自动弹栈) | StackOverflowError / OOM |
| 本地方法栈 | native 方法(JNI)调用 | 线程私有 | 不管 | StackOverflowError |
| 堆(Heap) | 所有 new 的对象实例 | 线程共享 | 管(GC 主战场) | Java heap space |
| 方法区(JDK8 起 = 元空间) | 类信息、常量、静态变量 | 线程共享 | 管(类卸载) | Metaspace |
| 直接内存(堆外) | NIO 直接缓冲区 | 线程共享 | 不管(靠 Cleaner 释放) | Direct buffer memory |
两个最容易记混的点:
- 方法区在 JDK 7 叫永久代(PermGen),JDK 8 换成元空间(Metaspace)——元空间用本地内存,默认不设上限,类太多才爆(动态代理、热部署)。
- 直接内存不是 JVM 规范里的区域,是 NIO 在堆外开的,
-Xmx管不到它,得单独配-XX:MaxDirectMemorySize。
三、堆 vs 栈:最容易被问倒的一组对比
面试最爱问,也是排查 OOM 时最容易混淆的——先把这两个分清楚:
栈(Stack):管方法调用
每个线程一条栈,方法调用一次,入栈一个栈帧;方法返回,栈帧弹出。栈帧里装着这个方法的"现场":
- 局部变量表:局部变量(基本类型的值、引用类型的地——存的是引用,不是对象)
- 操作数栈、动态链接、方法返回地址(不用背全,知道"一帧一方法"就够)
栈的特点:LIFO(后进先出)、方法结束自动弹出、不用 GC、容量由 -Xss 控制。
实测 -Xss 的作用(同一个无限递归程序,只改栈大小):
默认栈(1MB):栈溢出,递归深度 23660
-Xss256k :栈溢出,递归深度 2382 ← 栈小了 4 倍,深度正好也约 1/4堆(Heap):存对象
所有 new 出来的对象都在堆上,线程共享,靠 GC 回收,容量由 -Xmx 控制。
核心:引用在栈,对象在堆
public void method() {
Dog a = new Dog(); // a 在栈(局部变量表),Dog 对象在堆
}a这个变量在栈帧的局部变量表里,它存的是一个地址(引用)new Dog()创建的对象实体在堆上a指向堆里的对象;方法结束,a弹出栈,但对象还留在堆——等 GC 回收
这就是"局部变量在栈、对象在堆"的准确含义:栈上的是引用,堆上的是实体。之前被问倒的就是这一步。
| 维度 | 栈 Stack | 堆 Heap |
|---|---|---|
| 线程 | 私有(每线程一条) | 共享 |
| 管什么 | 方法调用、局部变量 | 对象实例 |
| 生命周期 | 方法结束自动弹出 | 靠 GC 回收 |
| 大小控制 | -Xss | -Xms / -Xmx |
| 满了 | StackOverflowError | Java heap space |
四、对象的一生:创建 → 分配 → GC 回收
创建:new 之后发生了什么
new Dog()
→ ① 类加载检查(Dog 类已加载?没加载先加载)
→ ② 堆上分配内存(指针碰撞 / 空闲列表)
→ ③ 内存置零(字段默认值:0 / null / false)
→ ④ 设置对象头(Mark Word、类型指针)
→ ⑤ 执行构造方法 → 字段真正赋值对象在堆上的内存布局分三块:对象头(Mark Word 存锁/GC 标记、类型指针指向类)、实例数据(字段值)、对齐填充(凑 8 字节倍数)。
分代:大部分对象"朝生夕死"
堆分成新生代 + 老年代,基于一个经验——大部分对象创建后很快就没用了:
- 新对象 → Eden
- Eden 满 → Minor GC → 活下来的进 Survivor(S0/S1 来回复制)
- 来回几次还活着 → 晋升老年代
- 老年代满 → Full GC(慢,要尽量避免频繁)
实测分代真的在跑(一个小程序:一半对象留着、一半丢弃,jstat -gcutil 盯 5 秒):
S1 E O YGC
第 1 次: 100.00 0.00 79.33 13
第 2 次: 100.00 53.85 90.38 17三行数字把分代机制全暴露了:
- YGC 13 → 17:5 秒内 Minor GC 干了 4 次(新生代在频繁清扫)
- E 0 → 53.85%:Eden 在填充新对象
- O 79 → 90.38%:存活对象在晋升老年代——"朝生夕死"的对象被回收,活下来的往老年代挪
怎么判断"谁还活着":可达性分析 + GC Roots
GC 不是数引用计数,而是从一组根(GC Roots)出发做可达性分析——从根能走到(引用链连上)的对象活着,走不到的就是垃圾:
| 对象 | 是否有引用链 | 结果 |
|---|---|---|
| 对象 A | 被栈上局部变量直接引用 | 存活 |
| 对象 C | 被 A 引用(顺着链能走到) | 存活 |
| 对象 B | 没有任何引用链 | 垃圾,回收 |
四种引用:强弱软虚
| 引用 | GC 态度 | 典型用途 |
|---|---|---|
| 强引用 | 只要还被引用,绝不回收 | new 出来的常规对象 |
| 软引用 SoftReference | 内存不足时才回收 | 缓存(如静态缓存防泄漏) |
| 弱引用 WeakReference | 下次 GC 就回收 | ThreadLocal、缓存辅助 |
| 虚引用 PhantomReference | 回收时通知(几乎不用) | 直接内存的 Cleaner |
五、垃圾回收算法与收集器
三种基础算法
| 算法 | 做法 | 缺点 | 用在哪 |
|---|---|---|---|
| 标记-清除 | 标记垃圾 → 清除 | 产生碎片 | 老年代基础 |
| 复制 | 活对象复制到另一块,清空原块 | 浪费空间 | 新生代(死的多活的少,复制便宜) |
| 标记-整理 | 标记垃圾 → 活对象挪到一边 | 慢 | 老年代(解决碎片) |
为什么新生代用复制、老年代用整理:新生代对象大多"朝生夕死",活对象少,复制成本低;老年代存活率高,复制浪费,改整理。
收集器演进:从串行到 ZGC
| 收集器 | 特点 | 场景 |
|---|---|---|
| Serial | 单线程、STW 长 | 客户端、小堆 |
| Parallel | 多线程吞吐优先 | 服务端默认之一(JDK8 默认) |
| CMS | 并发标记清除、低停顿 | JDK9 废弃,被 G1 取代 |
| G1 | 分区、可预测停顿 | JDK9+ 默认 |
| ZGC / Shenandoah | 停顿 <10ms | 超大堆(上百 GB)低延迟 |
JDK 版本注意:JDK8 默认 Parallel;JDK9+ 默认 G1——网上很多旧文章还按 JDK8 讲,看代码时先确认版本(JDK8 的 -XX:+UseG1GC 到 9 之后是默认,不用加)。
六、内存参数速查 + OOM 对应
| 参数 | 管什么 | 满了的报错 |
|---|---|---|
-Xms / -Xmx | 堆初始 / 最大 | Java heap space |
-Xmn | 新生代大小 | — |
-Xss | 每个线程栈大小 | StackOverflowError |
-XX:MaxMetaspaceSize | 元空间上限(默认无上限) | Metaspace |
-XX:MaxDirectMemorySize | 直接内存上限 | Direct buffer memory |
-XX:+HeapDumpOnOutOfMemoryError | OOM 时自动导出堆快照 | —(排查救命) |
每种 OOM 的触发代码、实跑输出和排查流程,见《内存溢出(OOM):类型、定位与实战排查》。
小结
- 内存分区:线程私有(PC/栈/本地方法栈)+ 共享(堆/方法区)+ 堆外(直接内存)
- 堆 vs 栈:栈管方法调用(引用在栈),堆存对象实体(对象在堆);栈自动弹、堆靠 GC
- 对象一生:Eden → Survivor → 老年代,GC Roots 可达性分析判生死
- GC 算法:复制(新生代)、标记-清除/整理(老年代);JDK9+ 默认 G1
- 参数:-Xmx/-Xss/Metaspace/DirectMemory,配好 + 开 HeapDump
想了解内存之上的并发保证,看《Java 并发编程》;对象在堆里的组织方式(HashMap 为什么 O(1))看《Java 集合框架详解》;volatile 为什么能保证可见性(和内存模型的关系)看《volatile 关键字详解》。
验证说明:
-Xss影响栈深度(默认 23660 vs 256k 时 2382)、jstat -gcutil观测分代 GC(YGC 13→17、Eden 0→53.85%、老年代 79→90.38%)均 Linux 容器实测;各区域 OOM 报错均为《OOM》文章中的实跑输出(堆 31MB 爆、Metaspace、Direct buffer、栈溢出等)。
