Java对象创建流程与内存分配
Java 内存区域概述
JVM 运行时内存主要划分为多个区域:程序计数器为线程私有,记录当前执行位置,是唯一不抛 OutOfMemoryError 的区域;虚拟机栈和本地方法栈均为线程私有,前者服务于 Java 方法执行,后者服务于本地方法,二者都可能抛出 StackOverflowError 和 OutOfMemoryError;Java 堆是最大的共享区域,用于存放对象实例和数组,是 GC 主要管理区域,常分为年轻代(含 Eden 区、Survivor 区)和老年代,内存不足时会抛出 OutOfMemoryError;方法区为共享区域,存储类信息、常量等,JDK8 后以元空间(使用本地内存)替代永久代,内存不足也会抛出 OutOfMemoryError。详细介绍参考:JVM内存结构
而当我们在 Java 程序中使用new
关键字创建一个对象时,Java 虚拟机(JVM)是如何创建对象的?以及这个对象是在哪块内存区域中分配的空间?
Java 对象内存创建流程
类加载检查
当虚拟机遇到一条new
指令时,首先会检查该指令的参数是否能在常量池中定位到这个类的符号引用。符号引用是一组符号来描述所引用的目标,在编译时,Java 类中的各种引用(如类名、方法名、字段名等)都是以符号引用的形式存储在字节码文件中的常量池里 。例如,在代码new Person()
中,Person
这个类名在编译后就会以符号引用的形式存在于常量池中。
同时,虚拟机还会检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果该类尚未被加载,虚拟机就会启动类加载机制。类加载过程主要包括加载、验证、准备、解析和初始化这几个阶段 。加载阶段通过类的全限定名获取定义此类的二进制字节流,并将其转化为方法区的运行时数据结构,同时在内存中生成一个代表这个类的java.lang.Class
对象,作为方法区中该类各种数据的访问入口。验证阶段确保Class
文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全,包括文件格式验证、元数据验证、字节码验证和符号引用验证等。准备阶段正式为类变量(被static
修饰的变量)分配内存并设置类变量初始值,这些变量存储在方法区中。解析阶段是将常量池内的符号引用替换为直接引用,直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄 。最后,初始化阶段真正开始执行类中定义的 Java 代码,根据程序员的定义对类变量和其他资源进行初始化,执行类构造器<clinit>()
方法,该方法由编译器自动收集类中的所有类变量赋值动作和静态语句块中的语句合并产生。
内存分配
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。在 Java 堆中,有两种常见的内存分配方式:指针碰撞和空闲列表。
指针碰撞:如果 Java 堆中的内存是规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为 “指针碰撞”。比如,堆内存就像一个整齐排列的书架,已经存放书籍(已使用内存)的区域和空着的区域(空闲内存)界限分明,当要放置一本新书(创建新对象)时,只需将表示界限的指针向空着的区域移动与新书大小(对象大小)相同的距离,然后把新书放在这个位置即可。这种分配方式适用于 Serial 和 ParNew 等不会产生内存碎片的垃圾收集器,因为它们在回收内存时会对内存进行整理,使得内存始终保持规整状态。
空闲列表:如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了。此时,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 “空闲列表” 。就好比一个杂乱的仓库,货物(已使用内存)随意摆放,空闲的空间(空闲内存)也不规则分布。当有新货物(新对象)要存放时,需要查看记录空闲空间的列表,找到一个合适大小的空闲区域,将新货物放入,并更新列表以记录该区域已被占用。这种分配方式常用于使用标记 - 清除算法的垃圾收集器,如 CMS 收集器,因为这种算法在回收内存时不会对内存进行整理,容易产生内存碎片。
在多线程环境下,对象的内存分配是一个并发操作,可能会出现线程安全问题。例如,当多个线程同时尝试创建对象并分配内存时,如果没有适当的同步机制,可能会出现两个线程同时使用同一个指针来分配内存,导致内存分配错误。为了解决这个问题,虚拟机通常采用以下两种方式:
CAS 配上失败重试:CAS(Compare and Swap,比较并交换)是一种乐观锁机制,它包含三个操作数:内存位置、预期原值和新值。CAS 操作会先比较内存位置的值与预期原值是否相等,如果相等,则将该内存位置的值更新为新值,否则不做任何操作。在内存分配中,虚拟机可以使用 CAS 操作来更新指针的位置,以确保内存分配的原子性。如果 CAS 操作失败,说明该内存位置已经被其他线程修改,那么就进行重试,直到分配成功为止。
本地线程分配缓冲(TLAB):为了避免多线程竞争内存分配带来的性能开销,虚拟机还可以采用本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)的方式。每个线程在 Java 堆中预先分配一小块内存,称为 TLAB。当线程创建对象时,首先在自己的 TLAB 中分配内存,如果 TLAB 中的内存不足,再采用 CAS 配上失败重试的方式在堆中分配内存。这样,每个线程都在自己的 TLAB 中独立地进行内存分配,减少了线程之间的竞争,提高了内存分配的效率。默认情况下,TLAB 占用 Eden 区的 1%,可以通过-XX:+UseTLAB
参数开启。
内存初始化
内存分配完成后,虚拟机需要将分配到的内存空间(不包括对象头)初始化为零值。例如,对于一个包含int
类型字段和Object
引用类型字段的对象,int
类型字段会被初始化为 0,Object
引用类型字段会被初始化为null
。这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。如果使用 TLAB,这一工作过程也可以提前至 TLAB 分配时进行。
设置对象头
在完成内存初始化后,虚拟机要对对象进行必要的设置,这些信息存放在对象头中。对象头主要包含两部分信息:
Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。由于对象需要存储的运行时数据较多,而对象头的空间有限,Mark Word 被设计成一个非固定的数据结构,以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间 。例如,在对象处于无锁状态时,Mark Word 中存储对象的哈希码和一些标志位;当对象被用作同步锁时,Mark Word 会存储锁的相关信息,如偏向锁状态下会记录抢到锁的线程 ID,轻量级锁和重量级锁状态下也会有相应的标识和指针信息。
类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。通过这个指针,虚拟机可以快速访问到对象所属类的元数据信息,包括类的字段、方法、继承关系等,从而在运行时能够正确地调用对象的方法和访问对象的字段。
如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。
关于java对象的内存布局可以参考 JAVA对象内存布局 。
执行构造函数
在完成上述步骤后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始。此时,虚拟机将调用对象的构造函数,即<init>()
方法。<init>()
方法是由程序员编写的,用于对对象进行初始化操作,例如给对象的字段赋予初始值、执行一些必要的初始化逻辑等。通过执行构造函数,对象的各个字段被赋予了程序员期望的值,对象的状态被设置为可用状态,一个真正可用的对象才算完全创建出来。例如,在以下代码中:
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
当执行new Person("Tom", 20)
时,在完成前面的内存分配、初始化和对象头设置等步骤后,会调用Person
类的构造函数,将name
初始化为 "Tom",将age
初始化为 20,使得这个Person
对象成为一个符合程序要求的实例。
Java 对象内存分配策略
在 Java 虚拟机中,对象的内存分配策略决定了对象在内存中的存放位置,这对于程序的性能和内存管理效率有着至关重要的影响。不同的分配策略适用于不同类型的对象,主要包括栈上分配、堆上分配和本地内存分配。
栈上分配
栈上分配是 Java 虚拟机提供的一项优化技术,它打破了我们通常认为对象都在堆上分配的常规认知。在传统的 Java 内存模型中,堆是所有线程共享的内存区域,几乎所有对象都分配在堆中,而栈是线程私有的,主要存储线程的局部变量等信息 。但对于那些线程私有的、生命周期较短且不会被其他线程访问到的对象,Java 虚拟机会尝试将它们分配在线程的栈帧中,而不是堆上。
判断一个对象是否适合栈上分配,需要借助逃逸分析技术。逃逸分析的核心是判断对象的作用域是否有可能逃出当前函数体。例如:
public class StackAllocationExample {
private static User user;
private static void escapeMethod() {
user = new User();
user.setName("Tom");
user.setAge(20);
}
private static void nonEscapeMethod() {
User u = new User();
u.setName("Jerry");
u.setAge(18);
}
}
在escapeMethod
方法中,user
对象被赋值给了类的静态成员变量,这意味着它可以被其他线程访问,属于逃逸对象,因此不能进行栈上分配。而在nonEscapeMethod
方法中,u
对象的作用域仅在该方法内部,不会被其他线程访问到,属于非逃逸对象,虚拟机就有可能将其分配在栈上。
栈上分配具有显著的优势。由于栈中的对象是随着方法调用结束而自行销毁,无需等待垃圾收集器处理,从而大大减轻了垃圾回收的压力,提高了对象分配和回收的效率,进而提升了程序的性能。特别是对于大量生命周期短暂的小对象,栈上分配技术能发挥出更大的优势。然而,栈空间相对较小,对于大对象来说,并不适合栈上分配,因为栈空间可能无法容纳大对象,或者会导致栈溢出等问题。
堆上分配
堆是 Java 对象分配内存的主要区域,大部分对象都在堆上进行分配,堆上分配又有以下几种情况:
Eden 区分配:在 Java 堆中,新生代又细分为伊甸园区(Eden Space)和幸存者区(Survivor Space,包含 S0 和 S1 两个区域) 。大多数情况下,新创建的对象会优先在新生代的 Eden 区进行分配。这是因为 Eden 区是为新对象分配内存的主要场所,其设计目的是为了快速创建和回收大量生命周期较短的对象。当 Eden 区空间充足时,对象直接在 Eden 区中分配内存。例如,在一个频繁创建临时对象的循环中:
for (int i = 0; i < 1000; i++) {
String temp = new String("temp" + i);
}
这些String
对象会首先在 Eden 区分配内存。当 Eden 区空间不足时,就会触发一次 Minor GC,这是新生代的垃圾收集动作。Minor GC 采用复制算法,会将 Eden 区和 Survivor 区中仍然存活的对象复制到另一个 Survivor 区(假设为 S1 区),然后清除 Eden 区和当前使用的 Survivor 区(假设为 S0 区)中的垃圾对象。由于 Java 中大部分对象的生命周期都很短,所以 Minor GC 非常频繁,但速度相对较快,因为它只涉及新生代的部分区域。
大对象直接进入老年代:大对象是指那些需要大量连续内存空间的 Java 对象,最典型的大对象就是很长的字符串以及大数组等 。为了避免在 Eden 区和 Survivor 区之间发生大量的内存复制操作,大对象会直接进入老年代进行分配。例如,当创建一个很大的数组时:
byte[] bigArray = new byte[1024 * 1024 * 10]; // 10MB的大数组
这个bigArray
对象会直接被分配到老年代。可以通过 JVM 参数-XX:PretenureSizeThreshold
来设置大对象的阈值,当对象大小超过这个阈值时,就会直接在老年代分配。如果不设置该参数,默认情况下,大对象的判断依据会根据不同的 JVM 实现而有所不同。设置合适的-XX:PretenureSizeThreshold
值对于优化内存分配和垃圾回收性能非常重要,如果设置过小,可能会导致一些本可以在新生代处理的对象被过早地分配到老年代,增加老年代的内存压力;如果设置过大,又可能会使一些大对象在新生代频繁地进行内存复制,降低垃圾回收效率。
长期存活对象进入老年代:虚拟机采用分代收集的方法管理内存,会为每个对象定义一个对象年龄(Age)计数器。对象在 Eden 区出生,当经历一次 Minor GC 后仍然存活,就会被移动到 Survivor 区,并且对象年龄加 1 。之后每熬过一次 Minor GC,对象的年龄就会再次增加。当对象年龄达到 “-XX:MaxTenuringThreshold
” 设置的值(默认为 15 次,可以通过该参数进行调整)时,对象就会被晋升到老年代。例如,一个对象在 Eden 区经历了多次 Minor GC 后,年龄不断增加,当它的年龄达到 15 时,就会被转移到老年代,因为经过多次垃圾回收仍然存活的对象,很可能是生命周期较长的对象,将其放入老年代可以减少新生代的内存压力,提高垃圾回收效率。
动态对象年龄判定:为了更好地适应不同程序的内存状况,虚拟机并不总是严格要求对象的年龄必须达到MaxTenuringThreshold
才能晋升到老年代。当 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区空间一半时,年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等到达到MaxTenuringThreshold
设置的年龄。例如,假设 Survivor 区中年龄为 5 的所有对象大小总和超过了 Survivor 区空间的一半,那么年龄为 5 及以上的对象在下次 Minor GC 时,都会被直接复制到老年代。这种动态对象年龄判定机制使得虚拟机能够根据实际的内存使用情况,更加灵活地管理对象的晋升,提高内存利用效率 。
本地内存分配
本地内存分配是指 JVM 使用由操作系统管理的本地内存来分配对象。通常,这种方式用于存储本地方法调用的数据结构,例如在 JNI(Java Native Interface)调用中,当 Java 代码调用本地 C 或 C++ 代码时,可能会涉及到在本地内存中分配对象 。在本地内存中分配对象的过程与在 Java 堆中分配对象有所不同,它绕过了 Java 堆的管理机制,直接由操作系统的内存分配器进行分配。这种分配方式在一些特定场景下非常有用,比如在需要与底层系统进行高效交互,或者需要使用操作系统特定的内存管理功能时。然而,本地内存分配也带来了一些挑战,由于本地内存不受 JVM 的垃圾回收机制直接管理,需要开发者手动负责内存的释放,否则容易导致内存泄漏等问题。在使用本地内存分配时,需要谨慎处理内存的生命周期,确保在不再使用相关对象时,能够及时释放其占用的本地内存。
总结
Java 对象的内存创建流程和内存分配策略是 Java 语言底层机制的重要组成部分,深入理解这些内容对于编写高效、稳定的 Java 程序至关重要。在内存创建流程中,从类加载检查到执行构造函数,每一步都严谨有序,确保了对象能够正确地在内存中诞生并初始化 。类加载检查保证了对象所属类的正确性和完整性,内存分配阶段通过不同的方式在 Java 堆中为对象找到合适的空间,内存初始化赋予对象字段初始值,设置对象头为对象添加了运行时所需的关键信息,而执行构造函数则完成了对象的个性化初始化,使其成为符合程序逻辑的可用实例。
在内存分配策略方面,栈上分配、堆上分配和本地内存分配各有其适用场景和特点 。栈上分配利用逃逸分析技术,将线程私有的、生命周期短的对象分配在栈上,大大减轻了垃圾回收的压力,提高了程序性能;堆上分配是 Java 对象分配的主要方式,通过 Eden 区、大对象直接进入老年代、长期存活对象晋升老年代以及动态对象年龄判定等机制,有效地管理了对象在堆中的分布,适应了不同生命周期对象的内存需求;本地内存分配则在与底层系统交互等特定场景下发挥作用,但需要开发者手动管理内存释放,以避免内存泄漏。