1、类加载
当虚拟机遇到一个 new 指令的时候,会先去检测这个指令的参数是否能定位到这个类的符号引用,并检查这个类是否被加载、链接(验证、准备、解析)、初始化过(在 JVM 的方法区中检查)。如果没有,则执行类加载(类加载机制)
2、内存分配
在类加载通过之后,虚拟机将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,相当于从 Java 堆中抽取一块内存出来;而根据内存的是否绝对规整,分为指针碰撞和空闲列表两种分配方式:
指针碰撞:假设 Java 堆中的内存是绝对规整的,分为空闲和非空闲两种,中间用一个指针当做划分界限的指示器;当一个新对象需要分配对象时,相当于把指针向空闲区域移动一段与对象大小相等的距离。
空闲列表:假设 Java 堆的内存不是绝对规整的,空闲和非空闲是相互交错的,那就需要一个 OopMap 列表,用来记录哪些内存块是可以用的,在对象分配内存时,划分一块大小相等的区域给对象,并更新这个列表
从上面的解释看,用哪种分配方式,是通过 Java 堆的内存块是否绝对规整决定的。
堆内存是否规整,主要是看 GC 回收了内存之后是否包含压缩或者整理功能.如果有,那么内存就比较规整.否则如果没有,创建对象就需要采用空闲列表的方式.
比如:serial,ParNew 等带有整理的收集器,可以使用指针碰撞.
CMS 使用简单清除的算法,可以使用空闲列表.
但对象的创建是频繁的,在并发的情况,多线程不一定是安全的,即存在 A 对象在分配内存,指针还未来得及修改,B 对象也同时使用了原来的指针来分配对象。所以又衍生了两种解决办法,CAS+失败重试 和 TLAB 两种方式
CAS+失败重试:虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性 (关于 CAS 锁,是乐观锁的一种实现,解释起来也比较麻烦。
TLAB:本地线程分配缓冲,把内存分配的动作按照线程分配划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,哪个线程需要需要分配,先在 TLAB 中分配,用完了并重新分配新的 TLAB 时,才需要同步锁定。
3、初始值为零
在内存分配完成之后,虚拟机需要将分配到的内存空间初始化为零值 (除对象头外),这一步操作也保证了对象的实例字段在 java 代码中可以不赋初始值就可以使用,因为程序能访问这些字段的数据类型所对应的零值。
4、设置对象头
初始值设置之后,怎么知道对象是哪个类的实例,如何才能找到类的元数据信息、哈希码、GC 分代年龄等信息呢?这就需要对对象头进行一些必要的设置,才能定位到。
5、入栈、执行 init 指令
从虚拟机来看,对象已经分配产生完成了,且入栈了;但 Java 程序来看,这才刚开始,所以,new 之后,则执行 init 方法,进行初始化。
6、Java 对象的内存分布(即实例化后的对象在堆中的分布)
对象在内存中的存储布局可分为 3 部分:
对象头
其中对象头又可以细分为两部分:
1、存储对象自身运行时数据:如哈希码、GC 分代年龄、锁状态标志、线程持有的、偏向线程 ID 等信息
2、类型指针:即对象指向它的类元数据的指针,虚拟机通过这个来确定这个对象是哪个类的实例(比如是指向栈中的类声明)
实例数据
是对象真正存储的有效信息,比如程序中定义的各种类型的字段内容,无论父类和子类都会记录下来;在分配时,相同宽度的字段会被分配到一起,这也是父类定义的变量会出现在子类之前的原因。
对齐填充
没啥实际意义,就是为了保证对象是 8 个字节的整数倍,没对齐时,用来补全而已。
7、对象的访问定位
使用对象时,通过栈上的 reference 数据来操作堆上的具体对象。
建立对象是为了使用对象,Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象;但这些访问方式取决于虚拟机实现而定,目前主流有句柄和直接指针两种:
句柄:从 Java 堆中划分出一块内存用来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄包含了对象实例数据与类型数据各自的具体地址信息,如下图(图片来自 Java 虚拟机第三版)
直接指针:在直接指针中,reference 储存的就是对象地址,所以,需要考虑的是如何防止访问类型数据的相关信息(图片来自 Java 虚拟机第三版)
优点介绍:
句柄:使用句柄好处是,reference 中存放的是文档的句柄地址,对象被移动时,只改变句柄的实例数据指针,而 reference 本身不需要修改
直接指针:使用直接指针的最大好处就是速度更快,节省了指针定位的开销;
HotSpot 使用第二种方式进行对象访问的.