对应过程则是:对象创建、对象内存布局、对象访问定位的三个过程。
java中对象的创建方式有很多种,常见的是通过new关键字和反射这两种方式来创建。除此之外,还有clone、反序列化等方式创建。
// 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()
当类实现了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:类型指针指向它的类型的元数据。对象头在对象的内存布局中细讲。
数据长度:如果对象 是 数组,那么在对象头中还必须有一块用于记录数组长度的数据
首先递归的执行父类的构造函数,然后收集本类中为实例变量赋值的语句并执行,最后执行构造方法中的语句。
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。
然后,super()方法出栈,回到子类构造方法中。此时应该收集为子类成员变量赋值的语句并执行。对象的age=20,saySomething()打印出第二句I am the son, 20 years old。
然后执行构造方法中的赋值语句int age = age;saySomething();
由于多态的规则:被重写的方法使用动态分派,查找(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存储与对象自身定义数据无关的信息,用来表示对象的运行时状态。包括了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.
指向类型元数据,从而可以通过对象来访问到它的类型信息。
主要记录数组的长度信息一般为4字节(根据int的范围来考虑)
实例数据中存放了对象的字段信息。无论是从父类继承的,还是在子类中定义的,都保存在实例数据中。
即代码中定义的字段内容
注:这部分数据的存储顺序会受到虚拟机分配参数(FieldAllocationStyle)和字段在Java源码中定义顺序的影响。
// HotSpot虚拟机默认的分配策略如下:
longs/doubles、ints、shorts/chars、bytes/booleans、oop(Ordinary Object Pointers)
CompactFields = true;
// 如果 CompactFields 参数值为true,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
如果对象的实例数据占用空间不是8的整数倍,则填充0值让对象的占用空间位8的整数倍。
常见的有两种方式,句柄访问和直接指针访问。
使用句柄访问的话,对象的引用(如zhangsan),指向的是句柄池中的某个句柄,该句柄存放了指向实际实例对象的指针和指向方法区数据类型的指针。