【JVM第六篇--对象】对象的实例化、内存布局和访问定位
写在前面的话:本文是在观看尚硅谷JVM教程后,整理的学习笔记。其观看地址如下:尚硅谷2020最新版宋红康JVM教程
一、对象的实例化
在平常写代码的过程中,我们用class
关键字定义的类只是一个类的模板,并没有产生类的对象,也没有分配内存。想要分配内存产生类对象,就要使用到一些创建对象的方式,比如常见的new
关键字,虚拟机遇到new
关键字,就会在内存中分配此类的内存空间,有了内存空间就可以往里面放定义好的数据并可以进行方法的调用,这就是对象的实例化。
创建对象的方式
(1)、new 的方式
①new X();
是最常见的方式,如下
Test t1 = new Test(); Test t2 = new Test("测试");
②X类的静态方法:
在单例模式中,通过调用类的静态方法来创建对象。一般这个类的静态方法是只允许创建一个对象实例。
③xBuilder/xFactory
的静态方法:
在工厂模式中,通过调用工厂类的静态方法来生产对象实例。
(2)、Class的newInstance()方法
使用反射的方式来创建对象,并且要使用该方式创建对象,必须保证该类已加载并已连接。该方式也只能调用无参的构造,其权限必须为public
。在JDK9中,该方式已被弃用,改用方式(3)代替。
代码示例如下,
public static void main(String[] args) throws Exception { String className = "com.fengjian.www.Test"; Class clasz = Class.forName(className); Test t = (Test) clasz.newInstance(); System.out.println(t); }
首先通过Class.forName()
方法动态的加载类的Class类对象(关于Class类对象后面会详细介绍),
然后通过newInstance()
方法获得Test
类的对象。
Class类位于Java.lang
包下。
(3)、Constructor的newInstance()方法
也是通过反射的方式来创建对象,可以使用空参或带参的构造器,对权限无要求。
public static void main(String[] args) throws Exception { Constructor<Test> constructor; try { constructor = Test.class.getConstructor(); Test t = constructor.newInstance(); System.out.println(t); } catch (Exception){ e.printStackTrace(); } }
类Constructor也有newInstance
方法,Class是通过类来创建对象,而Constructor则是通过构造器。
Constructor类位于java.lang.reflect
包下。
(4)、clone()的方式
不调用任何的构造器,但当前类需要实现cloneable
接口,并手动实现clone()
方法,它的作用是创建一个对象的副本。
使用示例如下,
public static void main(String[] args) throws Exception { Test t1 = new Test(); System.out.println(t1); Test t2 = (Test) t1.clone(); System.out.println(t2); }
Test类如下,
/** * 当前类需要自己实现Cloneable接口,并自己编写clone()方法 */ public class Test implements Cloneable{ public Test(){ } //需要自己实现clone()方法 public Object clone(){ return null; } }
(5)、反序列化的方式
使用该方式,可以从文件中,网络中获取一个对象的二进制流。
示例如下,
public static void main(String[] args) throws Exception { String filePath = "sample.txt";//序列化的路径 Test t1 = new Test("测试"); try { //t1开始序列化 FileOutputStream fileOutputStream = new FileOutputStream(filePath); ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream); outputStream.writeObject(t1); outputStream.flush(); outputStream.close(); //t2开始反序列化 FileInputStream fileInputStream = new FileInputStream(filePath); ObjectInputStream inputStream = new ObjectInputStream(fileInputStream); Test t2 = (Test) inputStream.readObject(); inputStream.close(); System.out.println(t2.getName()); } catch (Exception ee) { ee.printStackTrace(); } }
首先我们要对Test类实现Serializable
接口。然后开始序列化数据。最后得到反序列化的对象。
(6)、第三方库创建:如Objenesis
上述代码来自:愚公要移山《 java创建对象的过程详解(从内存角度分析)》
二、创建对象的步骤
1、判断对象对应的类是否已加载、连接、初始化
当虚拟机遇到一条new
指令(指字节码指令),首先会先去检查这个指令的参数能否在MetaSpace的运行时常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已被加载、解析和初始化(即判断类的元数据是否存在于方法区中)。
如果没有存在,那么在双亲委派模型下,使用当前类加载器以classloader+包名+类名为key进行查找对应的.class文件。如果没有找到文件,则抛出classNotFoundException
异常。如果找到了,则进行类加载,并生成对应的Class类对象。
这一步骤主要涉及类的加载过程,可以看看我之前的笔记【JVM第一篇--类加载机制】类加载过程
流程图如下,
注意到一点,在这一个步骤中,如果进行了类加载(包括已加载和未加载需要加载的情况),那么最终结果都是生成一个Class类对象。
什么是Class类对象?
1.1 Class类对象
有这样两个概念:Class类对象和对象实例。
对象实例:Java中的一个类,由这个类的构造器 new
出来的对象就是对象实例。我们平常说的实例对象、对象实例,或者X类对象,都是同一个意思,都是指某个类实例化出来的对象。
Class类对象:Class类是一个实际存在的类,类名就叫Class(其含义不是我们常见的关键字class
)。这个类位于java.lang
包下,用于记录每一个类的类型信息。虚拟机会为每一个类(类或接口)都生成一个Class类对象,这个Class类对象就是Class这个类实例化出来的一个对象。每一个Class类对象都保存着虚拟机运行时,所对应的类的所有信息(比如类型,属性,方法,所在包名等等)。
每一个Java中的类,首先先会被编译成.class文件,当这个文件被加载到虚拟机中,虚拟机则会为对应的类创建一个Class类的对象实例,用于存储关于这个类的信息。要注意,Class类的构造方法是私有的,所以我们在开发中不能显示的去调用并生成Class类对象,故只有虚拟机才可以生成Class类的对象实例。 同时,Class类对象是在运行时提供某个对象的类型信息,故只在运行时才能通过Class类来获取对应的类的信息。
当遇到new
指令创建对象时,会去获取这个已经创建好的Class类对象的信息,然后根据这些信息创建对应的对象实例。这也意味着,如果没有这个Class类对象,我们就无法实现类的实例化了。Class类对象跟其他类对象一样都存放在堆区。一个类的所有实例对象共同拥有唯一的一个Class类对象。
Class类如此重要,我们要如何获取Class类呢?有三种方式:
(1)、Class.forName("类名");通过类名字符串获取Class类对象。
(2)、通过对象调用getClass()获取该类的Class类对象。
(3)、通过类直接获取Class类对象。
代码如下,
/** * 第一种方式,使用泛型是由于不确定对象类型 */ Class<?> person = Class.forName("com.fengjian.www.Person"); /** * 第二种方式, * Class<? extend Person>: * 意思是设定其上限类型为Person, * 因为第二种方式是根据已知类型的对象来获取的, * 故其Class类对象的类型可以是Person或其子类 */ Person person = new Person(); Class<? extend Person> person_two = person.getClass(); /** * 第三种方式, * 直接根据类来获取Class类对象,该方式效率最高,也最常见 */ Class<Person> person = Person.class;
当类加载完成,且为一个类在虚拟机中创建Class类对象后,则进入创建对象的第2步,为对象分配内存。
2、为对象分配内存
(1)、首先计算对象占用空间的大小,接着就在堆中划分一块内存给新对象,如果实例成员变量是引用变量,仅分配引用变量空间即可(4个字节)。实际一个对象的大小在一步已经确定了。
(2)、在分配内存的过程中,需要先看堆内存是否规整。
如果堆内存是规整的,那么虚拟机将采用 指针碰撞法(Bumo The Pointer)来为对象分配内存。
指针碰撞法:所用过的内存放一边,空闲的内存放在另一边,中间放一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的虚拟机将采用这种分配方式。一般使用带有整理(compact)过程的收集器会使用指针碰撞。
(3)、如果内存不是规整的。已使用的内存和未使用的内存相互交错,那么虚拟机交采用的是空闲列表法(Tree List)来为对象分配内存。
空闲列表法:虚拟机维护一个列表,记录哪些内存块是可以使用的。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表的内容。这种分配方式被称为“空闲列表发法”。
注:选择哪种分配方式由Java堆是否规整来决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定。
(4)、处理并发安全问题
因为堆空间是线程共享的,如果两个线程在创建对象过程中访问了同一块堆空间,则容易造成并发的安全问题。
解决的具体策略是:
①采用CAS失败重试,区域加锁保证更新的原子性。
CAS是乐观锁的一种实现,其原理是:获取当前值进行计算。如果当前值没有改变,表示线程没有被占用,直接更新成功,否则进行重试,或者返回失败。它是Java并发工具包中lock-free的基础代码,依赖底层的CPU提供的特定指令实现。
②为每一个线程预先分配一块TLAB(本地线程分配缓冲)
通过
-XX:+/-UseTLAB
来决定是否启用该设置,默认为开启。
3、对象属性的初始化
在已经分配了的对象内存空间中,为对象的属性进行一个默认的初始化。即将对象的所有属性设为默认值(一般是零值),以保证对象实例字段在不赋值的情况下依然可以直接使用。
4、设置对象头
在对象分配到内存后,虚拟机会设置对象的一些必要的信息。
如类的元数据信息(这个对象属于哪个类),对象的哈希码(HashCode)、对象的GC信息和锁信息等等。
对象头分为两部分:Mark Word(运行时元数据)和类型指针。
①Mark Word:用于存储对象自身的运行时数据信息,如上述的哈希码、GC分代年龄、线程持有的锁等。
②类型指针:类型指针指向类的元数据,虚拟机通过这个指针确定该类在哪个变量中。
注:对象头中的信息是关于对象本身的一些信息的,并不是我们在类中定义的数据,对象头中的信息主要是用于JVM对对象的管理。
5、执行init方法进行对象的初始化
init方法实际上即为字节码文件 < init >() 方法,又称为实例构造器。
它由非静态变量、非静态代码块以及对应的构造器组成。
< init >()方法可以重载多个,类有几个构造器就有几个< init >()方法。
< init >()方法中的代码顺序为:==①静态变量②静态代码块③普通变量④普通代码块⑤构造器==
代码如下,
public class CodeOrder { //静态变量 public static String staticField = "静态变量"; //普通变量 public String field = "==普通变量=="; // 静态代码块 static { /** * 可以执行, * 因为静就态变量staticField在第1步《类加载》的准备阶段就已分配内存空间, * 并在类加载的初始化阶段被赋予给定值, * 而静态代码块static{}也是在类加载的初始化阶段执行的 * 两者执行的先后顺序看源代码的书写顺序 */ System.out.println( staticField ); System.out.println( "静态初始化块" ); /** * 报错, * 因为在静态代码块执行时普通变量还没进行初始化(分配内存空间) */ // System.out.println( field ); } //普通代码块 { /** * 可以执行, * 因为普通变量field在第3步《对象属性初始化》时, * 就已被分配内存空间并赋予默认值; * 而在init方法执行时,普通变量file也被赋予给定值, * 普通代码块则也是在init方法执行时被调用, * 两者执行的先后顺序看源代码的书写顺序 */ System.out.println( field ); System.out.println( "==普通代码块==" ); } // 构造方法 public CodeOrder() { System.out.println("构造器"); } public static void main( String[] args ){ new CodeOrder(); } }
代码执行结果为:
静态变量 静态初始化块 ==普通变量== ==普通代码块== 构造器
由此可得出执行顺序如下:①父类变量初始化②父类代码块③父类构造器④子类变量初始化⑤子类代码块⑥子类构造器。
图示如下,
==虚拟机执行init方法,以进行初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。==
执行完initi方法,把对象按照我们的意图进行初始化,这样一个真正可用的对象才算完全被创建出来。
实际上,从虚拟机角度看,在执行init方法前,一个新的对象已经产生了。因为虚拟机已经给该对象分配好了内存空间并设置好了必要的信息(存储在对象头),而对象的属性也已分配好内存空间(设置了默认值)。
但从Java程序的角度看,对象的创建才刚刚开始。因为在执行init方法之前,所有的属性都是默认值。而对象需要的其他资源(如引用的其他对象)和状态信息都还没构造好。
小结:对象实例化过程如下: ==①加载类②为对象分配内存③对象属性的默认初始化④设置对象头⑤对象初始化(属性的显示初始化,代码块中的初始化,构造器中的初始化)==
思维导图如下,
三、对象的内存布局
一个新创建的对象,大多放在堆中,但对象在堆内存中的存储是怎样的呢?
对象在堆空间中的布局可划分为三部分:==对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)==
1、对象头
对象头在前面已经简单介绍过了,其包含两部分:运行时元数据(Mark Word)和类型指针
(1)、Mark Word中存储的内容有:①对象的哈希值(HashCode)②GC分代年龄③锁状态标志④线程持有的锁⑤偏向线程ID⑥偏向时间线程。
(2)、类型指针:即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定对象是哪个类的实例。
但并不是所有的虚拟机都必须在对象数据上保存类型指针。
注意:如果对象是一个Java数组,那么对象头还需要记录数据的长度。因为虚拟机可以通过普通对象的元数据信息推断出对象的大小,但无法通过元数据信息推断出数组的大小。
2、实例数据
实例数据是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容,包括从父类继承下来的和本身拥有的字段。
实例数据的存放也有一定的规则:
- 相同宽度的字段总是被分配在一起
- 父类中定义的变量会出现在子类之前。
- 如果HotSpot虚拟机的
+XX:CompactFields
参数为true(默认为true)。子类的窄变量可能插入到父类变量的空隙。
3、对齐填充
这部分并不是必然存在的,也没有特别的含义,仅仅起到占位符的作用。因为HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,如果不够则需要通过对象来进行填充。
有代码如下,
public class Customer { int id = 1001; String name; Account acct; { name = "匿名客户"; } public Customer(){ acct = new Account(); } } class Account{ }
主类代码如下,
public class CustomerTest { public static void main(String[] args) { Customer customer = new Customer(); } }
则其在虚拟机内存中的图示如下,
小结:对象内存布局思维导图如下
四、对象的访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
创建对象的目的方式就是为了使用它,访问对象主要有两种方式:==句柄访问和直接指针(HotSpot)采用==
1、句柄访问
使用句柄访问对象,即虚拟机栈中的局部变量表的reference(引用类型)存储着的引用指向了Java堆中的句柄池,而句柄池中则存放了指向对象实例数据和类型数据的指针。对象实例数据存放于堆中,而类型数据则存放于方法区。
图示如下,
该方式的优劣:
优势:reference中存储稳定句柄地址,对象被移动时(垃圾收集时对象移动很普遍)只会改变句柄中实例数据指针即可,reference本身不需要被改变。
劣势:相对于直接指针访问,多了一道访问流程,故访问速度相对较慢。
2、直接指针访问
使用直接指针访问对象,即虚拟机栈中的局部变量表的reference(引用类型)存储着的引用直接指向了Java堆中的对象实例数据。
图示如下,
该方式的优劣:
- 优势: 访问效率高(Java中对对象的访问很频繁),故直接访问节省了不少时间。
- 劣势:如果对象被修改,那么reference也随之修改。