3. JVM内存模型与对象生命周期

理解JVM内存模型,是性能调优的起点。因为绝大多数“内存溢出”“频繁Full GC”“响应变慢”的问题,最终都要落到“对象在哪里、什么时候被创建、什么时候被回收”这三个问题上。本节围绕运行时数据区的划分,以及一个普通Java对象从new到被回收的完整路径展开。

一、运行时数据区的职责划分

JVM在运行期把内存划分为若干区域,每个区域的线程归属、生命周期和回收策略都不同,这是调优时判断“该调哪个参数”的依据。

  • 程序计数器:线程私有,记录当前线程执行到的字节码行号。它是唯一不会抛出OutOfMemoryError的区域。
  • 虚拟机栈:线程私有,生命周期与线程一致。每个方法调用对应一个栈帧,栈帧中存放局部变量表、操作数栈、动态链接和返回地址。局部变量表里保存的是基本类型值和对象引用,对象本体并不在栈上(逃逸分析后的标量替换除外)。
  • 本地方法栈:为native方法服务,HotSpot中与虚拟机栈合并实现。
  • 堆:线程共享,是对象实例和数组的主要分配场所,也是垃圾回收的主战场。堆通常按分代划分为新生代(Eden + 两个Survivor)和老年代。
  • 方法区:线程共享,存放类元信息、运行时常量池、静态变量、即时编译后的代码缓存。JDK 8之前由永久代实现,JDK 8起改为元空间,使用本地内存,默认不设上限,受物理内存约束。
  • 直接内存:不是JVM规范定义的运行时数据区,但通过ByteBuffer.allocateDirect()或Netty的DirectByteBuffer大量使用。它绕过堆,减少一次堆内到堆外的拷贝,但分配和回收成本高,且不受-Xmx控制,只受-XX:MaxDirectMemorySize约束。

下面这张图把各区域的线程归属、存放内容和典型溢出异常放在一起对照,便于快速定位问题区域。

JVM运行时数据区与对象生命周期 线程私有 程序计数器 PC 行号指示 · 无OOM 虚拟机栈 / 本地方法栈 栈帧 · 局部变量 · 引用 StackOverflowError 递归过深 / 栈帧过大 线程共享 堆 Heap(-Xms / -Xmx) Eden 新对象分配 S0 Survivor S1 Survivor Old 老年代 新生代 : 老年代 ≈ 1:2(默认) OutOfMemoryError: Java heap space 方法区 / 元空间 类元信息 运行时常量池 静态变量 · JIT代码 JDK8+ 本地内存 Metaspace OOM 直接内存 Direct Memory(堆外,-XX:MaxDirectMemorySize) ByteBuffer.allocateDirect / Netty DirectByteBuffer · 减少一次拷贝 · 不受 -Xmx 约束 回收依赖 Cleaner + Full GC,易出现“堆不大但物理内存被吃满” 对象生命周期路径 new 创建 Eden 分配 Minor GC 存活 年龄+1 进 Survivor 年龄≥15 晋升老年代

二、对象创建的完整过程

当字节码执行到new指令时,JVM按以下顺序完成对象创建:

  1. 类加载检查:检查常量池中该类的符号引用是否已解析,若未加载则先触发类加载。
  2. 分配内存:在Eden区划分一块确定大小的内存。若Eden空间不足,触发一次Minor GC;若仍不足,则通过分配担保机制直接进入老年代。
  3. 内存置零:将分配到的内存空间初始化为零值,保证实例字段不赋初值也能使用。
  4. 设置对象头:写入Mark Word(哈希码、GC分代年龄、锁状态标志)和类型指针。
  5. 执行构造方法:按程序员意图完成字段赋值和初始化逻辑。

分配方式取决于堆是否规整:使用Serial、ParNew等带压缩的收集器时,堆是规整的,采用指针碰撞;使用CMS这类基于标记-清除的收集器时,堆存在碎片,采用空闲列表。并发分配时通过CAS加失败重试或TLAB(线程本地分配缓冲)保证线程安全。TLAB是默认开启的,每个线程在Eden中预分配一小块私有区域,小对象直接在TLAB中分配,避免竞争。

三、晋升与回收:对象如何从新生代走到老年代

对象在新生代的流转遵循“复制+年龄”机制。Eden与两个Survivor的比例默认是8:1:1,可通过-XX:SurvivorRatio=8调整。Minor GC发生时,Eden和当前使用的Survivor中存活的对象被复制到另一个空闲Survivor,同时对象年龄加1。当年龄达到阈值(默认15,由-XX:MaxTenuringThreshold控制)时晋升老年代。

但年龄不是唯一条件。以下情况会提前晋升:

  • 动态年龄判定:Survivor中相同年龄对象大小总和超过Survivor空间的一半,该年龄及以上的对象直接晋升。
  • 大对象直接进老年代:超过-XX:PretenureSizeThreshold的对象绕过新生代,避免在Eden和Survivor之间反复复制。
  • 分配担保失败:Minor GC前若老年代可用连续空间小于新生代所有对象总大小,且HandlePromotionFailure允许担保,则尝试Minor GC;否则直接Full GC。

老年代使用标记-清除或标记-整理算法回收。当老年代空间不足、元空间不足或显式调用System.gc()时触发Full GC。Full GC通常伴随较长的STW停顿,是调优重点观察对象。

四、一个可观察的示例

下面这段代码用于观察对象晋升过程。运行参数建议:-Xms60m -Xmx60m -Xmn20m -XX:SurvivorRatio=8 -XX:+PrintGCDetails -XX:MaxTenuringThreshold=5。

public class PromotionDemo {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] a1 = new byte[2 * _1MB];
        byte[] a2 = new byte[2 * _1MB];
        byte[] a3 = new byte[2 * _1MB];
        byte[] a4 = new byte[4 * _1MB]; // 触发Minor GC
    }
}

观察GC日志时,重点看三处:ParNew行中的DefNew前后容量变化,判断Eden是否被正确回收;Tenured行的容量变化,判断是否有对象晋升;Full GC是否被触发,判断分配担保是否生效。若发现Survivor区长期为空、对象直接进入老年代,通常说明Survivor空间过小或动态年龄判定过早触发,可适当调大-XX:SurvivorRatio或提高-XX:MaxTenuringThreshold。

调优提示:直接内存的回收依赖Cleaner机制,只有在Full GC或显式调用System.gc()时才可能释放。若使用Netty等框架,建议开启-XX:+DisableExplicitGC之外,配合-Dio.netty.maxDirectMemory限制堆外用量,避免“堆内存正常但进程被OOM Killer杀掉”。

把内存区域、对象创建路径和晋升条件串起来看,调优就不再是盲目调参数:先通过jstat -gcutil或GC日志判断对象主要卡在哪个区域,再针对该区域的容量、比例或回收器做调整,才能形成有效闭环。