Java对象的实例化过程
对象的创建
-
检查对象对应的类是否已被虚拟机加载
当虚拟机遇到new对应的字节码指令时,首先检查这个指令的参数是否能在常量池中找到一个类的符号引用,并检查该引用代表的类是否已被虚拟机加载、解析和初始化。如果没有则执行相应的类加载过程。
-
虚拟机为新生对象分配内存(对象所需的内存大小在类加载的过程中已经确定)
内存分配有两种方式:
🌳指针碰撞:假设Java堆中的内存是规整有序的。已用的内存聚集在一块,空闲的内存聚集在另一块。使用一个指针指向两块区域中间,那么需要分配的内存就仅仅把这个指针向空闲区域移动当前对象大小的距离。
🌳空闲列表:如果Java堆内存是已用和空闲交错在一块,并且维护一个列表记录内存的使用情况。当需要分配一定大小的存储时,通过查询列表来获取存储空间。
选择哪种分配方式由Java堆内存是否规整决定,而Java堆内存是否规整由取决于垃圾收集器是否带有压缩的功能。
-
线程安全问题
由于对象的创建在虚拟机中是一个频繁的行为,可能会引起非线程安全问题。解决方法有两种:
🌴对分配内存空间的动作进行同步处理——采用CAS(比较再交换)配上失败重试的方法保证更新操作的原子性
🌴把内存分配的动作按照线程划分在不同的空间进行——每个线程预先分配一块内存(本地线程分配缓冲区TLAB),哪个线程要分配内存就在哪个TLAB中分配。当TLAB用完了,分配新的缓冲区时才需要同步锁定。
-
分配的内存空间初始化
内存空间分配完成后,虚拟机必须对分配好的内存空间(不包括对象头)都进初始化为零值。这一步操作保证了对象的实例字段在Java代码中可以不用赋值直接使用。
-
设置对象头。
将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存放在对象的对象头中。
-
执行
<init>()
方法进行初始化。<init>()
方法包含了初始化成员变量、执行实例化代码块和调用类的构造方法:
对象的内存布局
对象在堆内存中的存储布局可以划分为三部分:
-
对象头
🌲 对象头包含两部分数据:
- 运行时元数据:HashCode、对象年龄、锁状态标志、线程持有的锁等信息;
- 类型指针:指向类元数据,确定对象所属的类型。(并不是所有的对象都要存储类型指针)
如果对象是数组,则还需记录数组的长度。
-
实例数据
即类中定义的各种类型属性(包括从父类继承下来的和本身定义的)。实例数据存放具有一定规则:相同宽度的字段总是被分配在一起;父类中定义的变量会出现在子类之前。
-
对齐填充:不是必须的,也没有特殊含义,起到占位符的作用。
下面通过一个例子来演示对象的内存布局。现有如下代码:
public class Customer {
int id = 100;
String name;
Account account;
{
name = "大客户";
}
public Customer() {
account = new Account();
}
public static void main(String[] args) {
Customer customer = new Customer();
}
}
class Account {
}
main
方法创建Customer对象后,相关内存布局如下图所示:
对象的访问定位
创建对象是为了后续使用该对象。Java程序会通过Java栈上的reference数据来操作Java堆上的具体对象。
主流虚拟机的访问方式有:
句柄访问:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自具体的地址信息👇
直接指针访问:reference中存储的直接就是对象在Java堆中的地址,该对象的内存布局必须考虑放至访问类型数据的相关信息👇
两种对象的访问方式各有优点:
🍁 对于句柄访问方式,reference存储的是句柄地址,当对象在内存中被移动时,只需要改变句柄中实例数据的指针,而reference本身不需要改变。
🍁 直接指针访问的最大优点就是访问速度很快。