JVM技术专题 全流程化分析Java对象的创建过程「 原理篇」

Posted 浩宇の天尚

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM技术专题 全流程化分析Java对象的创建过程「 原理篇」相关的知识,希望对你有一定的参考价值。

前言概要

对应过程则是:对象创建、对象内存布局、对象访问定位的三个过程。

对象的创建过程

对象的创建方式

java中对象的创建方式有很多种,常见的是通过new关键字和反射这两种方式来创建。除此之外,还有clone、反序列化等方式创建


通过new关键字创建
// Person zhangsan = new Person(id, height, weight)
Person zhangsan = new Person();

通过反射创建

反射创建对象,可以通过class.newInstance()调用无参的构造器创建对象,也可以使用构造器来创建constructor.newInstance()


//Class clz = Class.forName("Person类的全限定类名")
Class clz = Person.class;
Person zhangsan = clz.newInstance()
// 使用构造器创建
Constructor<Person> cons = clz.getConstructor()
// 也可以指定参数类型获取有参构造器
Person zhangsan1 = cons.newInstance()

通过clone创建对象

当类实现了Cloneable接口时,可以使用clone()方法复制一个对象。需要留意是clone方法是浅拷贝。

Person libo = new Person(name: "李博", age:12, ...)
Person Livonor = new Person(name: "Livorno", age:32, ...)
libo.setFather(Livonor)
Person zhangsi = libo.clone() // 此时,张四和张三的名字、父亲在内存中都引用了相同的对象
反序列化创建

通过读取IO数据流创建,非本节重点

对象的创建过程

检查类加载(包含是否初始化、是否被加载、是否被解析)

对于new和反射两种创建方式而言,需要检查创建对象所使用的参数是否已完成类加载(比如它的类型和参数类型)。如果没有,要先完成类加载过程

分配内存空间

  • 虚拟机为对象分配内存,即起始地址和偏移量

  • 对象所需要的空间在创建前就可以确定,但是起始地址需要在分配时去内存中找到一块足够大的空间。地址的分配有两种方式:指针碰撞和空闲列表

指针碰撞

指针碰撞的方式是假设内存空间是规整的,被使用的和空闲的内存被分割成了两整块,通过一个指针记录分界点。在给对象分配内存的时候,将指针空闲区域移动一段与对象大小相等的距离即可。

空闲列表

如果内存不规整,那么就需要维护一张表,来记录内存中那些地址是空闲的。分配对象时,通过空闲列表去找到一块足够大的空闲内存分配给对象并更新空闲列表。

多个线程创建的对象内存的冲突

举个例子,线程1和2同时要创建两个对象,指针是同一个。它们各自将指针加载到了cpu缓存,然后去执行分配地址空间的指令。结果就导致,后分配的哪一个,可能将先分配的那个对象的地址给覆盖了。

解决的办法有两种,一种是对分配内存的动作进行同步处理,即采用CAS加失败重试的方式,保证更新操作的原子性

// 伪代码表示CAS+失败重试
while(true)
    oldPtr = ptr //读取共享指针
    newPtr = oldPtr + sizeOfInstance
    if(compareAndSet(oldPtr, ptr, newPtr))break

另一种是使用TLAB的方式将线程的分配空间在堆内存中隔离开,在堆中为每个线程预先分配一小块不同的空间,每个线程创建对象都在自己对应的空间中完成

即每个线程在 Java堆中预先分配一小块内存(本地线程分配缓冲(Thread Local Allocation Buffer ,TLAB)),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时才需要同步锁。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。


分配完内存之后,对象就已经存在于虚拟机的堆中了,此时虚拟机要将分配的内存空间初始化为零值(对象头例外)。

设置对象头

对象头包含了两种信息:MarkWord和类型指针。

  • MarkWord:存放对象本身的运行时状态数据(如HashCode, GC分代年龄、锁状态、是否偏向信息等)

  • KlassPointer:类型指针指向它的类型的元数据。对象头在对象的内存布局中细讲

    • 即对象指向它的类元数据的指针
    • 虚拟机通过这个指针来确定这个对象是哪个类的实例
  • 数据长度:如果对象 是 数组,那么在对象头中还必须有一块用于记录数组长度的数据

    • 因为虚拟机可以通过普通Java对象的元数据信息确定对象的大小,但是从数组的元数据中却无法确定数组的大小。
执行对象实例构造函数

首先递归的执行父类的构造函数,然后收集本类中为实例变量赋值的语句并执行,最后执行构造方法中的语句

public class AddA 
    public static void main(String[] args) 
        Father guy = new Son(30);
        guy.saySomething();
        System.out.println(guy.age);
    


class Father

    int age = 60;

    public Father() 
        saySomething();
    

    public Father(int age) 
        this.age = age;
    

    public void saySomething()
        System.out.println("I am the father, " + age + "years old");
    


class Son extends Father

	int age = 20;

    public Son(int age) 
        // super();  不写则隐式调用方法,写则必须在子类构造方法的第一句
        saySomething();
        this.age = age;
        saySomething();
    

    public void saySomething()
        System.out.println("I am the son, " + age + " years old");
    

因为它涉及到了多态与方法的动态分派。在这里先简单描述一下它的执行过程,用来掌握构造方法的执行还是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 old
I am the son, 20 years old
I am the son, 30 years old
I 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),指向的是句柄池中的某个句柄,该句柄存放了指向实际实例对象的指针和指向方法区数据类型的指针

    • 其好处是,当对象被移动的时候(比如垃圾回收时,整理内存空间需要大量移动对象),不需要频繁的修改引用,只需要修改句柄中实例数据指针。

  • 通过指针访问,则是对象的引用直接指向了该对象。其好处是,通过引用访问对象时,不需要多一次的指针定位,使得访问速度更快

以上是关于JVM技术专题 全流程化分析Java对象的创建过程「 原理篇」的主要内容,如果未能解决你的问题,请参考以下文章

JVM技术专题 带你梳理分析虚拟机栈映射源代码的流程「原理篇」

JVM技术专题你真正掌握了Java对象创建的流程吗?「原理篇」

JVM技术专题 Java各种类型对象占用内存情况分析「下篇」

JVM技术专题针对于HotSpot虚拟机对象学习和分析指南 「 入门篇」

JVM技术专题 字节码指令集调用执行流程分析「语法分析篇」

JVM技术专题「原理专题」深入剖析Java对象内存分配及跨代引用分析