🧠 内存模型
运行时数据区 · 对象创建过程 · 对象内存布局 · 逃逸分析与 TLAB · 直接内存
1. JVM 运行时数据区有哪些?各区域的作用和 OOM 风险?
五大区域(按线程归属分):
- 程序计数器(PC 寄存器):记录当前线程执行到的字节码行号。线程私有,无 OOM。唯一不会 OOM 的区域。
- 虚拟机栈(Java 栈):线程私有,存放栈帧(局部变量表、操作数栈、动态链接、返回地址)。每调用一个方法入栈一个栈帧。溢出:StackOverflowError(栈深超限)/ OutOfMemoryError(栈容量申请失败)。Xss 默认 1M。
- 本地方法栈:为 native 方法服务,与虚拟机栈类似。
- 堆(Heap):线程共享,存放所有对象实例。GC 主战场,分新生代(Eden + 两个 Survivor)和老年代。OOM 最常见区域:Java heap space。
- 方法区(Method Area):存放类的元信息(字段/方法描述)、运行时常量池、类变量的定义。注意:静态变量的值在 JDK 7 起已随 Class 对象移到堆中,字符串常量池同样在 JDK 7 移入堆。JDK 7 前方法区是永久代(PermGen,-XX:MaxPermSize);JDK 8 改为元空间(Metaspace,使用本地内存,-XX:MaxMetaspaceSize)。
🎯 面试要点
- 栈管运行(方法调用),堆管存储(对象实例)——背下这句,好理解
- JDK8 为什么去永久代:永久代有大小上限且与堆连在一起,元空间用本地内存,且字符串常量池和静态变量在 JDK7 已移到堆中
- 字符串常量池在堆中(JDK7+),运行时常量池在方法区/元空间
- OOM 常见类型:Java heap space(堆)、GC overhead limit exceeded(GC 效率极低)、Metaspace、unable to create new native thread(创建线程失败,可能是栈内存不足)
2. new 一个对象,JVM 内部发生了什么?
- 类加载检查:检查常量池能否定位到类符号引用,类是否已加载/解析/初始化(未加载则先加载)
- 分配内存:
- 指针碰撞(Bump the Pointer):堆内存规整(Serial/ParNew 用复制整理)→ 移动指针即可
- 空闲列表(Free List):堆内存碎片化(CMS 用标记清除)→ 维护可用块列表
- 并发安全:CAS + 失败重试,或 TLAB(线程本地分配缓冲区,Eden 中每线程预分配一块,优先在 TLAB 分配,避免锁竞争)
- 初始化零值:实例字段赋默认值(int=0、引用=null),保证不赋初值也能用
- 设置对象头:Mark Word(hashCode、GC 分代年龄、锁状态标志)、类型指针(指向类元数据)
- 执行构造方法:先执行父类构造(super()),再执行实例代码块和本类构造,按字节码顺序把字段初始化
🎯 面试要点
- 对象分配位置有优先级:栈上分配(逃逸分析)→ TLAB → Eden → 大对象直接进老年代
- 大对象阈值 -XX:PretenureSizeThreshold,避免大对象在新生代来回拷贝
- 对象进入老年代:年龄计数器达到 MaxTenuringThreshold(默认 15)或动态年龄判定
3. 对象在内存中的布局(HotSpot)
- 对象头(Header):
- Mark Word(64 位机 8 字节):hashCode、GC 年龄、锁状态(偏向锁/轻量锁/重量锁标记)。复用存储,不同状态含义不同
- 类型指针(Klass Pointer,开启压缩指针后 4 字节):指向方法区中的类元数据
- 数组对象还有数组长度(4 字节)
- 实例数据(Instance Data):字段值,相同宽度字段按分配顺序排列
- 对齐填充(Padding):对象大小必须是 8 字节的倍数,不够就补
估算对象大小
// 64 位 JVM,开启压缩指针(默认)
// 普通对象:对象头 12B(Mark Word 8B + 类型指针 4B)+ 字段 + 对齐
class User {
int age; // 4B
String name; // 4B(压缩引用)
}
// 12 + 4 + 4 = 20 → 对齐到 24 字节
// 数组:对象头 16B(多 4B 长度)+ 元素 × 大小,同样 8 字节对齐
int[] arr = new int[10]; // 16 + 40 = 56 → 对齐 56(已是 8 的倍数)
🎯 面试要点
- 压缩指针 -XX:+UseCompressedOops:堆 ≤ 32G 时引用 8B → 4B,省内存
- 字段重排:HotSpot 会按宽度重排字段减少填充(boolean/byte 靠后)
- Mark Word 里存了锁信息,这是 synchronized 锁升级的硬件基础(见并发模块)
4. 什么是逃逸分析?JIT 会做什么优化?
逃逸分析:JIT 编译器分析对象作用域——对象只在方法内部使用(不逃逸)还是被方法外引用(逃逸)?基于分析结果做三个优化:
- 栈上分配:不逃逸的小对象直接在栈帧分配,方法结束自动销毁,免 GC。HotSpot 实际通过标量替换实现(见下)
- 标量替换:对象字段拆成独立标量(int、引用),直接在寄存器/栈上使用,对象本体不创建
- 锁消除:JIT 检测到对象锁不逃逸(无竞争),直接去掉 synchronized(如 StringBuffer 的局部使用)
锁消除示例
public static String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // 局部对象,不可能被其他线程访问
sb.append(a).append(b); // JIT 消除 synchronized 开销
return sb.toString();
}
🎯 面试要点
- 逃逸分析开关:-XX:+DoEscapeAnalysis(JDK 默认开启)
- 栈上分配依赖标量替换:-XX:+EliminateAllocations
- 并非所有对象都能栈上分配:大对象、逃逸对象不行
5. 什么是直接内存(Direct Memory)?
- JDK4 引入 NIO 后,通过
DirectByteBuffer分配堆外内存(基于 Unsafe.allocateMemory) - 好处:读写不走 JVM 堆,省去一次堆内 ↔ 堆外拷贝;对垃圾回收无压力(不占堆)
- Netty 用它做零拷贝:网络数据直接读入直接内存,避免 read → 堆拷贝
- 风险:不归 JVM GC 管,需要手动回收(Cleaner 虚引用机制);分配过多会
OutOfMemoryError: Direct buffer memory - 大小由
-XX:MaxDirectMemorySize控制,默认等于堆最大值
🎯 面试要点
- 零拷贝的两种理解:堆外直接内存 + sendfile(见网络模块)
- NIO 的 ByteBuffer.allocateDirect() 分配直接内存
- 直接内存 OOM 时 top 命令看 RSS 内存会很高,但堆没满——注意区分