1 程序计数器
程序计数寄存器(Program Counter Register),是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
1.1 特点
- 线程私有:每个线程都有自己独立的程序计数器,各线程之间的计数器互不影响,独立存储。
- 不会出现内存溢出(OutOfMemoryError):由于它存储的是字节码指令地址或者是 Native 方法指针,所占用的空间较小且固定,因此不会出现内存溢出的情况。当线程执行的是 Java 方法时,计数器记录的是正在执行的虚拟机字节码指令的地址;当线程执行的是本地(Native)方法时,计数器的值为空(Undefined)。
1.2 程序计数器为什么是线程私有的?
程序计数器是线程私有的,是因为Java多线程是通过线程轮流切换来获得CPU时间片的,如果线程共享程序计数器,那么线程切换时,就会出现线程A执行到了第100行,切换到线程B,线程B也从第100行开始执行,这样就会出现混乱。每个线程都有自己的程序计数器,各个线程之间的计数器互不影响,这样在多线程切换时,就能保证各个线程之间的独立性。
2 虚拟机栈
是 Java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
2.1 特点
- 线程私有:每个线程都有自己的虚拟机栈,生命周期与线程相同。
2.2 可能的异常
StackOverflowError
:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出此异常。例如,在一个递归方法中没有正确的终止条件,会不断地创建栈帧,最终导致栈深度超过限制。OutOfMemoryError
:如果虚拟机栈可以动态扩展(当前大部分 Java 虚拟机都可以),当扩展无法申请到足够的内存,就会抛出此异常。
2.3 栈帧
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈的基本元素,在这个线程上正在执行的每个方法都有各自对应的一个栈帧。
2.3.1 局部变量表(Local Variable Table)
- 作用:存储方法参数和方法内部定义的局部变量。
- 结构:
-
- 以变量槽(Variable Slot) 为最小单位,每个槽通常占 32 位(可存储
int
、float
、reference
等)。 - 64 位类型(如
long
、double
)占用两个连续的槽。
- 以变量槽(Variable Slot) 为最小单位,每个槽通常占 32 位(可存储
- 索引规则:
-
- 非静态方法:局部变量表的第 0 位索引存储的是方法所属对象的引用(即
this
)。 - 静态方法:局部变量表从第 0 位开始直接存储方法参数。
- 非静态方法:局部变量表的第 0 位索引存储的是方法所属对象的引用(即
- 示例:
public void method(int a, long b, String c) {
int d = a + 1;
// 局部变量表布局:
// 0: this (引用)
// 1: a (int)
// 2-3: b (long,占两个槽)
// 4: c (引用)
// 5: d (int)
}
2.3.2 操作数栈(Operand Stack)
- 作用:方法执行时的临时数据存储区,用于存储中间结果和计算过程。
- 特点:
-
- 后进先出(LIFO)结构,类似于寄存器的功能。
- 操作数栈的深度在编译时确定(通过
max_stack
属性)。
- 示例:计算
int a = 1 + 2
的过程:
-
iconst_1
:将常量 1 压入操作数栈。iconst_2
:将常量 2 压入操作数栈。iadd
:弹出两个操作数,相加后将结果 3 压入栈。istore_1
:将结果 3 存入局部变量表的第 1 个槽。
2.3.3 动态链接(Dynamic Linking)
- 作用:将字节码中的符号引用转换为直接引用,实现方法调用的动态绑定。
- 符号引用:方法在编译时的名称和描述符(如
com/example/MyClass.method:(I)V
)。 - 直接引用:方法在运行时的内存地址,通过运行时常量池解析得到。
- 动态绑定:支持多态(如接口调用、方法重写),在运行时根据对象实际类型确定调用的方法版本。
- 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。例如,对于一些在编译时就能确定的常量、静态方法等,会在类加载的解析阶段将其符号引用转换为直接引用。
- 动态链接:如果被调用的方法在编译器无法被确定下来,只能在程序运行期将调用方法的符号引用转化为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
2.3.4 方法返回地址(Return Address)
- 作用:记录方法调用完成后需要返回的位置(PC 寄存器的值)。
- 返回方式:
-
- 正常返回:通过
ireturn
、areturn
等指令返回,返回地址由调用者保存。 - 异常返回:通过异常处理器表(Exception Table)跳转,不使用返回地址。
- 正常返回:通过
2.3.5 附加信息
- 栈帧信息:如帧数据区、对齐填充等,取决于 JVM 实现。
2.3.2 运行原理
- 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java 方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出。
public class StackFrameExample {
public static int add(int a, int b) {
int c = a + b;
return c;
}
public static void main(String[] args) {
int result = add(3, 4);
System.out.println(result);
}
}
/**
1. main 方法被调用,创建栈帧 M,局部变量表包含 args 和 result。
2. 调用 add(3, 4) 时:
a. 创建新栈帧 A,局部变量表包含 a=3、b=4、c(未初始化)。
b. 执行 a + b:将 a 和 b 压入操作数栈,相加后结果存入 c。
3. add 方法返回:
a. 返回值 7 压入 main 方法的操作数栈。
b. 栈帧 A 出栈,栈帧 M 恢复执行,将 7 存入 result。
*/
3 本地方法栈
本地方法栈与虚拟机栈发挥的作用时非常相似的,区别是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的本地(Native)方法服务。本地方法一般是使用 C、C++等语言编写的,用于与操作系统底层进行交互。
4 堆
堆是 Java 虚拟机所管理的内存中最大的一块,是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
- 栈是运行时的单位,堆是存储的单位。
- 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
堆解决的是数据存储的问题,即数据怎么放?放在哪?
4.1 特点
- 线程共享:所有线程都可以访问堆中的对象。
- 垃圾回收的主要区域:Java 堆是垃圾收集器管理的主要区域,因此也被称为“GC 堆”。
- 可能出现的异常:当堆无法再为新的对象分配内存时,会抛出
OutOfMemoryError
异常。
4.2 分区
4.2.1 年轻代(Young Generation)
年轻代是所有新对象创建的地方。这种垃圾收集称为 MinorGC。年轻一代被分为三个部分:伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为 from/to 或 s0/s1),默认比例是 8:1:1。大部分对象的生命周期都很短,创建后很快就会变成垃圾对象,所以年轻代的垃圾回收操作比较频繁。
- Eden 区(Eden Space)
新创建的对象大多会被分配到 Eden 区。当 Eden 区空间不足时,会触发一次 Minor GC。
- Survivor 区(Survivor Space)
Survivor 区有 2 个,分别称为 From Survivor 和 To Survivor。在 Minor GC 时,Eden 区中存活的对象会被移动到其中一个 Survivor 区(假设为 From Survivor),同时这个 Survivor 区中之前存活的对象,如果还未达到一定的年龄阈值,也会被移动到另一个 Survivor 区(To Survivor)。经过这个 GC 后,From Survivor 和 To Survivor 的角色会互换。
Minor GC 过程:
- 新对象不断在 Eden 区创建,当 Eden 区满时,触发 Minor GC。
- 垃圾回收器会标记除 Eden 区和 From Survivor 区中的存活对象。
- 存活的对象被复制到 To Survivor 区,同时对象的年龄计数器加 1。
- 清空 Eden 区和 From Survivor 区。
- Form Survivor 和 To Survivor 角色互换,原来的 To Survivor 变为新的 From Survivor。
4.2.2 老年代(Old Generation)
老年代用于存储生命周期较长的对象。这些对象通常是经过多次 Minor GC 后仍然存活的对象,或者是一些大对象(超过一定大小的对象可能会直接被分配到老年代)。老年代的垃圾回收频率较低,但回收时通常会导致较长的停顿事件。
4.2.2.1 晋升机制
对象从年轻代晋升到老年代主要有以下几种情况:
- 年龄阈值:对象每经历一次 Minor GC,其年龄计数器就会加 1。当对象的年龄达到一定阈值(默认是 15,可以通过
-XX:MaxTenuringThreshold
参数调整)时,就会被晋升到老年代。
为什么年龄只能是 0-15?
因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。
- Survivor 区空间不足:如果在 Minor GC 时,Survivor 区无法容纳所有存活的对象,这些对象会直接晋升到老年代。
- 大对象直接分配:如果创建的对象超过了一定的大小(可以通过
-XX:PretenureSizeThreshold
参数设置),该对象会直接被分配到老年代。这样做的目的时避免在 Eden 区和 2 个 Survivor 区之间发生大量的内存拷贝。
4.2.2.2 Full GC
当老年代空间不足时,会触发 Full GC。Full GC 会对整个堆(包括年轻代、老年代和方法区)进行垃圾回收,通常会导致较长的停顿时间,因为它需要进行更复杂的对象标记和清理操作。
4.2.3 永久代(Permanent Generation)与元空间(Metaspace)
在 Java 8 之前,堆中还存在一个永久代(Permanent Generation),它主要用于存储类的元数据信息、常量池、静态变量等。永久代有固定的大小限制,容易出现 OutOfMemoryError: PermGen space
错误。从 Java 8 开始,永久代被元空间(Metaspace)所取代。元空间不再是堆的一部分,而是直接使用本地内存。元空间的大小可以动态扩展,只要系统的本地内存足够,就不会出现类似永久代的内存溢出问题。
4.2.4 JVM中对象的分配过程
为对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和内存回收算法密切相关,所以还要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new 的对象先放在Eden区,此区有大小限制。
- 当Eden区空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Eden区进行垃圾回收(Minor GC),将Eden区中的不再被其他对象所引用的对象进行回收。
- 然后将Eden区中的剩余对象移动到Survivor区(From Survivor区),如果Survivor区满了,就将剩余对象移动到另外一个Survivor区(To Survivor区)。
- 当一个对象在Survivor区中经历了一次Minor GC后仍然存活,并且年龄达到一定程度(默认15岁),就会被晋升到老年代。
- 当老年代空间不足时,会触发一次Major GC(Full GC),进行老年代的垃圾回收。
- 如果在Major GC后空间还是不足,就会抛出OutOfMemoryError异常。
4.3 逃逸分析
逃逸分析(Escape Analysis)是一种在编译器(如 Java 的即时编译器 JIT)中使用的优化技术,它能够分析对象的作用域,判断对象是否会逃逸出方法或者线程的范围。
4.3.1 基本概念
Java 中,当创建一个对象时,默认情况下对象是分配在堆上的。而堆上的对象对于多个线程是共享的,这就需要进行额外的同步和垃圾回收操作,会带来一定的性能开销。逃逸分析就是通过分析对象的引用范围,判断对象是否会被方法外部或者其他线程访问,如果对象不会逃逸,就可以对其进行一些优化。
4.3.2 分类
- 方法逃逸:一个对象的引用被方法外部所访问,这种情况称为方法逃逸。例如,方法返回了对象的引用,或者将对象的引用赋值给了类的成员变量。
class EscapeExample {
private static Object globalObj;
public static Object methodEscape() {
Object obj = new Object();
// 将对象引用返回,发生方法逃逸
return obj;
}
public static void assignToGlobal() {
Object obj = new Object();
// 将对象引用赋值给类的静态成员变量,发生方法逃逸
globalObj = obj;
}
}
- 线程逃逸:一个对象被多个线程所访问,这种情况称为线程逃逸。例如,将对象的引用存储在一个静态变量或者共享的集合中,多个线程都可以访问该对象。
import java.util.ArrayList;
import java.util.List;
class ThreadEscapeExample {
private static List<Object> sharedList = new ArrayList<>();
public static void threadEscape() {
Object obj = new Object();
// 将对象引用添加到共享集合中,发生线程逃逸
sharedList.add(obj);
}
}
4.3.3 基于逃逸分析的优化方式
- 栈上分配:如果一个对象不会发生逃逸,那么就可以将该对象分配在栈上而不是堆上。当方法执行结束后,栈上的空间会自动释放,无需进行垃圾回收,从而减少了垃圾回收的开销。
public class StackAllocationExample {
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
// 这里的对象如果不会逃逸,就可能会进行栈上分配
createObject();
}
}
public static void createObject() {
Object obj = new Object();
// 该对象没有发生逃逸
}
}
- 标量替换:标量是指不可再分的数据类型,如基本数据类型(int、float 等)。如果一个对象不会发生逃逸,并且该对象可以被拆解为多个标量,那么就可以不创建对象,而是直接使用这些标量来替代对象的各个字段。相对的,那些还可以被分解的数据叫作聚合量,Java 中的对象就是聚合量。
class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
public class ScalarReplacementExample {
public static void main(String[] args) {
allocatePoint();
}
public static void allocatePoint() {
// 如果 Point 对象不会逃逸,可能会进行标量替换
Point p = new Point(1, 2);
int x = p.x;
int y = p.y;
}
}
在上述代码中,如果 Point 对象不会逃逸,JIT 编译器可能会将 Point 对象拆解为 2 个 int 类型的变量 x 和 y,直接在栈上分配这 2 个变量,而不是创建 Point 对象。
- 同步消除:如果一个对象不会发生线程逃逸,那么对该对象的同步操作就可以被消除。因为只有当对象被多个线程访问时,才需要进行同步操作来保证线程安全。
public class SynchronizationEliminationExample {
public static void main(String[] args) {
createAndUseObject();
}
public static void createAndUseObject() {
Object obj = new Object();
synchronized (obj) {
// 由于 obj 对象不会发生线程逃逸,这里的同步操作可能会被消除
System.out.println("Inside synchronized block");
}
}
}
5 方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
5.1 特点
- 线程共享:所有线程都可以访问方法区中的类信息、常量等数据。
- 可能出现的异常:当方法区无法满足内存分配需求时,会抛出 OutOfMemoryError 异常。
5.2 存储内容
5.2.1 类元数据(Class Metadata)
- 类的全限定名:例如
java.lang.String
,用于唯一标识一个类。 - 类的父类信息:记录该类的直接父类的全限定名。
- 实现的接口信息:列出该类实现的所有接口的全限定名。
- 类的访问修饰符:如
public
、private
、protected
等。 - 类的字段信息:包括字段的名称、类型、访问修饰符等。例如,对于一个类中的
private int age;
字段,会存储字段名age
、类型int
以及访问修饰符private
。 - 类的方法信息:包含方法的名称、参数列表、返回类型、访问修饰符、方法体的字节码等。例如,对于方法
public int add(int a, int b)
,会存储方法名add
、参数列表(int a, int b)
、返回类型int
、访问修饰符public
以及方法体对应的字节码指令。
5.2.2 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern () 方法。
5.2.3 静态变量
类的静态变量也存储在方法区中。静态变量是属于类的,而不是属于某个对象的,所有该类的实例共享这些静态变量。
5.2.4 即时编译器编译后的代码
JIT(Just-In-Time)编译器会在运行时将热点代码(频繁执行的代码)编译成机器码,这些编译后的机器码也会存储在方法区中,以提高代码的执行效率。
5.3 注意事项
- 方法区(Method Area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT 编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念,Java 8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。
- 永久代物理是堆的一部分,和新生代,老年代地址是连续的(受垃圾回收器管理),而元空间存在于本地内存(我们常说的堆外内存,不受垃圾回收器管理),这样就不受 JVM 限制了,也比较难发生OOM(都会有溢出异常)。
- 存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。
5.4 发展演变
5.4.1 永久代(Permanent Generation)
在 Java 7 及以前的版本中,方法区的实现是永久代。永久代有固定的大小限制,它和堆内存是连续的,使用 -XX:MaxPermSize
参数可以设置永久代的最大空间。由于永久代的大小需要提前指定,当加载的类过多或者常量池过大时,很容易出现 OutOfMemoryError: PermGen space
错误。
5.4.2 元空间(Metaspace)
从 Java 8 开始,永久代被元空间(Metaspace)所取代。元空间不再是堆的一部分,而是直接使用本地内存。元空间的大小可以动态扩展,只要系统的本地内存足够,就不会出现类似永久代的内存溢出问题。可以使用 -XX:MetaspaceSize
和 -XX:MaxMetaspaceSize
等参数来控制元空间的初始大小和最大大小。
5.5 垃圾回收
5.6.1 方法区垃圾回收的必要性
方法区主要存储类信息、常量、静态变量等数据。随着程序的运行,会不断有类被加载到方法区中。如果这些类在不再使用后不进行回收,会导致方法区的内存占用不断增加,最终可能引发内存溢出问题。因此,对方法区进行垃圾回收可以有效释放不再使用的内存,提高内存利用率。
5.6.2 回收内容
- 废弃常量
-
- 字面量常量:如字符串常量、整数常量等。例如,在字符串常量池中,如果一个字符串常量没有任何引用指向它,那么这个字符串常量就可能成为废弃常量,被垃圾回收器回收。
- 符号引用常量:Class 文件常量池中的符号引用,如类和接口的全限定名、字段和方法的名称及描述符等。如果这些符号引用所代表的类或成员不再被使用,对应的常量也可能被回收。
- 无用的类
当一个类满足以下三个条件时,就可以被认为是无用的类,有可能被垃圾回收器回收:
-
- 该类的所有实例都已经被回收:也就是 Java 堆中不存在该类的任何实例对象。
- 加载该类的
ClassLoader
已经被回收:类加载器负责将类的字节码加载到方法区中,如果类加载器被回收,说明该类的加载环境已经不存在。 - 该类对应的
java.lang.Class
对象没有在任何地方被引用:即无法通过反射等方式再使用该类的Class
对象。
5.6 与其他区域的关系
- 与堆的关系:在 Java 虚拟机规范中,方法区是堆的逻辑部分,但物理上是相互独立的(Java 8 及以后元空间不再属于堆)。堆主要用于存储对象实例,而方法区主要存储类的元数据等信息。
- 与栈的关系:栈帧中会包含对方法区中类信息和方法信息的引用。例如,当调用一个方法时,栈帧中的动态链接部分会引用方法区中该方法的符号引用,在运行时会将其解析为直接引用。
6 对象创建流程
当在 Java 中执行new ClassName()
时,JVM 会经历一系列复杂的步骤来完成对象的创建。这个过程涉及类加载、内存分配、初始化等多个阶段。以下是详细的执行流程:
6.1 类加载检查(Class Loading Check)
- JVM 首先检查目标类(
ClassName
)是否已在方法区中加载。 - 若未加载,则触发类加载机制,按以下顺序完成加载:
-
- 加载(Loading):通过类加载器将字节码文件加载到方法区。
- 验证(Verification):校验字节码的合法性(如格式、语义检查)。
- 准备(Preparation):为静态变量分配内存并设置初始值(如
int
初始为 0)。 - 解析(Resolution):将符号引用转换为直接引用(如类方法、字段的引用)。
- 初始化(Initialization):执行类的静态代码块和静态变量赋值语句。
// 示例:若Person类未加载,先触发类加载过程
Person p = new Person();
6.2 内存分配(Memory Allocation)
- 计算对象所需内存大小
-
- 根据类的元数据(如字段类型、数量)计算对象在堆中的内存占用。
- 对象布局通常包含:
-
-
- 对象头(Object Header):存储哈希码、分代年龄、锁状态等。
- 实例数据(Instance Data):存储对象的字段值。
- 对齐填充(Padding):确保对象大小为 8 字节的整数倍。
-
- 在堆中分配内存
-
- 指针碰撞(Bump the Pointer):若堆内存规整(如使用 Serial、ParNew GC),通过移动指针分配连续内存。
- 空闲列表(Free List):若堆内存不规整(如使用 CMS GC),通过维护空闲列表分配内存。
- 并发安全处理
-
- CAS(Compare-and-Swap):通过原子操作保证多线程环境下的内存分配安全。
- TLAB(Thread Local Allocation Buffer):为每个线程预分配一小块内存,线程内分配无需加锁。
6.3 内存初始化(Memory Initialization)
- 设置初始值
-
- 将分配的内存空间初始化为零值(如
int
为 0,引用类型
为null
)。 - 这一步确保对象的字段在未显式赋值前有默认值。
- 将分配的内存空间初始化为零值(如
- 设置对象头(Object Header)
-
- 存储对象的类元数据指针(指向方法区中的类信息)。
- 存储哈希码、分代年龄、锁状态等信息。
6.4 执行构造函数(Constructor Execution)
- 调用实例初始化方法
<init>()
-
- 编译器会将构造函数的代码、字段初始化语句和实例代码块合并为
<init>()
方法。 - 若父类构造函数未被显式调用,会先调用父类的无参构造函数。
- 编译器会将构造函数的代码、字段初始化语句和实例代码块合并为
public class Person {
private String name = "default"; // 字段初始化
public Person(String name) {
this.name = name; // 构造函数代码
}
}
// 编译后的<init>()方法包含:
// 1. 调用父类构造函数super()
// 2. 执行字段初始化name="default"
// 3. 执行构造函数代码this.name=name
6.5 对象引用赋值(Reference Assignment)
- 将对象引用传递给变量
-
- 若代码为
Person p = new Person()
,则将新对象的引用赋值给变量p
。 - 此时对象创建完成,可通过引用访问对象的方法和字段。
- 若代码为
6.6 关键优化与特殊情况
- 对象逃逸分析(Escape Analysis)
-
- 若 JVM 通过逃逸分析判定对象仅在方法内部使用(未逃逸),可能会将对象分配在栈上而非堆上(栈上分配)。
- 标量替换(Scalar Replacement)
-
- 若对象可分解为基本类型,JVM 可能不创建完整对象,而是直接在栈上分配其成员变量。
- TLAB(Thread Local Allocation Buffer)
-
- 线程优先在自己的 TLAB 中分配内存,减少锁竞争,提升性能。
6.7 总结
// 完整流程示例
Person p = new Person("Alice");
- 类加载:检查
Person
类是否已加载,若未加载则完成类加载过程。 - 内存分配:在堆中为
Person
对象分配内存(考虑并发安全)。 - 初始化零值:将对象的字段初始化为默认值(如
name=null
)。 - 设置对象头:存储类元数据指针、哈希码等信息。
- 执行构造函数:调用
Person
的构造函数,初始化字段值(name="Alice"
)。 - 引用赋值:将新对象的引用赋值给变量
p
。
7 对象内存布局
在 Java 虚拟机(JVM)中,对象的内存布局(Memory Layout)是理解对象在内存中存储结构的关键。HotSpot VM(最常用的 JVM 实现)中,对象在堆内存中的布局主要分为三个部分:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。
7.1 对象头(Object Header)
对象头是对象内存布局的起始部分,用于存储对象的元数据和运行时信息。
7.1.1 Mark Word(标记字段)
- 作用:存储对象的运行时元数据(随对象状态动态变化)。
- 结构(以 64 位 JVM 为例,开启压缩指针时):
-
- 无锁状态(默认):
-
-
- 25 位:哈希码(HashCode,延迟计算)。
- 4 位:GC 分代年龄(Generational GC Age)。
- 1 位:锁偏向标志(Biased Locking Flag,0 表示非偏向锁)。
- 2 位:锁状态标志(01 表示无锁 / 偏向锁)。
-
-
- 偏向锁状态:
-
-
- 54 位:偏向线程 ID(Thread ID)。
- 2 位:锁状态标志(01 表示偏向锁)。
-
-
- 轻量级锁状态:
-
-
- 62 位:指向栈中锁记录(Lock Record)的指针。
- 2 位:锁状态标志(00 表示轻量级锁)。
-
-
- 重量级锁状态:
-
-
- 62 位:指向互斥量(Monitor)的指针。
- 2 位:锁状态标志(10 表示重量级锁)。
-
-
- GC 标记状态:
-
-
- 64 位:全为 1(用于 GC 标记阶段)。
-
- 特点:Mark Word 的内容随锁状态、GC 状态动态变化,是实现锁优化(如偏向锁、轻量级锁)的核心。
7.1.2 类型指针(Klass Pointer,即 KClass 指针)
- 作用:指向对象的类元数据(Class Metadata),JVM 通过该指针确定对象的类信息(如所属类、方法等)。
- 长度:
-
- 32 位 JVM:4 字节(与地址总线宽度一致)。
- 64 位 JVM:
-
-
- 开启压缩指针(默认,堆≤32GB):4 字节(通过
-XX:+UseCompressedOops
启用)。 - 关闭压缩指针(堆 > 32GB):8 字节(通过
-XX:-UseCompressedOops
关闭)。
- 开启压缩指针(默认,堆≤32GB):4 字节(通过
-
7.1.3 数组长度字段(仅数组对象存在)
- 作用:记录数组的长度(如
int[] array = new int[5]
中的长度 5)。 - 长度:4 字节(类型为
int
,与 JVM 位数无关)。
7.2 实例数据(Instance Data)
实例数据是对象存储的有效数据,包含对象的成员变量(包括从父类继承的变量)。
7.2.1 数据类型与内存占用
数据类型 | 占用字节数 | 说明 |
| 1 | 布尔值(JVM 规范未明确,但 HotSpot 按 1 字节存储) |
| 1 | 字节型 |
| 2 | 字符型(UTF-16 编码) |
| 2 | 短整型 |
| 4 | 整型 / 浮点型 |
| 8 | 长整型 / 双精度浮点型 |
引用类型( | 4/8 | 32 位 JVM:4 字节;64 位 JVM:4 字节(压缩)或 8 字节(未压缩) |
7.2.2 存储顺序
- 字段顺序:JVM 会按字段类型的大小进行优化排序(非严格按类中声明顺序),以减少内存碎片:
-
- 先存储父类继承的字段,再存储当前类声明的字段。
- 同一类中,按字段类型大小排序:
boolean
/byte
→char
/short
→int
/float
→long
/double
→ 引用类型。
- 示例
class MyObject {
boolean flag; // 1字节
int id; // 4字节
Object ref; // 4字节(压缩指针)
double value; // 8字节
}
存储顺序:flag
(1 字节)→ id
(4 字节)→ ref
(4 字节)→ value
(8 字节),总实例数据大小为 1+4+4+8=17 字节。
7.3 对齐填充(Padding)
- 作用:确保对象总大小为 JVM 内存对齐模数(通常为 8 字节)的整数倍,以提高内存访问效率。
- 规则:对象头 + 实例数据的总大小若不是 8 的倍数,则通过填充字节(不存储有效数据)补足。
- 示例:
-
- 上述
MyObject
对象:
- 上述
-
-
- 对象头:Mark Word(8 字节) + 类型指针(4 字节) = 12 字节。
- 实例数据:17 字节。
- 总大小(未对齐):12 + 17 = 29 字节。
- 对齐后:32 字节(29 + 3 字节填充)。
-
7.4 对象内存布局总结
部分 | 内容描述 | 长度(64 位,压缩指针) |
对象头 | Mark Word(含锁状态、GC 信息等) + 类型指针 + 数组长度(若为数组对象) | 8 + 4 = 12 字节(普通对象) |
实例数据 | 成员变量(基本类型值 + 引用指针) | 由字段类型决定 |
对齐填充 | 补足至 8 字节整数倍 | 0~7 字节 |
普通对象 vs. 数组对象
- 普通对象:对象头(12 字节) + 实例数据 + 对齐填充。
- 数组对象:对象头(16 字节,含 4 字节数组长度) + 实例数据(数组元素) + 对齐填充。
7.5 关键知识点扩展
- 指针压缩(Compressed Oops):
-
- 64 位 JVM 默认启用,将引用类型和类型指针压缩为 4 字节,堆最大支持 32GB(通过
-XX:MaxHeapSize
设置)。 - 若堆超过 32GB,需关闭压缩指针(
-XX:-UseCompressedOops
),此时指针长度为 8 字节,对象头变为 16 字节(普通对象)。
- 64 位 JVM 默认启用,将引用类型和类型指针压缩为 4 字节,堆最大支持 32GB(通过
- 锁状态与 Mark Word:
-
- 无锁 → 偏向锁 → 轻量级锁 → 重量级锁(状态升级,不可逆),Mark Word 随状态变化存储不同数据。
- 内存对齐的意义:
-
- 现代 CPU 按块(如 64 位系统按 8 字节块)读取内存,对齐后可减少 CPU 读取次数,提升性能。
7.6 计算对象大小示例
// 普通对象
class Demo {
boolean b = false; // 1字节(偏移量12)
char c = 'a'; // 2字节(偏移量14,需2字节对齐,补足1字节)
int i = 10; // 4字节(偏移量16)
Object obj = null; // 4字节(偏移量20)
}
// 计算步骤:
1. 对象头:Mark Word(8字节) + 类型指针(4字节) = 12字节。
2. 实例数据:1 + 2 + 4 + 4 = 11字节。
3. 总大小(未对齐):12 + 11 = 23字节。
4. 对齐填充:23 → 24字节(补足1字节)。
最终对象大小:24字节。
- 子类实例数据中,父类字段先于子类字段存储。
- JVM 会对字段进行重排序,优先排列大类型,减少填充。
- 每个字段的起始偏移量必须是其类型大小的整数倍。
8 对象访问定位
在 Java 中,对象的访问定位是指通过引用变量(reference)找到堆中实际对象的过程。由于 Java 采用间接访问机制(引用变量存储对象的内存地址),对象的访问定位涉及 JVM 如何通过引用值快速找到对象的实例数据和方法。
8.1 引用的本质
在 Java 中,引用变量(如Object obj = new Object()
中的obj
)存储的是对象在内存中的地址信息,而非对象本身。根据 JVM 实现不同,引用可能是:
- 直接指针:引用直接存储对象在堆中的起始地址。
- 句柄指针:引用存储句柄池(Handle Pool)中的句柄地址,句柄再指向对象。
8.2 对象访问的两种主流方式
JVM 规范并未强制规定引用的实现方式,主流的 HotSpot VM 采用直接指针方式,但也支持句柄访问。以下是两种方式的对比:
8.2.1 直接指针(Direct Pointer)
- 原理:引用变量直接存储对象的内存地址,访问对象时直接通过该地址定位。
- HotSpot 实现:
-
- 对象头中的类型指针(Klass Pointer)指向方法区中的类元数据(Class Metadata),用于获取对象的类型信息(如方法表)。
- 实例数据存储在对象头之后的内存区域。
- 优点:访问速度快(只需一次指针操作)。
- 缺点:当对象被 GC 移动时,需要修改所有引用的指针值。
8.2.2 句柄访问(Handle Access)
- 原理:引用变量存储句柄池中的句柄地址,句柄包含两部分:
-
- 对象实例指针:指向堆中的对象实例数据。
- 类型数据指针:指向方法区中的类元数据。
- 优点:
-
- 当对象被 GC 移动时,只需修改句柄中的实例指针,引用本身无需修改。
- 引用稳定性高(句柄地址固定)。
- 缺点:访问对象需两次指针操作(先句柄,再实例),效率较低。
8.3 HotSpot VM 的实现选择
HotSpot VM 采用直接指针方式访问对象,主要原因是:
- 性能优势:减少一次指针跳转,访问速度更快(尤其对高频方法调用)。
- 缓存友好:对象的实例数据和类型数据在内存中连续存储,利于 CPU 缓存。
8.4 对象访问的具体流程
以方法调用为例,访问对象的完整流程如下:
- 栈帧中的引用:
-
- 方法调用时,对象引用存储在栈帧的局部变量表中。
- 通过引用定位对象:
-
- 若采用直接指针,引用直接指向堆中的对象头。
- 读取对象头信息:
-
- 通过对象头中的 Mark Word 获取锁状态、哈希码等运行时数据。
- 通过类型指针(Klass Pointer)定位方法区中的类元数据。
- 访问实例数据:
-
- 根据对象头的起始地址和字段偏移量(Offset),读取实例数据。
- 方法调用:
-
- 通过类元数据中的虚方法表(Virtual Method Table,vtable) 找到方法的具体实现地址,执行方法。
8.5 验证示例
通过 JOL(Java Object Layout)工具可以验证对象引用的内部结构:
import org.openjdk.jol.info.ClassLayout;
public class ObjectReferenceDemo {
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
// 打印引用的哈希码(基于内存地址计算)
System.out.println("Identity hash: " + System.identityHashCode(obj));
}
}
输出分析:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf8001d60
12 4 (object alignment gap)
Instance size: 16 bytes
- 对象头包含 Mark Word(8 字节)和类型指针(4 字节)。
- 引用
obj
直接指向该对象头地址。
8.6 总结
- 直接指针为主流:HotSpot VM 通过直接指针实现引用,提升访问速度。
- 指针压缩优化:64 位 JVM 默认压缩指针,减少内存占用。
- 对象头的关键作用:
-
- Mark Word 存储运行时状态。
- 类型指针连接对象与类元数据。
- 方法调用路径:通过虚方法表实现动态分派(多态)。
部分内容参考JavaGuide,首图为个人总结所画,后续会继续更新。