欢迎光临
我们一直在努力

JVM 内存结构全链路:堆、栈、方法区、元空间

JVM 内存结构全链路:堆、栈、方法区、元空间

面试必问的 JVM 内存模型,从堆栈到元空间,每个区域的作用、OOM 类型和调优方向一次性讲清楚。

一、JVM 内存结构全景

┌─────────────────────────────────────────────────────────────┐
│ JVM 内存结构全景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程共享区域 │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 堆 (Heap) │ │ │
│ │ │ ┌──────────────┬──────────────────────────┐ │ │ │
│ │ │ │ 新生代 │ 老年代 │ │ │ │
│ │ │ │ (Young Gen) │ (Old Gen) │ │ │ │
│ │ │ │ ┌───┬──┬───┐│ │ │ │ │
│ │ │ │ │Eden│S0│S1││ │ │ │ │
│ │ │ │ └───┴──┴───┘│ │ │ │ │
│ │ │ └──────────────┴──────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ 方法区 / 元空间 (Metaspace) │ │ │
│ │ │ 类元数据、常量池、静态变量、方法字节码 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ 线程私有区域 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 虚拟机栈 │ │ 本地方法栈 │ │ 程序计数器(PC) │ │ │
│ │ │ (VM Stack)│ │(Native │ │ (Program Counter │ │ │
│ │ │ │ │ Stack) │ │ Register) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘

二、各区域详解

1. 程序计数器(PC Register)

// 每个线程独立拥有,记录当前执行的字节码行号
// 不会 OOM,是唯一不会 OOM 的区域

特性说明
线程私有 每个线程独立
作用 记录当前线程执行到的字节码行号
OOM 不可能发生
大小 很小(一个 word)

2. 虚拟机栈(VM Stack)

public class StackDemo {
public void methodA() {
int a = 1; // 局部变量
Object obj = new Object(); // 引用
methodB(); // 方法调用 → 压入新栈帧
}

public void methodB() {
int b = 2;
// 栈帧结构:
// ┌─────────────────────┐
// │ 局部变量表 │ a, b, obj, this…
// ├─────────────────────┤
// │ 操作数栈 │ 计算中间结果
// ├─────────────────────┤
// │ 动态链接 │ 指向运行时常量池
// ├─────────────────────┤
// │ 方法返回地址 │ 调用者PC
// └─────────────────────┘
}
}

虚拟机栈结构:

线程1的虚拟机栈
┌─────────────────┐
│ methodA 栈帧 │ ← 栈顶(当前执行)
│ ┌─────────────┐│
│ │局部变量表 ││ this, a=1, obj
│ │操作数栈 ││
│ │动态链接 ││
│ │返回地址 ││
│ └─────────────┘│
├─────────────────┤
│ main 栈帧 │
│ ┌─────────────┐│
│ │局部变量表 ││ args
│ │操作数栈 ││
│ │动态链接 ││
│ │返回地址 ││
│ └─────────────┘│
└─────────────────┘

异常类型触发条件
StackOverflowError 栈深度超过限制(递归过深)
OutOfMemoryError 栈扩展时无法申请到足够内存

3. 本地方法栈

与虚拟机栈类似,区别在于服务对象是 Native 方法。HotSpot 中与虚拟机栈合二为一。

4. 堆(Heap)

堆是 JVM 管理的最大内存区域,存放对象实例和数组:

// 所有 new 出来的对象都在堆中
Object obj = new Object(); // obj 引用在栈中,Object 对象在堆中

int[] arr = new int[100]; // 数组也在堆中

堆内存分代结构:

┌──────────────────────────────────────────────────────┐
│ 堆 (Heap) │
├───────────────────────┬──────────────────────────────┤
│ 新生代 (1/3) │ 老年代 (2/3) │
│ ┌─────┬────┬────┐ │ │
│ │Eden │ S0 │ S1 │ │ 长期存活的对象 │
│ │ 8 │ 1 │ 1 │ │ 大对象直接进入 │
│ └─────┴────┴────┘ │ Major GC / Full GC │
│ Minor GC │ │
│ 复制算法 │ 标记-清除/标记-整理 │
└───────────────────────┴──────────────────────────────┘

对象分配流程:
new 对象 → Eden 区分配
Eden 满 → Minor GC
存活 → 复制到 S0/S1,年龄+1
年龄 ≥ 15 → 晋升老年代
大对象 → 直接进老年代

异常触发条件
OutOfMemoryError: Java heap space 堆内存不足,无法分配新对象
OutOfMemoryError: GC overhead limit exceeded GC 花费超过 98% 时间但回收不到 2% 内存

5. 方法区 / 元空间

方法区的演进:
JDK 7 之前: 永久代(Permanent Generation) — 堆的一部分,JVM 管理
JDK 7: 符号引用移到 native memory, 静态变量移到堆
JDK 8+: 元空间(Metaspace) — 使用本地内存(Native Memory)

// 方法区存储的内容
public class MethodAreaDemo {
// 静态变量 → JDK 7+ 移到堆
private static final int MAX = 100;

// 类信息、方法字节码 → 元空间
public void process() {
// 方法字节码存储在元空间
}

// 常量池中的字符串
String s1 = "abc"; // 常量池
String s2 = new String("abc"); // 堆中对象
}

对比项永久代(JDK 7-)元空间(JDK 8+)
位置 JVM 堆中 本地内存
大小限制 -XX:PermSize / -XX:MaxPermSize -XX:MetaspaceSize / -XX:MaxMetaspaceSize
GC Full GC 时回收 达到阈值触发 Full GC
OOM java.lang.OutOfMemoryError: PermGen space java.lang.OutOfMemoryError: Metaspace
动态性 容易 OOM(固定大小) 可用本地内存,不易 OOM

为什么要用元空间替代永久代?

  • 永久代大小固定,容易在大量动态生成类的场景下 OOM
  • 元空间使用本地内存,大小受限于物理内存,更灵活
  • 方便与 JRockit 合并(JRockit 没有永久代)
  • 三、对象在堆中的分配策略

    public class AllocationDemo {
    public static void main(String[] args) {
    // 小对象 → Eden 区
    Object small = new Object();

    // 大对象 → 老年代(避免 Eden→Survivor 复制开销)
    // -XX:PretenureSizeThreshold=1M
    byte[] large = new byte[2 * 1024 * 1024]; // 2MB → 直接进老年代

    // 长期存活对象 → 老年代
    // 对象头有 GC 年龄,每次 Minor GC 存活则 +1
    // -XX:MaxTenuringThreshold=15 (默认)
    Object longLived = new Object();
    // 经过 15 次 Minor GC 仍存活 → 晋升老年代
    }
    }

    对象分配决策树:

    new 对象


    是否大对象? ──── 是 ───▶ 直接进老年代
    │ 否

    尝试在 TLAB 分配 ──── 成功 ───▶ 分配完成
    │ 失败

    尝试在 Eden 区 CAS 分配 ──── 成功 ───▶ 分配完成
    │ 失败

    Minor GC


    存活对象年龄+1

    ├─ 年龄 ≥ 阈值 ─────────▶ 晋升老年代
    ├─ Survivor 区放不下 ────▶ 晋升老年代
    └─ 否 ──────────────────▶ 复制到另一 Survivor

    TLAB(Thread Local Allocation Buffer)

    // 每个线程在 Eden 区有一小块私有区域
    // 避免多线程分配时的 CAS 竞争
    // -XX:+UseTLAB (默认开启)

    // 分配流程:
    // 1. 先在 TLAB 中分配(无锁)
    // 2. TLAB 用完 → CAS 在 Eden 区分配
    // 3. Eden 满 → Minor GC

    四、对象访问定位

    Object obj = new Object();
    // 栈中的 reference → 堆中的 Object 对象

    访问方式: 句柄 vs 直接指针

    句柄方式 (JDK 早期):
    栈 (reference) ──▶ 堆 (句柄池)

    ├─▶ 对象实例数据 (堆)
    └─▶ 对象类型数据 (元空间)

    直接指针方式 (HotSpot):
    栈 (reference) ──▶ 堆 (对象头 + 实例数据)

    └─▶ 对象类型数据 (元空间)

    对比项句柄直接指针
    优点 对象移动时只改句柄指针 访问少一次间接跳转,速度快
    缺点 多一次访问 对象移动时需修改所有引用
    使用 HotSpot 使用

    五、各区域 OOM 类型总结

    // 1. 堆 OOM
    List<byte[]> list = new ArrayList<>();
    while (true) {
    list.add(new byte[1024 * 1024]); // 不断创建 1MB 对象
    }
    // Exception: java.lang.OutOfMemoryError: Java heap space

    // 2. 栈溢出
    public void recursive() {
    recursive(); // 无限递归
    }
    // Exception: java.lang.StackOverflowError

    // 3. 元空间 OOM
    // 大量动态生成类(如 CGLIB 代理类)
    // Exception: java.lang.OutOfMemoryError: Metaspace

    // 4. 直接内存 OOM
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
    // Exception: java.lang.OutOfMemoryError: Direct buffer memory

    OOM 类型原因解决方向
    Java heap space 堆内存不足 增大 -Xmx,检查内存泄漏
    GC overhead limit GC 回收效率过低 增大堆或排查泄漏
    StackOverflowError 栈深度超限 检查递归,增大 -Xss
    Metaspace 元空间不足 增大 -XX:MaxMetaspaceSize
    Direct buffer memory 直接内存不足 检查 NIO Buffer 使用

    六、常用 JVM 参数

    参数作用示例
    -Xms 初始堆大小 -Xms256m
    -Xmx 最大堆大小 -Xmx1024m
    -Xmn 新生代大小 -Xmn256m
    -Xss 栈大小 -Xss512k
    -XX:MetaspaceSize 元空间初始大小 -XX:MetaspaceSize=128m
    -XX:MaxMetaspaceSize 元空间最大大小 -XX:MaxMetaspaceSize=256m
    -XX:SurvivorRatio Eden:Survivor 比例 -XX:SurvivorRatio=8
    -XX:MaxTenuringThreshold 晋升老年代年龄 -XX:MaxTenuringThreshold=15
    -XX:PretenureSizeThreshold 大对象阈值 -XX:PretenureSizeThreshold=1M
    -XX:+UseTLAB 启用 TLAB 默认开启

    七、实战:内存泄漏排查

    // 常见内存泄漏场景
    public class MemoryLeakDemo {
    // 1. 静态集合持有对象引用
    private static final Map<String, Object> cache = new HashMap<>();
    public void addToCache(String key, Object value) {
    cache.put(key, value); // 永远不清理 → 泄漏
    }

    // 2. 未关闭的资源
    public void readFile() throws IOException {
    InputStream is = new FileInputStream("file.txt");
    // 忘记 is.close() → 泄漏
    }

    // 3. 监听器未移除
    public void addListener() {
    button.addActionListener(e -> {
    // 匿名类隐式持有外部类引用
    // 如果 button 生命周期长 → 外部类无法回收
    });
    }

    // 4. ThreadLocal 未 remove
    private ThreadLocal<Object> threadLocal = new ThreadLocal<>();
    public void process() {
    threadLocal.set(new Object());
    // 忘记 threadLocal.remove() → 线程池场景泄漏
    }
    }

    排查步骤:
    1. jps → 找到进程 PID
    2. jstat -gcutil <pid> 1000 → 观察 GC 情况
    3. jmap -heap <pid> → 查看堆内存分布
    4. jmap -dump:format=b,file=heap.hprof <pid> → 导出堆转储
    5. MAT / VisualVM 分析 hprof 文件
    6. 找到 GC Root 引用链,定位泄漏对象

    八、面试高频追问

    Q1:为什么要把永久代替换为元空间?

    永久代大小固定容易 OOM,尤其是动态代理、动态语言等场景大量生成类。元空间使用本地内存,大小灵活,不易 OOM。

    Q2:对象一定分配在堆上吗?

    不一定。JIT 的逃逸分析可以证明对象不逃逸时,通过标量替换在栈上分配,避免堆分配和 GC 压力。

    Q3:为什么 Eden:S0:S1 = 8:1:1?

    新生代中大部分对象朝生夕死,98% 的对象在 Minor GC 时被回收。复制算法只需要 10% 的空间(S0 或 S1)作为备用,Eden 区有 80% 空间可用,整体利用率 90%,远高于 50%(两个 Survivor 各占一半的情况)。

    Q4:栈上分配和 TLAB 是一回事吗?

    不是。栈上分配是 JIT 逃逸分析后的优化,对象分配在栈帧中,随方法退出自动销毁。TLAB 是堆中 Eden 区的线程私有区域,减少多线程分配时的 CAS 竞争,对象仍在堆中。

    九、总结

    JVM 内存结构:
    线程共享: 堆(对象/数组) + 元空间(类元数据/常量池)
    线程私有: 虚拟机栈(栈帧) + 本地方法栈 + 程序计数器

    堆: 新生代(Eden:S0:S1=8:1:1) + 老年代
    Minor GC(复制) + Full GC(标记-整理)
    栈: StackOverflowError(递归过深) / OOM(无法扩展)
    元空间: 本地内存, -XX:MaxMetaspaceSize 限制

    对象分配: TLAB → Eden → Survivor → 老年代
    大对象: 直接进老年代
    GC Roots: 栈引用、静态变量、常量、JNI 引用

    理解 JVM 内存结构是性能调优和排查 OOM 问题的基础,面试中每个区域的作用、OOM 类型和调优参数都是高频考点。

    赞(0)
    未经允许不得转载:171主机测评 » JVM 内存结构全链路:堆、栈、方法区、元空间
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址