← 返回面试专题导航

⚙️ JVM 内存模型面试题

深入理解 JVM 运行时数据区域

本页系统讲解 JVM 内存结构、堆栈区别、方法区、直接内存等核心知识点。

🎯 难度筛选

一、JVM 内存结构

1. JVM 运行时数据区域有哪些? 中等

五大运行时数据区域:

JVM 运行时数据区域: ┌─────────────────────────────────────┐ │ 线程共享区域 │ ├─────────────────────────────────────┤ │ 方法区 (Method Area) │ │ - 类信息、常量、静态变量 │ │ - 运行时常量池 │ ├─────────────────────────────────────┤ │ 堆 (Heap) │ │ - 对象实例 │ │ - 数组 │ └─────────────────────────────────────┘ ┌─────────────────────────────────────┐ │ 线程私有区域 │ ├─────────────────────────────────────┤ │ 虚拟机栈 (VM Stack) │ │ - 局部变量表 │ │ - 操作数栈 │ │ - 动态链接 │ │ - 方法出口 │ ├─────────────────────────────────────┤ │ 本地方法栈 (Native Method Stack) │ │ - Native 方法服务 │ ├─────────────────────────────────────┤ │ 程序计数器 (Program Counter) │ │ - 当前线程执行的字节码行号 │ └─────────────────────────────────────┘
  • 线程共享:堆、方法区
  • 线程私有:虚拟机栈、本地方法栈、程序计数器
2. 堆和栈的区别是什么? 困难
┌──────────────┬──────────────┬──────────────┐ │ 特性 │ 堆 (Heap) │ 栈 (Stack) │ ├──────────────┼──────────────┼──────────────┤ │ 存储内容 │ 对象实例 │ 局部变量、方法调用│ │ 线程共享 │ 是 │ 否(线程私有)│ │ 生命周期 │ GC 回收 │ 方法结束自动释放│ │ 大小 │ 较大(-Xmx) │ 较小(-Xss) │ │ 异常 │ OutOfMemoryError│ StackOverflowError│ │ 分配速度 │ 较慢 │ 快 │ └──────────────┴──────────────┴──────────────┘
public void method() { int a = 10; // 栈:局部变量 String s = "hello"; // 栈:引用变量 User user = new User(); // 栈:引用,堆:User对象 // 内存分配: // 栈:a, s, user(引用) // 堆:User对象实例 }
💡 记忆:栈管运行,堆管存储。栈存引用,堆存对象。
3. 什么是栈帧?包含哪些内容? 中等

栈帧(Stack Frame):每个方法执行时在虚拟机栈中创建的数据结构。

栈帧结构: ┌─────────────────────────┐ │ 局部变量表 │ ← 存储方法参数和局部变量 ├─────────────────────────┤ │ 操作数栈 │ ← 方法执行时的工作区 ├─────────────────────────┤ │ 动态链接 │ ← 指向运行时常量池的引用 ├─────────────────────────┤ │ 方法返回地址 │ ← 方法正常/异常退出的地址 └─────────────────────────┘
public int add(int a, int b) { int c = a + b; return c; } // 栈帧内容: // 局部变量表:[this, a, b, c] // 操作数栈:执行 a + b 的临时数据 // 动态链接:指向 add 方法的符号引用 // 返回地址:调用 add 方法的下一条指令地址

二、堆内存

4. 堆内存是如何划分的? 中等

堆内存分代模型(JDK 8 之前):

堆内存结构: ┌─────────────────────────────────────┐ │ 堆 (Heap) │ ├─────────────────────────────────────┤ │ 新生代 (Young Generation) - 1/3 │ │ ├─ Eden 区 (8/10) │ │ ├─ Survivor 0 (1/10) │ │ └─ Survivor 1 (1/10) │ ├─────────────────────────────────────┤ │ 老年代 (Old Generation) - 2/3 │ │ - 长期存活的对象 │ └─────────────────────────────────────┘ 永久代 (PermGen) - JDK 7 元空间 (Metaspace) - JDK 8+
  • 新生代:新创建的对象,GC 频繁(Minor GC)。
  • 老年代:长期存活的对象,GC 较少(Major GC)。
  • 元空间:类元数据,使用本地内存(JDK 8+)。
5. 对象是如何分配内存的? 困难

对象分配流程:

对象分配决策树: 1. 对象创建 ↓ 2. 尝试在 Eden 区分配 ↓ 3. Eden 区空间不足? ├─ 否 → 分配成功 └─ 是 → Minor GC ↓ 4. GC 后仍不足? ├─ 否 → 分配成功 └─ 是 → 尝试直接进入老年代 ↓ 5. 大对象直接进入老年代 (-XX:PretenureSizeThreshold) ↓ 6. 长期存活对象进入老年代 (年龄 > MaxTenuringThreshold)
// 示例:对象分配 public class AllocationTest { public static void main(String[] args) { // 小对象:Eden 区 byte[] small = new byte[1024]; // 大对象:直接进入老年代 byte[] large = new byte[10 * 1024 * 1024]; // 长期存活:经过多次 GC 后进入老年代 Object longLived = new Object(); } } // JVM 参数: // -Xmx20m -Xms20m -Xmn10m // -XX:PretenureSizeThreshold=5m // 大对象阈值 // -XX:MaxTenuringThreshold=15 // 晋升老年代年龄
6. 什么是 TLAB?为什么需要它? 中等

TLAB(Thread Local Allocation Buffer):线程本地分配缓冲区。

作用:避免多线程并发分配内存时的同步开销。

  • 每个线程在 Eden 区预分配一小块内存(TLAB)。
  • 线程优先在自己的 TLAB 中分配对象,无需加锁。
  • TLAB 用完后,再申请新的 TLAB 或直接在 Eden 区分配。
// JVM 参数 -XX:+UseTLAB // 启用 TLAB(默认开启) -XX:TLABSize=256k // TLAB 大小 -XX:+PrintTLAB // 打印 TLAB 信息
💡 优势:提高对象分配效率,减少锁竞争。

三、方法区与元空间

7. 方法区存储什么内容? 中等

方法区(Method Area)存储内容:

  • 类信息:类的版本、字段、方法、接口等。
  • 常量池:编译期生成的字面量和符号引用。
  • 静态变量:类的静态变量。
  • 即时编译器编译后的代码:JIT 编译的本地代码。
public class User { private static int count = 0; // 方法区:静态变量 private String name; // 堆:实例变量 public static final String TYPE = "USER"; // 方法区:常量 public void method() { int local = 10; // 栈:局部变量 } } // 方法区存储: // - User 类的元数据 // - count 静态变量 // - TYPE 常量 // - method() 方法的字节码
8. 永久代和元空间的区别?为什么要用元空间? 困难
┌──────────────┬──────────────┬──────────────┐ │ 特性 │ 永久代(JDK7)│ 元空间(JDK8+)│ ├──────────────┼──────────────┼──────────────┤ │ 位置 │ JVM 堆内存 │ 本地内存 │ │ 大小限制 │ 固定大小 │ 默认无限制 │ │ GC │ Full GC │ 类卸载时回收 │ │ 参数 │ -XX:PermSize │ -XX:MetaspaceSize│ │ OOM 风险 │ 较高 │ 较低 │ └──────────────┴──────────────┴──────────────┘

为什么要用元空间?

  • 避免 OOM:永久代大小固定,容易溢出;元空间使用本地内存,更灵活。
  • 简化 GC:减少 Full GC 的频率。
  • 统一内存管理:HotSpot 与 JRockit 统一。
// JDK 7 永久代参数 -XX:PermSize=64m -XX:MaxPermSize=256m // JDK 8+ 元空间参数 -XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=256m
⚠️ 注意:JDK 8 之后,字符串常量池从永久代移到了堆中。
9. 运行时常量池和字符串常量池的区别? 中等
  • 运行时常量池:方法区的一部分,存储类的常量池信息。
  • 字符串常量池:专门存储字符串字面量,JDK 7+ 在堆中。
// 字符串常量池 String s1 = "hello"; // 常量池 String s2 = "hello"; // 复用常量池中的对象 System.out.println(s1 == s2); // true String s3 = new String("hello"); // 堆中新对象 System.out.println(s1 == s3); // false // intern() 方法 String s4 = s3.intern(); // 返回常量池中的引用 System.out.println(s1 == s4); // true

四、直接内存与其他

10. 什么是直接内存?有什么优缺点? 中等

直接内存(Direct Memory):不在 JVM 堆中,而是直接在操作系统内存中分配。

优点:

  • 避免 Java 堆和 Native 堆之间的数据复制。
  • 提高 I/O 性能(NIO 使用)。

缺点:

  • 不受 JVM GC 管理,需要手动释放。
  • 分配和回收成本较高。
// 使用直接内存 ByteBuffer buffer = ByteBuffer.allocateDirect(1024); // JVM 参数 -XX:MaxDirectMemorySize=512m // 限制直接内存大小
11. 常见的 JVM 内存参数有哪些? 简单
# 堆内存 -Xms2g # 初始堆大小 -Xmx4g # 最大堆大小 -Xmn1g # 新生代大小 -XX:SurvivorRatio=8 # Eden:Survivor = 8:1 # 栈内存 -Xss256k # 每个线程的栈大小 # 元空间 -XX:MetaspaceSize=128m # 初始元空间大小 -XX:MaxMetaspaceSize=512m # 最大元空间大小 # 直接内存 -XX:MaxDirectMemorySize=512m # 推荐配置 -Xms4g -Xmx4g -Xmn2g -Xss256k -XX:MetaspaceSize=256m
💡 建议:-Xms 和 -Xmx 设置相同,避免堆自动扩展的开销。
12. 如何排查 OOM 问题? 困难

排查步骤:

  1. 查看错误信息:确定 OOM 类型(Heap、Metaspace、Direct Memory)。
  2. 生成堆转储:使用 -XX:+HeapDumpOnOutOfMemoryError。
  3. 分析堆转储:使用 MAT、jvisualvm 等工具。
  4. 定位问题:找出占用内存最多的对象。
# 1. 启用 OOM 时自动生成堆转储 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof # 2. 手动生成堆转储 jmap -dump:format=b,file=heap.hprof # 3. 查看堆内存使用情况 jmap -heap # 4. 查看对象统计 jmap -histo | head -20 # 5. 使用 MAT 分析 # 下载 Eclipse MAT,打开 heap.hprof 文件 # 查看 Leak Suspects Report
⚠️ 常见 OOM 原因:
  • 内存泄漏(对象无法被 GC 回收)
  • 内存溢出(对象太多,超过堆大小)
  • 元空间溢出(类太多)
  • 直接内存溢出(NIO 使用过多)