1.对象的创建过程
1.1扯淡
java中,class是对某一类事物的抽象表示,而对象则是对某个具体事物的抽象。比如,某个人名叫张三,身份证id:xxxx,身高175,体重70kg...。那么在java中,可以用Person类来对人类进行抽象,用一个具体的Person实例对象来表示张三这个人。正如人需要被生出来才存在于这个世界一样,对象也需要被创建才能存在于java虚拟机中,被程序使用。
1.2 对象的创建方式
java中对象的创建方式有很多种,常见的是通过new关键字和反射这两种方式来创建。除此之外,还有clone、反序列化等方式创建。
- 通过new 关键字创建
Person zhangsan = new Person() // Person zhangsan = new Person(id, height, weight)
- 通过反射创建
反射创建对象,可以通过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 zhanglin = new Person(name: "张灵", age:44, ...)
Person zhangsan = new Person(name: "张三", age:44, ...)
zhangsan.setFather(zhanglin)
Person zhangsi = zhangsan.clone() // 此时,张四和张三的名字、父亲在内存中都引用了相同的对象
- 反序列化创建
通过读取IO数据流创建,非本节重点
1.3 对象的创建过程
检查类加载
对于new 和反射两种创建方式而言,需要检查创建对象所使用的参数是否已完成类加载(比如它的类型和参数类型)。如果没有,要先完成类加载过程。-
分配内存空间
然后,虚拟机为对象分配内存,即起始地址和偏移量。对象所需要的空间在创建前就可以确定,但是起始地址需要在分配时去内存中找到一块足够大的空间。地址的分配有两种方式:指针碰撞和空闲列表。
指针碰撞的方式是假设内存空间是规整的,被使用的和空闲的内存被分割成了两整块,通过一个指针记录分界点。在给对象分配内存的时候,将指针空闲区域移动一段与对象大小相等的距离即可。
如果内存不规整,那么就需要维护一张表,来记录内存中那些地址是空闲的。分配对象时,通过空闲列表去找到一块足够大的空闲内存分配给对象并更新空闲列表。
并发情况下,如何保证多个线程创建的对象内存不会冲突呢。举个例子,线程1和2同时要创建两个对象,指针是同一个。它们各自将指针加载到了cpu缓存,然后去执行分配地址空间的指令。结果就导致,后分配的哪一个,可能将先分配的那个对象的地址给覆盖了。
解决的办法有两种,一种是对分配内存的动作进行同步处理,即采用CAS加失败重试的方式,保证更新操作的原子性。
// 伪代码表示CAS+失败重试
while(true){
oldPtr = ptr //读取共享指针
newPtr = oldPtr + sizeOfInstance
if(compareAndSet(addrsessOfPtr, oldPtr, newPtr)){break}
}
另一种是使用TLAB的方式将线程的分配空间在堆内存中隔离开--在堆中为每个线程预先分配一小块不同的空间,每个线程创建对象都在自己对应的空间中完成。
分配完内存之后,对象就已经存在于虚拟机的堆中了,此时虚拟机要将分配的内存空间初始化为零值(对象头例外)。
设置对象头。
对象头包含了两种信息:MarkWord和类型指针。MarkWord存放对象本身的运行时状态数据(如HashCode, GC分代年龄、锁状态信息...),类型指针指向它的类型的元数据。对象头在对象的内存布局中细讲。执行对象构造函数
首先递归的执行父类的构造函数,然后收集本类中为实例变量赋值的语句并执行,最后执行构造方法中的语句。
举个例子:这个例子不是重点,不必死磕,没人会在实际工作中写出这样的代码。
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对象。由于多态的规则:被重写的方法使用动态分派,查找方法表,该方法实际是属于子类对象的。因此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
60
2.对象的内存布局
对象在堆中的存储布局划分为三个部分:对象头、实例数据和padding。
2.1对象头
对象头中包含markword和类型指针。
1) markword
markword存储与对象自身定义数据无关的信息,用来表示对象的运行时状态。包括了HashCode,GC年龄,锁状态等信息。在一个32位的虚拟机中,markword用一个32位的bitmap表示,bitmap最后两位存放锁状态信息,如下图。
- 普通状态下,状态为01,存储hashcode,分代年龄,偏向锁状态为0。
- 偏向锁状态下,状态为01,存储持有偏向锁的线程和重入次数,分代年龄,偏向锁状态为1。此时hashcode没了,但是,hashcode可以通过Object的hashcode()方法计算出来,只要没有重写该方法,那么得到的哈希码始终是一致的。
- 轻量级锁,状态为00。通过cas方式将对象的markword信息原子性地交换到了持有该对象锁的线程中,存储在lockRecord内,并同时将lockRecord的指针存放在对象头Markword的前30位。
- 重量级锁状态下,前30位存放指向锁控制器Monitor的指针,锁状态为10.
- 对象被标记为待回收状态时,最后两位状态为11.
2)类型指针
指向类型元数据,从而可以通过对象来访问到它的类型信息。
2.2 实例数据
实例数据中存放了对象的字段信息。无论是从父类继承的,还是在子类中定义的,都保存在实例数据中。按照一定顺序存放,在满足这个顺序的条件下,父类定义的字段又会出现在子类定义的变量之前。
2.3填充
如果对象的实例数据占用空间不是8的整数倍,则填充0值让对象的占用空间位8的整数倍。
3.对象的访问定位
常见的有两种方式,句柄访问和直接指针访问。
使用句柄访问的话,对象的引用(如zhangsan),指向的是句柄池中的某个句柄,该句柄存放了指向实际实例对象的指针和指向数据类型的指针,盗张图说明下。其好处是,当对象被移动的时候(比如垃圾回收时,整理内存空间需要大量移动对象),不需要频繁的修改引用,只需要修改句柄中实例数据指针。(这和修改引用的指针有啥区别吗?)
通过指针访问,则是对象的引用直接指向了该对象。其好处是,通过引用访问对象时,不需要多一次的指针定位,使得访问速度更快。