Skip to content

面试被问"堆和栈有什么区别",我按网上八股背了一遍:栈存局部变量,堆存对象。面试官追问:"一个方法调用下去,栈里到底发生了什么?局部变量 a 和它指向的对象,各在哪儿?"——卡住了。八股只告诉我"东西在哪儿",没告诉我"内存是怎么工作的"。这篇把运行时数据区、堆栈原理、对象的一生和 GC 从头捋一遍,图表为主,关键结论都实测过。

一、解决什么问题:自动内存管理 ≠ 不用懂

Java 的招牌是自动内存管理——不用像 C/C++ 那样手动 malloc/free,垃圾靠 GC 自动回收。但"自动"不等于"不用懂":内存溢出(OOM)怎么排查、GC 为什么卡顿、堆怎么调,都得先搞清楚内存怎么分、对象怎么存、垃圾怎么收。这篇讲清楚这三件事,OOM 的具体类型和排查见《内存溢出(OOM):类型、定位与实战排查》。想看 JVM 整体架构(类加载/执行引擎/跨平台)先看《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 时最容易混淆的——先把这两个分清楚:

堆 vs 栈:引用在栈,对象在堆

栈(Stack):管方法调用

每个线程一条栈,方法调用一次,入栈一个栈帧;方法返回,栈帧弹出。栈帧里装着这个方法的"现场":

  • 局部变量表:局部变量(基本类型的值、引用类型的地——存的是引用,不是对象
  • 操作数栈、动态链接、方法返回地址(不用背全,知道"一帧一方法"就够)

栈的特点:LIFO(后进先出)、方法结束自动弹出、不用 GC、容量由 -Xss 控制。

实测 -Xss 的作用(同一个无限递归程序,只改栈大小):

默认栈(1MB):栈溢出,递归深度 23660
-Xss256k    :栈溢出,递归深度 2382   ← 栈小了 4 倍,深度正好也约 1/4

堆(Heap):存对象

所有 new 出来的对象都在堆上,线程共享,靠 GC 回收,容量由 -Xmx 控制。

核心:引用在栈,对象在堆

java
public void method() {
    Dog a = new Dog();   // a 在栈(局部变量表),Dog 对象在堆
}
  • a 这个变量在栈帧的局部变量表里,它存的是一个地址(引用)
  • new Dog() 创建的对象实体在堆上
  • a 指向堆里的对象;方法结束,a 弹出栈,但对象还留在堆——等 GC 回收

这就是"局部变量在栈、对象在堆"的准确含义:栈上的是引用,堆上的是实体。之前被问倒的就是这一步。

维度栈 Stack堆 Heap
线程私有(每线程一条)共享
管什么方法调用、局部变量对象实例
生命周期方法结束自动弹出靠 GC 回收
大小控制-Xss-Xms / -Xmx
满了StackOverflowErrorJava heap space

四、对象的一生:创建 → 分配 → GC 回收

创建:new 之后发生了什么

new Dog()
  → ① 类加载检查(Dog 类已加载?没加载先加载)
  → ② 堆上分配内存(指针碰撞 / 空闲列表)
  → ③ 内存置零(字段默认值:0 / null / false)
  → ④ 设置对象头(Mark Word、类型指针)
  → ⑤ 执行构造方法 → 字段真正赋值

对象在堆上的内存布局分三块:对象头(Mark Word 存锁/GC 标记、类型指针指向类)、实例数据(字段值)、对齐填充(凑 8 字节倍数)。

分代:大部分对象"朝生夕死"

堆分成新生代 + 老年代,基于一个经验——大部分对象创建后很快就没用了

堆的分代:Eden → Survivor → 老年代,对象随 GC 逐步晋升

  • 新对象 → 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)出发做可达性分析——从根能走到(引用链连上)的对象活着,走不到的就是垃圾:

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:+HeapDumpOnOutOfMemoryErrorOOM 时自动导出堆快照—(排查救命)

每种 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、栈溢出等)。