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约束。
下面这张图把各区域的线程归属、存放内容和典型溢出异常放在一起对照,便于快速定位问题区域。
二、对象创建的完整过程
当字节码执行到new指令时,JVM按以下顺序完成对象创建:
- 类加载检查:检查常量池中该类的符号引用是否已解析,若未加载则先触发类加载。
- 分配内存:在Eden区划分一块确定大小的内存。若Eden空间不足,触发一次Minor GC;若仍不足,则通过分配担保机制直接进入老年代。
- 内存置零:将分配到的内存空间初始化为零值,保证实例字段不赋初值也能使用。
- 设置对象头:写入Mark Word(哈希码、GC分代年龄、锁状态标志)和类型指针。
- 执行构造方法:按程序员意图完成字段赋值和初始化逻辑。
分配方式取决于堆是否规整:使用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日志判断对象主要卡在哪个区域,再针对该区域的容量、比例或回收器做调整,才能形成有效闭环。