☕【JVM 技术之旅】全流程化分析 Java 对象的创建过程
前言概要
对应过程则是:对象创建、对象内存布局、对象访问定位的三个过程。
对象的创建过程
对象的创建方式
java 中对象的创建方式有很多种,常见的是通过 new 关键字和反射这两种方式来创建。除此之外,还有 clone、反序列化等方式创建。
通过 new 关键字创建
通过反射创建
反射创建对象,可以通过 class.newInstance()调用无参的构造器创建对象,也可以使用构造器来创建 constructor.newInstance()。
通过 clone 创建对象
当类实现了 Cloneable 接口时,可以使用 clone()方法复制一个对象。需要留意是 clone 方法是浅拷贝。
反序列化创建
通过读取 IO 数据流创建,非本节重点
对象的创建过程
检查类加载(包含是否初始化、是否被加载、是否被解析)
对于 new 和反射两种创建方式而言,需要检查创建对象所使用的参数是否已完成类加载(比如它的类型和参数类型)。如果没有,要先完成类加载过程。
分配内存空间
虚拟机为对象分配内存,即起始地址和偏移量。
对象所需要的空间在创建前就可以确定,但是起始地址需要在分配时去内存中找到一块足够大的空间。地址的分配有两种方式:指针碰撞和空闲列表。
指针碰撞
指针碰撞的方式是假设内存空间是规整的,被使用的和空闲的内存被分割成了两整块,通过一个指针记录分界点。在给对象分配内存的时候,将指针空闲区域移动一段与对象大小相等的距离即可。
空闲列表
如果内存不规整,那么就需要维护一张表,来记录内存中那些地址是空闲的。分配对象时,通过空闲列表去找到一块足够大的空闲内存分配给对象并更新空闲列表。
多个线程创建的对象内存的冲突
举个例子,线程 1 和 2 同时要创建两个对象,指针是同一个。它们各自将指针加载到了 cpu 缓存,然后去执行分配地址空间的指令。结果就导致,后分配的哪一个,可能将先分配的那个对象的地址给覆盖了。
解决的办法有两种,一种是对分配内存的动作进行同步处理,即采用 CAS 加失败重试的方式,保证更新操作的原子性。
另一种是使用 TLAB 的方式将线程的分配空间在堆内存中隔离开,在堆中为每个线程预先分配一小块不同的空间,每个线程创建对象都在自己对应的空间中完成。
即每个线程在 Java 堆中预先分配一小块内存(本地线程分配缓冲(Thread Local Allocation Buffer ,TLAB)),哪个线程要分配内存,就在哪个线程的 TLAB 上分配,只有 TLAB 用完并分配新的 TLAB 时才需要同步锁。虚拟机是否使用 TLAB,可以通过-XX:+/-UseTLAB 参数来设定。
分配完内存之后,对象就已经存在于虚拟机的堆中了,此时虚拟机要将分配的内存空间初始化为零值(对象头例外)。
设置对象头
对象头包含了两种信息:MarkWord 和类型指针。
MarkWord:存放对象本身的运行时状态数据(如 HashCode, GC 分代年龄、锁状态、是否偏向信息等)
KlassPointer:类型指针指向它的类型的元数据。对象头在对象的内存布局中细讲。
即对象指向它的类元数据的指针
虚拟机通过这个指针来确定这个对象是哪个类的实例
数据长度:如果对象 是 数组,那么在对象头中还必须有一块用于记录数组长度的数据
因为虚拟机可以通过普通 Java 对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。
执行对象实例构造函数
首先递归的执行父类的构造函数,然后收集本类中为实例变量赋值的语句并执行,最后执行构造方法中的语句。
因为它涉及到了多态与方法的动态分派。在这里先简单描述一下它的执行过程,用来掌握构造方法的执行还是 ok 的。
首先,创建一个 Son 对象,然后调用其有参构造方法 Son(int age)。
在有参构造方法中隐式调用了父类的无参构造方法,然后父类的构造方法继续调 Object 的构造方法。接下来收集为父类成员变量赋值的语句并执行。
由于多态中子类的成员变量会覆盖父类的成员变量,因此子类对象的 age 仍然是 0。
同时无参构造方法中的 saySomething()此时是被子类对象调用的,因此打印了第一句 I am the son, 0 years old。
然后,super()方法出栈,回到子类构造方法中。此时应该收集为子类成员变量赋值的语句并执行。对象的 age=20,saySomething()打印出第二句 I am the son, 20 years old。
然后执行构造方法中的赋值语句 int age = age;saySomething();
第三句话被打印 I am the son, 30 years old。
子类对象创建完成,回到 main 方法。此时使用多态,将对象转成 Father 对象。
由于多态的规则:被重写的方法使用动态分派,查找(vtable)方法表,该方法实际是属于子类对象的。
因此 guy.saySomething()实际调用的是子类对象的方法,打印出第四句话,I am the son, 30 years old。
最后,输出 guy.age. 成员变量不具备多态性,因此打印出父类对象的 age 60.
I am the son, 0 years oldI am the son, 20 years oldI am the son, 30 years oldI am the son, 30 years old
对象的内存布局
对象在堆中的存储布局划分为三个部分:对象头、实例数据和对其填充(padding)。
对象的内存布局
对象头
对象头中包含 markword(标记字段)和类型指针【数组长度】。
markword
markword 存储与对象自身定义数据无关的信息,用来表示对象的运行时状态。包括了 HashCode,GC 年龄,锁状态等信息。在一个 32 位的虚拟机中,markword 用一个 32 位的 bitmap 表示,bitmap 最后两位存放锁状态信息,如下图。
markword
普通状态下,状态为 01,存储 hashcode,分代年龄,偏向锁状态为 0。
偏向锁状态下,状态为 01,存储持有偏向锁的线程和重入次数,分代年龄,偏向锁状态为 1。此时 hashcode 没了,但是,hashcode 可以通过 Object 的 hashcode()方法计算出来,只要没有重写该方法,那么得到的哈希码始终是一致的。
轻量级锁,状态为 00。通过 cas 方式将对象的 markword 信息原子性地交换到了持有该对象锁的线程中,存储在 lockRecord 内,并同时将 lockRecord 的指针存放在对象头 Markword 的前 30 位。
重量级锁状态下,前 30 位存放指向锁控制器 Monitor 的指针,锁状态为 10.
对象被标记为待回收状态时,最后两位状态为 11.
KlassPointer(类型指针)
指向类型元数据,从而可以通过对象来访问到它的类型信息。
数组长度(array length)
主要记录数组的长度信息一般为 4 字节(根据 int 的范围来考虑)
实例数据
实例数据中存放了对象的字段信息。无论是从父类继承的,还是在子类中定义的,都保存在实例数据中。
按照一定顺序存放,在满足这个顺序的条件下,父类定义的字段又会出现在子类定义的变量之前。
即代码中定义的字段内容
注:这部分数据的存储顺序会受到虚拟机分配参数(FieldAllocationStyle)和字段在 Java 源码中定义顺序的影响。
// HotSpot 虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
// 从分配策略中可以看出,相同宽度的字段总是被分配到一起
// 在满足这个前提的条件下,父类中定义的变量会出现在子类之前
CompactFields = true;
// 如果 CompactFields 参数值为 true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
对齐填充
如果对象的实例数据占用空间不是 8 的整数倍,则填充 0 值让对象的占用空间位 8 的整数倍。
对象的访问定位
常见的有两种方式,句柄访问和直接指针访问。
句柄访问
使用句柄访问的话,对象的引用(如 zhangsan),指向的是句柄池中的某个句柄,该句柄存放了指向实际实例对象的指针和指向方法区数据类型的指针。
其好处是,当对象被移动的时候(比如垃圾回收时,整理内存空间需要大量移动对象),不需要频繁的修改引用,只需要修改句柄中实例数据指针。
通过指针访问,则是对象的引用直接指向了该对象。其好处是,通过引用访问对象时,不需要多一次的指针定位,使得访问速度更快。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/a9992016403ac9d76ee69218f】。文章转载请联系作者。
评论 (1 条评论)