Java 面试笔记III · 11 / 14

Lesson 34 · JVM 原理与调优

JVM 运行时数据区全景:堆、栈、方法区、程序计数器

中级·⭐ 必问·#JVM·#内存·#核心

第 1 站

面试官:JVM 运行时数据区有哪些部分?

几乎所有 JVM 相关的面试题,都会从这个问题开始。它看似简单,却能考察候选人对 Java 内存模型的全局理解深度——堆和栈的区别、线程共享与隔离、方法区的演进历史、溢出场景的差异,全都藏在这一个问题里。

如果你只能回答"堆、栈、方法区"三个,说明你对 JVM 的理解还停留在入门阶段。完整的运行时数据区包含六大区域,外加一块常被忽略的直接内存。能不能清晰地说出每个区域的职责、线程共享性、溢出表现,是区分初级和中高级候选人的关键分水岭。

JVM 在执行 Java 程序时,会将内存划分成若干个不同的数据区域,每个区域有各自的用途和生命周期。根据 JVM 规范,运行时数据区包括:

区域线程共享核心职责溢出异常
程序计数器 (PC Register)私有记录当前线程执行的字节码行号唯一不会 OOM 的区域
虚拟机栈 (VM Stack)私有方法调用:局部变量、操作数、返回地址StackOverflowError / OOM
本地方法栈 (Native Method Stack)私有Native 方法调用StackOverflowError / OOM
堆 (Heap)共享对象实例、数组,GC 的主战场OutOfMemoryError
方法区 (Method Area)共享类元信息、常量池、静态变量OutOfMemoryError
直接内存 (Direct Memory)共享NIO 堆外内存,零拷贝 IOOutOfMemoryError

接下来,我们将逐一深入每个区域,揭开它们的真面目。

第 2 站

全景图:JVM 运行时数据区一览

在深入细节之前,先看一张完整的全景图。JVM 运行时数据区按线程共享性分为两大阵营:线程私有区域(每个线程独立拥有)和线程共享区域(所有线程共同访问)。

JVM 运行时数据区全景 线程私有区域(Thread-Private) 线程 A 程序计数器 PC Register 字节码行号指示器 虚拟机栈 VM Stack 栈帧: methodC() 局部变量 | 操作数栈 | 返回地址 栈帧: methodB() 局部变量 | 操作数栈 | 返回地址 栈帧: methodA() 局部变量 | 操作数栈 | 返回地址 ⋮ 更多栈帧 本地方法栈 Native Method Stack C/C++ 本地方法调用 ⚠ StackOverflowError 线程 B 程序计数器 PC Register 虚拟机栈 VM Stack 栈帧: run() 栈帧: main() ⋮ 更多栈帧 本地方法栈 Native Method Stack 每个线程拥有独立的 PC + VM Stack + Native Stack 线程共享区域(Thread-Shared) 堆 (Heap) 对象实例 · 数组 · GC 的主战场 新生代 (Young Gen) Eden S0 S1 Minor GC 频繁回收 老年代 (Old Gen) 长存活对象 Full GC 回收 ⚠ java.lang.OutOfMemoryError: Java heap space -Xms / -Xmx 控制堆大小 方法区 (Method Area) 类元信息 · 常量池 · 静态变量 · JIT 编译代码 永久代 PermGen JDK 5 / 6 / 7 -XX:MaxPermSize 演进 元空间 Metaspace JDK 8+ 使用本地内存 (Native Memory) ⚠ OutOfMemoryError: Metaspace -XX:MaxMetaspaceSize 控制上限 直接内存 (Direct Memory) NIO DirectByteBuffer · 堆外分配 · 零拷贝 IO -XX:MaxDirectMemorySize 控制上限 ⚠ OutOfMemoryError: Direct buffer memory 线程私有 线程共享 非 JVM 规范区域 溢出风险
图 1 JVM 运行时数据区全景:线程私有区域(PC、虚拟机栈、本地方法栈)+ 线程共享区域(堆、方法区)+ 直接内存
要点 JVM 规范定义的运行时数据区有 5 块:PC Register、VM Stack、Native Method Stack、Heap、Method Area。直接内存不在 JVM 规范中,但在实际开发中被广泛使用(NIO、Netty),且同样会导致 OOM。
第 3 站

线程私有区域:PC、虚拟机栈、本地方法栈

3.1 程序计数器 (PC Register)

程序计数器是一块极小的内存空间,它是线程私有的。它的作用只有一个:记录当前线程正在执行的字节码指令的地址(行号)。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

为什么程序计数器是线程私有的?

因为 CPU 会在多个线程之间快速切换,每次切换回来都需要恢复到上次的执行位置。如果 PC 是共享的,线程 A 刚执行到第 42 行就被切走,线程 B 把 PC 改成了第 100 行,那线程 A 回来后就找不到自己该从哪继续了。所以每个线程必须有自己的 PC

程序计数器是 JVM 规范中唯一没有规定 OutOfMemoryError 的区域——它的内存占用极小且固定,不需要动态扩展。

3.2 虚拟机栈 (VM Stack)

虚拟机栈描述的是 Java 方法执行的内存模型。每个方法在执行时都会创建一个栈帧 (Stack Frame),用于存储以下信息:

组成部分作用
局部变量表存放方法参数和局部变量(基本类型的值 + 对象引用 reference)
操作数栈字节码指令的工作空间,用于中间计算(如 iadd 弹出两个值,压入结果)
动态链接指向运行时常量池中该方法的引用,支持多态调用(虚方法分派)
方法返回地址方法结束后回到调用者的哪一行继续执行
StackDemo.java · 栈帧与局部变量
public class StackDemo {
    public static int add(int a, int b) {
        int sum = a + b;  // a, b, sum 存在局部变量表
        return sum;       // 操作数栈: iload a → iload b → iadd → istore sum
    }
    public static void main(String[] args) {
        int result = add(3, 5); // 调用 add → 压入新栈帧;返回 → 栈帧弹出
    }
}

StackOverflowError 发生在栈的深度超出限制时——最典型的就是无限递归:

InfiniteRecursion.java · StackOverflowError 示例
public class InfiniteRecursion {
    public static void stackOverflow() {
        stackOverflow(); // 每次调用都压入一个新栈帧,永不停止
    }

    public static void main(String[] args) {
        stackOverflow();
        // 抛出: java.lang.StackOverflowError
        // 默认栈大小约 512KB~1MB(-Xss 可调)
    }
}
栈溢出条件:递归深度 × 每帧大小 > -Xss 设定的栈容量

虚拟机栈还有一种 OOM 情况:当无法申请到足够内存来扩展栈(比如创建了太多线程,每个线程都要占用栈空间),会抛出 OutOfMemoryError: unable to create new native thread

3.3 本地方法栈 (Native Method Stack)

本地方法栈与虚拟机栈类似,只不过它服务的是 Native 方法(用 C/C++ 实现的底层方法,如 Unsafe.allocateMemoryThread.start0())。HotSpot VM 直接把本地方法栈和虚拟机栈合二为一。

本地方法栈同样会抛出 StackOverflowError 和 OutOfMemoryError。

第 4 站

线程共享区域:堆与方法区

4.1 堆 (Heap)

堆是 JVM 中最大的一块内存区域,被所有线程共享。几乎所有的对象实例和数组都在这里分配。堆也是垃圾收集器(GC)的主战场。

堆的典型分代结构:

分区占比特点GC 类型
Eden 区~80% 新生代新对象首先分配在 EdenMinor GC
Survivor S0 / S1各 ~10% 新生代Minor GC 存活对象的暂存区Minor GC
老年代 (Old Gen)约 2/3 总堆经过多次 GC 仍存活的对象晋升至此Full GC / Major GC
HeapOOM.java · 堆溢出示例
public class HeapOOM {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();
        while (true) {
            list.add(new byte[1024 * 1024]); // 每次 1MB,list 持有强引用,GC 无法回收
        } // → OOM: Java heap space(-Xms / -Xmx 控制堆大小)
    }
}

通过 -Xms(初始堆大小)和 -Xmx(最大堆大小)来控制堆内存。

4.2 方法区 (Method Area)

方法区存储以下数据:

内容说明
类信息类的全限定名、父类、接口、字段、方法定义等元数据
运行时常量池编译期生成的字面量和符号引用(.class 文件中的常量池的运行时表示)
静态变量static 修饰的字段
JIT 编译代码热点代码被 JIT 编译后的本地机器码

4.3 方法区的演进:永久代 → 元空间

这是面试中的高频考点。方法区是一个逻辑概念,不同 JDK 版本有不同的实现:

evolution.txt · 方法区演进历史
/* ═══ JDK 5/6/7:永久代 (Permanent Generation) ═══ */
// 方法区用堆内存中的一块"永久代"来实现
// 大小由 -XX:MaxPermSize 控制(默认约 64MB~82MB)
// 字符串常量池在永久代中
// 问题:大小固定,容易因加载过多类而 OOM: PermGen space

/* ═══ JDK 8+:元空间 (Metaspace) ═══ */
// 永久代被移除,方法区改用"元空间"实现
// 元空间使用本地内存 (Native Memory),不再占用 JVM 堆内存
// 默认不设上限(受限于系统物理内存)
// 可用 -XX:MaxMetaspaceSize 设置上限
// 字符串常量池从方法区移到了堆中(其实 JDK 7 就移了)
为什么 JDK 8 要把永久代替换成元空间?

三大原因:
1. 固定大小容易 OOM:永久代大小在启动时确定,难以预估需要多少空间。Web 应用中大量使用动态代理、JSP 编译、热部署时,类不断被加载,PermGen 很容易溢出。
2. GC 效率低:永久代的 GC 回收效率很差,类卸载条件苛刻,导致大量无用的类元数据占据空间。
3. 与 JRockit 合并:Oracle 收购 Sun 后,需要将 HotSpot 和 JRockit 的代码库合并。JRockit 没有永久代的概念,统一到元空间可以减少代码复杂度。
面试加分 JDK 7 时,字符串常量池就已经从永久代移到了堆中。所以 String.intern() 在 JDK 6 和 JDK 7+ 的行为不同——JDK 6 会在永久代创建新字符串,JDK 7+ 在堆中创建(或在常量池中记录已存在的引用)。这是很多候选人会搞混的知识点。

4.4 方法区 OOM 示例

MethodAreaOOM.java · 元空间溢出
/** 使用 CGLib 动态生成大量类,撑爆元空间 */
public class MethodAreaOOM {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMTarget.class);
            enhancer.setUseCache(false); // 关闭缓存,每次都生成新类
            enhancer.setCallback((MethodInterceptor) (o, m, a, p) -> p.invokeSuper(o, a));
            enhancer.create(); // → OOM: Metaspace
        }
    }
}
class OOMTarget {}
第 5 站

直接内存 (Direct Memory):堆外的秘密战场

直接内存不属于 JVM 运行时数据区的规范定义,但在实际开发中极为重要——Netty、Kafka、Elasticsearch 等高性能框架大量使用直接内存。

5.1 什么是直接内存?

直接内存是在 JVM 堆之外,通过操作系统直接分配的内存。Java NIO 中的 DirectByteBuffer 就是典型的直接内存使用者:

DirectMemoryDemo.java · 直接内存分配
import java.nio.ByteBuffer;
public class DirectMemoryDemo {
    public static void main(String[] args) {
        ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); // 分配 10MB 堆外内存
        buffer.put((byte) 1);
        buffer.flip();
        byte val = buffer.get(); // val = 1
        // 直接内存不受 GC 管理,依赖 Cleaner 机制(虚引用)回收
        // 当 DirectByteBuffer 对象被 GC 时,关联的 Cleaner 会释放堆外内存
    }
}

5.2 为什么需要直接内存?——零拷贝

传统的 IO 操作需要4 次数据拷贝,而直接内存可以实现零拷贝

传统 IO vs 直接内存(零拷贝) 传统 IO(4 次拷贝) 磁盘 内核缓冲区Kernel Buffer 用户缓冲区JVM Heap Socket 缓冲区Kernel Buffer 网卡 直接内存(零拷贝) 磁盘 内核缓冲区Kernel Buffer mmap 映射,无需拷贝 直接内存DirectByteBuffer 网卡 直接内存跳过 JVM Heap,减少数据拷贝次数,大幅提升 IO 密集型应用性能 Netty、Kafka、RocketMQ 等框架的核心优化手段
图 2 传统 IO 与直接内存(零拷贝)的对比:减少用户态与内核态之间的数据拷贝

5.3 直接内存的 OOM

DirectOOM.java · 直接内存溢出
import java.nio.ByteBuffer;
import java.util.*;
public class DirectOOM {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        while (true) {
            buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); // 不断分配 1MB 直接内存
        } // → OOM: Direct buffer memory (-XX:MaxDirectMemorySize 控制上限)
    }
}
生产注意 直接内存的回收依赖 System.gc() 触发 Cleaner 来释放,如果同时设置了 -XX:+DisableExplicitGC,直接内存可能无法及时回收,导致 OOM。Netty 通过自定义的 ReferenceCountUtil.release() 手动管理直接内存的生命周期来解决这个问题。
第 6 站

StackOverflowError vs OutOfMemoryError

面试中经常被问到:什么时候抛 StackOverflowError?什么时候抛 OutOfMemoryError?它们的本质区别是什么?

对比维度StackOverflowErrorOutOfMemoryError
发生区域虚拟机栈 / 本地方法栈堆 / 方法区 / 直接内存 / 虚拟机栈
根本原因栈的深度超出限制内存总量不够,无法分配更多空间
典型场景无限递归 / 极深调用链大对象、内存泄漏、类加载过多
JVM 参数-Xss(栈大小)-Xmx / -XX:MaxMetaspaceSize / -XX:MaxDirectMemorySize
可恢复性几乎无法恢复,需修复代码部分场景可通过扩容缓解,但根因仍需修复代码

6.1 虚拟机栈的两种溢出

StackErrors.java · 虚拟机栈的两种异常
public class StackErrors {
    /* 场景 1:栈深度溢出 → StackOverflowError */
    private int depth = 0;
    public void stackLeak() {
        depth++;
        stackLeak(); // 无限递归,栈帧不断压入 → StackOverflowError
    }
    /* 场景 2:栈扩展失败 → OutOfMemoryError */
    public void createThreads() {
        while (true) {
            new Thread(() -> { try { Thread.sleep(1000000); } catch (Exception e) {} }).start();
        } // 线程太多 → OOM: unable to create new native thread
    }
}

6.2 各种 OOM 汇总

OOM 错误信息发生区域典型原因
Java heap space对象过多 / 大对象 / 内存泄漏
GC overhead limit exceededGC 花了 98% 以上时间却回收不到 2% 的内存
Metaspace方法区加载了过多的类(动态代理、热部署)
Direct buffer memory直接内存NIO 分配的 DirectByteBuffer 过多
unable to create new native thread虚拟机栈线程数过多,操作系统无法分配更多栈空间
Requested array size exceeds VM limit尝试分配超过 JVM 限制的数组
PermGen space (JDK 7-)永久代加载了过多的类或字符串
StackOverflowError 和 OutOfMemoryError 之间有联系吗?

有。当栈的大小(-Xss)设得较大时,每个线程的栈会占用更多内存。如果创建大量线程,总的栈空间需求可能超出系统内存,此时就会从 StackOverflow 变成 OOM(unable to create new native thread)。

所以存在一个权衡:-Xss 设大 → 支持更深的调用链,但能创建的线程数变少;-Xss 设小 → 能创建更多线程,但容易 StackOverflow。
第 7 站

总结:运行时数据区速查手册

最后,用一张表把所有区域的核心信息一网打尽:

区域线程共享存储内容大小控制溢出异常
程序计数器私有当前执行的字节码行号固定,无需配置无(唯一不溢出的区域)
虚拟机栈私有栈帧(局部变量表、操作数栈、动态链接、返回地址)-XssStackOverflowError / OOM
本地方法栈私有Native 方法调用信息-Xss(HotSpot 合并)StackOverflowError / OOM
共享对象实例、数组-Xms / -XmxOOM: Java heap space
方法区共享类元信息、常量池、静态变量、JIT 代码PermGen: -XX:MaxPermSize
Metaspace: -XX:MaxMetaspaceSize
OOM: Metaspace / PermGen
直接内存共享NIO DirectByteBuffer 堆外内存-XX:MaxDirectMemorySizeOOM: Direct buffer memory
面试回答模板:
"JVM 运行时数据区分为线程私有和线程共享两大部分。线程私有的有程序计数器——每个线程一个,记录当前执行的字节码行号,是唯一不会 OOM 的区域;虚拟机栈——每个方法调用创建一个栈帧,包含局部变量表和操作数栈,递归过深会 StackOverflowError;本地方法栈——服务 Native 方法。线程共享的有堆——存放对象实例,是 GC 的主战场,分为新生代和老年代;方法区——存放类元信息和常量池,从 JDK 7 的永久代演进到 JDK 8 的元空间,后者使用本地内存,解决了永久代固定大小容易 OOM 的问题。此外还有直接内存,虽然不在 JVM 规范中,但 NIO 和 Netty 广泛使用,用于实现零拷贝 IO,受 MaxDirectMemorySize 控制。"
核心记忆 一句话区分:PC 不溢出,栈溢出靠深度,堆溢出靠容量,方法区溢出靠类数量,直接内存溢出靠 NIO 用量。方法区从永久代到元空间的演进,本质是从"堆内固定空间"迁移到"本地内存按需扩展"。