对象的创建过程
类加载检查
当JVM遇到一条字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载,解析和初始化过。如果没有,那么先执行相应的类加载过程。
为新生对象分配内存
对象所需内存的大小再类加载完成后便可完全确定(对象的字段存储的时基本类型值,对象和数组的引用),接下来从Java堆中划分出对应大小的内存块给新的对象,分配方式有两种:
- 指针碰撞
假设Java堆中内存时绝对规整的,所有被使用的内存都被放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那么所分配内存就仅仅是把那个指针向空闲空间方向挪动一段所需大小的距离。
- 空闲列表
如果Java堆中内存并不是规整的,使用的内存和未使用的内存交错在一起,此时无法使用指针碰撞方法,JVM需要维护一个列表,记录哪些内存块空闲可用,再分配的时候,从列表中找出一块足够大的空间划分给对象实例,并更新列表上的记录。
Java堆是否规整,取决于采用的垃圾收集器是否具有空间压缩整理(Compact)的能力决定。使用Serial
、ParNew
等收集器时,采取指针碰撞方法,当使用CMS这种基于清除(Sweep)算法的收集器时,采用较为复杂的空闲列表来分配内存。
如何保证并发情况下的线程安全问题?
- 对分配内存空间的动作进行同步处理,实际上JVM是采用CAS配上失败重试的方式保证更新操作的原子性
- 把内存分配的动作按照线程划分再不同的空间之中进行,即每个线程在Java堆预先分配一小块内存,称为本地线程分配缓冲(TLAB)。哪个线程要分配内存,在哪个本地缓冲区进行分配。本地缓冲区用完了,分配新的缓存区才需要同步锁定。
初始化
分配完内存之后,JVM将内存空间都初始化为零值,这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用。
设置信息
设置对象头信息:
- 对象是哪个类的实例
- 如何才能找到类的元数据信息
- 对象的哈希码(真正调用
Object::hashCode()
时才计算) - 对象的GC分代年龄
执行构造函数
执行之前,对象的所有字段都为默认的零值,通过Class文件中的<init>()
完成对象的创建过程。
虚拟机层面完成对象创建工作时,Java程序刚开始执行构造函数,此时别的线程读取该对象不为Null(引用存了地址),但是内部无值。如果在并发环境下,由于指令重排序的存在,可能还未读到初始化变量。可以使用volitale
配合Double Check
完成工作。
对象的内存布局
对象头
对象头存储两类信息,第一类用于存储对象自身的运行时数据,称为Mark Word
,包括以下信息:
- 哈希码
- GC分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程ID
- 偏向时间戳
Mark Word
在32位,64位下分别为32bit
和64bit
,32位HotSpot虚拟机中,对象未被同步锁锁定的状态下,Mark Word
的32bit
中25bit
用于存储对象哈希码,4bit
用于存储对象分代年龄,2bit
用于存储锁标志位,1bit
固定为0。
类型指针即对象指向它的类型元数据的指针,JVM通过这个指针来确定对象是哪个类的实例。但类型指针并不是一定存在的。
实例数据
实例数据部分是对象成员变量的值,包括父类继承下来的成员变量和本类的成员变量。
对齐填充
不是必然存在的,起到占位符的作用,因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,对象头部分正好是整数倍,当实例数据部分没有对齐时,通过对齐填充来补全。
对象的访问定位
对象的存储空间在堆上分配,对象的引用在栈上分配,通过这个引用找到具体的对象,主流的访问方式有使用句柄和直接指针两种。
句柄访问方式
堆中需要划分一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。句柄访问的最大好处就是引用中存放的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针。
直接指针访问方式
直接指针访问的最大好处就是速度更快,节省了一次指针定位的时间开销。