# JVM
- 对象的创建
JDK
版本:1.8
# 1、对象的创建的方式
Java
语言中,对象创建的方式有六种:
new
关键字:最常见的形式、Xxx
的静态方法、XxxBuilder
、XxxFactory
的静态方法。Class
类的newInstance()
方法:通过反射的方式创建对象,调用类的无参构造器进行对象的创建,且其访问权限为public
。Constructor
的newInstance()
方法:通过反射的方式创建对象,调用类的无参、有参构造器进行对象的创建,对构造器访问权限没有要求。- 使用
clone()
方法:不调用任何构造器,但是要求当前类实现Cloneable
接口,重写clone()
方法。 - 使用序列化:从文件、网络中获取一个对象的二进制流。
- 使用第三方库:
Objenesis
。
# 2、对象创建的具体过程
这里讨论的是普通的Java
对象,不包括数组和Class
对象等。
# 1、对象类加载检查
当Java
虚拟机遇到一条字节码new
指令时,首先将去检查这个指令的参数是否能在Metaspace
的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、和初始化,即判断该类型的元信息是否存在。
如果没有定位到该类的符号引用,JVM
会使用双亲委派模式使用当前类的类加载器以**ClassLoader
+包名+类名**作为key
进行查找对应的.class
文件。如果找到了.class
文件则进行类加载,并生成对应的Class
对象。如果没有找到则抛出ClassNotFoundException
异常。
# 2、为对象分配堆内存
在类加载检查通过后,接下来JVM
虚拟机将为新生对象分配堆内存。对象所需的内存大小在类加载完成后便可完全确定。为对象分配内存空间的任务实际上便等同于将一块确定大小的内存区域从JVM
堆中划分出来,这里的对内划分分为两种情况:
假设JVM
堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存放在另一边,已使用与未使用内存之间使用一个指针作为内存分界点的指示器,在这种情况下为新生对象分配内存只需要将指针朝着空闲内存的方向移动一段空间与新生对象内存大小相等的距离即可,这种分配方式称为指针碰撞(Bump The Pointer
)。
如果JVM
堆中内存不是绝对规整的,已使用的内存与空闲内存相互交错在一起,那就没有办法进行简单的指针碰撞分配内存了,JVM
必须维护一个堆内存的空闲列表,在为新生对象分配内存时必须在空闲列表中寻找一块符合要求的内存空间划分给新生对象。分配成功之后并将列表进行更新,这种分配方式称为空闲列表(Free List
)。
选择哪种分配方式是由JVM
堆内存是否规整决定的,而JVM
堆内存是否规则是由JVM
所采用的垃圾收集器是否带有空间压缩整理(Compact
)能力决定的。所以当使用Serial
、ParNew
等带压缩整理过程的垃圾收集器时,系统采用的对象内存分配算法是指针碰撞,既简单又高效。而当使用CMS
这种基于清除(Sweep
)算法收集垃圾时,理论上就只能采用较为复杂的空闲列表算法来分配内存。
# 3、内存分配并发问题
对象的创建在JVM
中是非常频繁的行为,即使仅仅是修改一个指针所指向的位置,在并发场景下也是非线程安全的。解决这个问题有两种可选方案:
- 对为对象分配内存的动作进行同步处理,采用
CAS
以及失败重试的方式保证更新操作的原子性。 - 将为对象分配内存的动作按照线程划分在不同的区域中进行,即每个线程在堆中预先分配一块小内存,称为本地线程分配缓冲区(
Thread Local Allocation Buffer TLAB
),这部分内存空间是线程隔离的,在为新生对象分配内存时先尝试在TLAB
中进行分配,如果本地线程分配缓冲区用完了,再尝试使用CAS
以及失败重试的方式进行分配。虚拟机是否使用TLAB
可以通过-XX:+UseTLAB
选项进行配置。
# 4、初始化分配到的内存
在内存分配完成之后,虚拟机必须将分配到的内存空间进行初始化,初始化过程不包括对象头。内存初始化的作用是对类中的所有属性设置默认值,如果使用了TLAB
这一项工作也可以提前至TLAB
分配内存成功后一同进行。这步操作保证了对象的实例字段在Java
代码中可以不赋初始值就直接使用,使程序都能访问到这些实例字段的数据类型所对应的零值。
# 5、设置对象头
初始化内存之后,JVM
还会将一些信息设置到对象头中。JVM
会将对象所属的类(即类的元信息数据)、如何才能寻找到类的元数据信息、对象的哈希码(对象的哈希码会延迟到正真调用Object#hashCode()
方法时才进行计算)、对象的GC
分代年龄等信息存放在对象的对象头(Object Header
)中。
# 6、执行<init>()
当上述所有步骤执行完成之后,从JVM
的角度看一个新生对象已经创建完成了,但是从Java
程序的角度看,对象创建才刚刚开始–构造函数。即Class
文件中的<init>()
方法还未执行,此时对象中的所有实例字段都还为默认的零值,对象需要的其它资源和状态信息也还未按照预定的意图构建好。
一般来说执行初始化动作由字节码流中new
指令后面是否跟随invokespecial
指令所决定,Java
编译器会在遇到new
关键字地方同生成这两条字节码指令,但是通过其它方式并不一定会如此。new
指令之后会直接执行<inti>()
方法,按照开发者的意愿对对象进行初始化动作,至此一个真正可用的Java
对象才算完全被创建出来。
综上所述,为对象属性赋值的操作:
- 属性的默认初始化
- 显示初始化
- 代码块中初始化
- 构造器中初始化
对象实例化的过程:
- 加载类元信息
- 为对象分配内存,同时处理并发问题
- 属性的默认初始化
- 设置对象头信息
- 属性的显示初始化、代码块中初始化、构造器中初始化
# 3、对象的内存布局
在Hotsopt VM
中,对象在堆内存中存储布局可以划分为三个部分:对象头(Object Header
)、实例数据(Instance Data
)和对齐填充(Padding
)。
# 1、对象头(Object Header
)
Hotsopt VM
中对象的对象头中包含两类信息:
第一类是用于村粗对象自身的运行时数据,如哈希码(hashCode
)、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程ID
、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,这类数据官方称为Mark Word
。
对象头中另外一部分是类型指针,即实例对象指向它的类型元数据的指针,Java
虚拟机通过这个指针来确定该对象是哪个类的实例。
此外如果对象是一个Java
数组,对象头中必须有一块用于记录数组长度的数据,因为JVM
可以通过普通Java
对象的元数据信息确定Java
对象的大小,如果数组长度是不确定的,JVM
将无法通过元数据中的信息推断出数组的大小。
# 2、实例数据(Instance Data
)
实例数据部分是对象真正存储的有效信息,即在Java
代码中定义的各种类型的字段内容,无论是父类中继承下来的还是在子类中定义的字段都必须在此进行记录。
# 3、对齐填充(Padding
)
对齐填充并不是比如存在的,也没有特殊的含义,它仅仅是起着占位符的作用。由于Hotspot
虚拟机的自动内存管理系统要求对象地址的起始地址必须是8字节的整数倍,换而言之就是所有Java
对象的大小都必须是8字节的整数倍。其中对象头部分已被精心设计成正好是8字节的整数倍,因此如果在实例数据部分没有对齐的话,就需要对齐填充部分进行补全。
创建一个Account
类:
public class Account {
public Account() {
System.out.println("Account‘s No Arg Construt");
}
}
创建一个Customer
类:
public class Customer {
private Integer id = 100001;
private String name;
private Account account;
{
name = "kapcd";
}
public Customer() {
System.out.println("Customer's No Arg Construct");
account = new Account();
}
public static void main(String[] args) {
Customer customer = new Customer();
}
}
其对象内存布局如下图所示:
# 4、对象的访问定位
创建对象自然是为了后续使用该对象,Java
应用程序会通过栈上的reference
数据来操作堆上的具体对象。由于reference
类型在《Java
虚拟机规范》中只规定了它是一个指向对象的引用,并没有具体规定这个引用应该通过什么方式去定位、访问到堆中具体位置,所以对象访问方式也是由虚拟机而实现的,主流的访问方式有使用句柄和直接指针两种:
- 如果使用句柄访问的方式,
Java
堆中可能会划分出一块内存空间来作为句柄池,reference
中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。 - 如果使用直接指针访问的方式,
Java
堆中对象的内存布局就必须考虑如何放置访问类型数据相关的信息,reference
中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。
下图为句柄访问方式:
下图为直接指针访问方式:
以上两种对象访问方式各有千秋,使用句柄来访问的最大好处就是reference
中存储的是稳定的句柄地址,当对象应该垃圾收集而被移动时只会改变句柄中的实例数据指针,而reference
本身不需要被修改。
使用直接指针访问对象最大的好处就是速度快,它节省了一次指针定位的时间开销,由于对象访问在Java
中是一种非常频繁的行为,因此这类开销积少成多也是一项极为可观的执行成本。
GitHub源码地址:https://github.com/kapbc/Java-Kapcb/tree/master/src/main/java/com/kapcb/ccc/jvm
备注:此文为笔者学习
JVM
的笔记,鉴于本人技术有限,文中难免出现一些错误,感谢大家批评指正。