Java对象在计算机中的执行原理:从JVM内存模型到对象创建全过程
你知道
new Student()背后的内存操作吗?为什么对象的属性会有默认值?引用变量和实际对象存储在哪里?本文将带你深入 JVM 内存模型,图解对象创建的全流程,让你彻底理解 Java 对象的内存执行原理。
一、引言
作为一名 Java 开发者,我们每天都在 new 对象,却很少有人真正思考:对象到底被放在了哪里?引用又是如何指向它的? 理解对象的执行原理,不仅能帮助我们写出更高效的代码,还能快速定位内存泄漏、空指针等疑难问题。
本文基于 JVM 规范,结合一个简单的 Student 类,详细剖析从 .java 源代码到运行时内存分配的完整过程。
二、JVM 运行时数据区域(内存模型)
Java 虚拟机在运行程序时,会将内存划分为几个不同的区域。每个区域都有特定的职责和生命周期。
| 区域 | 存储内容 | 线程共享 | 主要特点 |
|---|---|---|---|
| 程序计数器 | 当前线程执行的字节码行号 | 线程私有 | 很小,不会 OOM |
| Java 虚拟机栈 | 局部变量、操作数栈、方法出口等 | 线程私有 | 每个方法执行时创建栈帧 |
| 本地方法栈 | 为 native 方法服务 |
线程私有 | 与虚拟机栈类似 |
| 堆 | 所有 new 出来的对象、数组 |
线程共享 | GC 主要管理区域,可设置大小 |
| 方法区 | 类信息、常量、静态变量、JIT 编译后的代码 | 线程共享 | 逻辑上属于堆,常被称为"永久代"或"元空间" |
下图展示了这五个区域的逻辑关系(箭头表示引用,不是继承):
线程私有
线程共享
栈中的引用指向
类信息定义对象结构
堆
存储对象实例
方法区
存储类结构、常量、静态变量
程序计数器
Java虚拟机栈
栈帧1 栈帧2 ...
本地方法栈
三、一个完整的示例:Student 类
我们定义一个简单的 Student 类,包含两个成员变量和一个方法,然后在 main 中创建对象。
java
public class Student {
String name; // 姓名
double chinese; // 语文成绩
double math; // 数学成绩
public void printTotalScore() {
double total = chinese + math;
System.out.println(name + " 总分:" + total);
}
}
public class StudentTest {
public static void main(String[] args) {
Student s = new Student();
s.name = "小张";
s.chinese = 89;
s.math = 90;
s.printTotalScore();
}
}
四、对象在内存中的执行过程(逐步图解)
步骤 1:类加载 ------ 将 .class 文件读入方法区
JVM 首先找到 StudentTest.class 和 Student.class,通过类加载器将它们的类信息 存入方法区。方法区中记录了:
- 类的访问修饰符、父类、接口
- 字段信息(
name、chinese、math的名称、类型、偏移量) - 方法信息(
printTotalScore的字节码指令) - 常量池(例如字符串
"小张"、" 总分:"等)
注意 :此时还没有任何
Student对象,只有类的蓝图。
步骤 2:main 方法执行 ------ 创建栈帧
JVM 为 main 线程分配一个虚拟机栈 ,然后为 main 方法创建一个栈帧(Stack Frame)。栈帧中包含:
- 局部变量表(存放
args、局部变量s) - 操作数栈(用于计算)
- 方法出口信息
此时局部变量 s 是一个引用类型,尚未赋值 (默认为 null)。
步骤 3:执行 new Student() ------ 分配堆内存
当执行到 new Student() 时,JVM 会做以下几件事:
- 检查类是否已加载 :如果方法区没有
Student类信息,先进行类加载。 - 分配内存 :在堆中为对象分配一块连续的内存空间。对象所需的内存大小在类加载时就已经确定(实例变量 + 对象头)。本例中,对象头约 12~16 字节(取决于 JVM),加上
name引用(4 或 8 字节)、chinese和math(各 8 字节),总共约 32~40 字节。 - 零值初始化 :将分配的内存空间全部初始化为默认值。于是:
name→nullchinese→0.0math→0.0
- 设置对象头 :对象头中存储了哈希码、GC 分代年龄、锁状态标志,以及指向方法区中
Student类元数据的指针。 - 执行构造方法 :如果定义了构造器,会执行
<init>方法。本例中没有显式构造器,但会执行默认构造器,该构造器不做额外赋值。
最终,堆中有了一个完整的 Student 对象,假设其起始地址为 0x4f3f5b4f。
步骤 4:将引用赋值给栈中的变量
new 操作返回该对象的引用(即地址 0x4f3f5b4f),然后赋值给栈帧中的局部变量 s。此时,s 就是一个指向堆中 Student 对象的引用(存放在栈中)。
方法区
堆
栈
引用
类指针
局部变量 s
0x4f3f5b4f
Student 对象
地址: 0x4f3f5b4f
name = null
chinese = 0.0
math = 0.0
对象头...
Student.class
类元数据
字段偏移量
方法字节码
步骤 5:属性赋值 ------ 修改堆中对象的状态
执行 s.name = "小张"; 等语句时,JVM 通过 s 中的地址找到堆中对象,然后修改对应字段的值。注意 "小张" 是一个字符串字面量,它被存放在方法区的运行时常量池 中(JDK 1.7 之后字符串常量池在堆中,为简化本文按经典模型理解)。对象中的 name 引用指向常量池中的 "小张"。
步骤 6:调用方法 ------ 创建新的栈帧
执行 s.printTotalScore(); 时,JVM 会为 printTotalScore 方法创建一个新栈帧压入当前线程的栈中。该方法的局部变量表包含 this(隐式参数,指向当前对象)和局部变量 total。方法执行完毕后,栈帧被弹出,程序返回 main 继续执行。
五、完整流程图 ------ 对象从生到灭
下面用 Mermaid 流程图总结 new Student() 的完整执行路径:
否
是
"执行 StudentTest.main"
"类加载器加载 StudentTest 和 Student 到方法区"
"虚拟机栈为 main 方法创建栈帧"
"执行 new Student"
"类 Student 已加载?"
"加载 Student 类信息到方法区"
"堆中分配内存"
"零值初始化: name=null, chinese=0.0, math=0.0"
"设置对象头"
"执行默认构造方法"
"返回对象地址 0x4f3f5b4f"
"将地址赋值给栈中的局部变量 s"
"s.name = "小张" 等,修改堆中数据"
"调用 printTotalScore,创建新栈帧"
"执行方法字节码"
"方法结束,弹出栈帧"
"main 继续或结束"
六、补充:String 与 StringBuffer 在内存中的区别
在你的原始内容中提到了 String s1; 和 StringBuffer sb;。这里补充一下它们在内存上的差异:
- String :不可变类,任何修改都会创建新的
String对象。字面量"abc"存储在常量池,new String("abc")会在堆中创建对象(即使常量池中已有)。 - StringBuffer :可变类,内部维护一个字符数组,修改操作(
append)直接在原对象上进行,不会创建新对象。因此频繁的字符串拼接推荐使用StringBuffer(或StringBuilder)。
从内存角度看,StringBuffer 对象的堆内存中会有一个 char[] 数组的引用,该数组也在堆中。而 String 对象则持有一个 char[] 引用,且该数组不可变。
七、常见误区与注意事项
- "对象在栈中"?错。对象一定在堆中,栈中只存放引用类型的变量(即地址)。
- 基本类型变量一定在栈中?不一定。局部变量中的基本类型在栈中,但类的成员变量(实例变量)在堆中,静态变量在方法区。
- 方法区也会 OOM?是的,如果动态加载大量类或字符串常量过多,可能导致元空间溢出。
- 引用传递的本质:Java 只有值传递,当参数是引用类型时,传递的是引用的副本(地址值),所以可以通过引用修改堆中的对象内容,但无法让原引用指向一个新对象。
八、总结
- JVM 内存模型分为线程共享 (堆、方法区)和线程私有(栈、程序计数器、本地方法栈),各自负责不同的数据存储。
- 对象创建过程:类加载 → 堆中分配内存 → 零值初始化 → 设置对象头 → 执行构造方法。
- 栈中的引用变量指向堆中的对象,方法区则保存类的元数据。
- 理解这一执行原理,有助于写出更健壮、高效的代码,也能更轻松地排查内存相关 Bug。
如果你曾经对
NullPointerException感到困惑,或者疑惑为什么s = null后对象还没消失,现在你应该有了答案:栈中的引用消失了,但堆中的对象要等待 GC 来回收。