HotSpot虚拟机对象的创建

引入

 在上一篇中,我们已经了解到类是如何加载到内存中去的。上一篇中曾聊到过这样的一个问题:类加载和对象实例化有什么区别和联系?
 在上一篇中,我是这样来描述的:

  1. 如果对Java有一些了解,你就应该知道类可以分为两个大的部分:类本身和类的实例。类加载机制加载的是类本身,而类的实例化是针对的类的实例。
  2. 一个类只会被加载一次,所以在方法区中只有一个java.lang.Class对象能代表当前类;而一个类的实例可以有多个,实例对象基本上都是在堆中分配的内存。
  3. 类的实例化必须依赖于类加载,在实例化时必须是被jvm认可的类型,而被jvm认可就是类的加载。类的实例化干的第一件事就是检查当前类是不是已经被加载过了,如果没有加载,则应先加载类。

 这一篇我们就来看看类在实例化的时候到底干了些什么。相信通过这部分内容,能对对象的实例化有一个初步的认识,而通过这两篇的内容能对类在虚拟机中是如何被使用的有一个比较全面的认知。

 请注意,本篇文章的内容是基于最常用的HotSpot虚拟机和Java堆为例展开的。

对象的创建过程

 众所周知,Java是一门面向对象的语言,在程序运行期间,无时无刻都有对象被创建出来。我们都知道在应用程序中可以通过new运算符来创建新的对象,那么在虚拟机中又是如何运转的呢?如果你对对象创建有一些了解,可能会说:虚拟机会自动调用构造函数来产生对象。那么,虚拟机又是如何调用你的构造函数的呢?一个对象真的是在构造函数中被创建的吗?

 注意,本篇内容讨论的对象并不包括数组和Class对象。

 总的来说,对象的创建应该包含这样的几个大步骤:类加载检查、分配空间、初始化零值、设置对象头和执行构造函数。下面我们将针对这些步骤一个一个的进行分析,每一个步骤到底干了什么。

类加载检查

 简而言之,虚拟机如果遇到一条字节码为new的指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用。
 并且检查这个符号引用代表的类是否已经经过类加载阶段(被加载、解析和初始化过)。如果没有,则应先进行类加载,这是对象实例化的硬性前提。

 这一步很好理解,说白了就是判断我们需要实例化的类是不是已经被加载进内存了。在聊类加载机制的时候,我们有提到这样的内容:每一个类被加载后都会在方法区中创建一个java.lang.Class对象。这句话实际上是一个高度概括后的总结,忽略了很多的细节。
 针对上面提到的细节,这里补充一点内容:虚拟机在执行类加载任务后,关于这个类的定义最终会被放到方法区中的运行时常量池中去。注意在这里我用了“最终”一词,在类加载的过程中,关于这个类的定义是放在方法区的Class文件常量表中,在初始化完成后,虚拟机才会把这个类的定义搬到方法区的运行时常量池中。

分配空间

 在类加载检查通过后,虚拟机开始为新生对象分配空间;
 事实上,对象所需内存空间的大小在类加载完成后便已经确定了。

 分配空间很好理解,就是把一块空闲的空间从堆空间中划分出来,就像切蛋糕一样,把一块切好的蛋糕分给你,你就可以吃这块蛋糕了。
 分配空间有两种方式:

  1. 指针碰撞:假设Java堆中的内存是规整的,所有空闲的在一边,所有被使用的在另一边,中间有一个临界指针,这个时候分配内存就只需要把指针向着空闲的那边移动要分配空间大小的距离就可以了;
  2. 空闲列表:如果Java堆中的内存并不规整,被用的和空闲的混杂在一起,就只能维护一个列表,列表上记录下哪些内存是空闲的,这个时候分配内存就是在列表中找一块足够大的区域进行分配,分配完后需要维护列表上的记录;

 这两种分配方式各有优势和缺点,虚拟机会根据不同的垃圾回收机制采用相应的分配方式。具体的内容在虚拟机垃圾回收机制进行详细分析。
关于虚拟机GC参见:此处应该插眼。

初始化零值

 在内存分配之后,虚拟机会把部分内存空间进行初始化为零值。

 注意"部分"一词,这里的部分指的是除对象头以外的空间。接下来会谈到这一块,先有这么个概念就行。
 如果你还有印象的话,我们在类加载的准备阶段也提到了给刚分配的内存初始化对应类型的零值。在这里,其实也是一样的,主要作用是为了保证对象的实例字段(在类加载的准备阶段则是为了保证类变量)可以不用赋初始值就直接使用,这种情况下访问到这些字段的值就是对应数据类型的零值。

设置对象头

 给对象的分配的空间中,有一部分是对象头(Header),这一部分存储了对象的相关信息,但不是对象的实例数据;
 Header中主要包括有:对象的所属类、对象的hashcode、GC的分代年龄信息等等。

 这里你应该知道为什么初始化零值的时候为什么要排除对象头的空间,因为对象头的初始化有单独的豪华包间,不需要在上一步初始化。
 设置对象头这部分内容不理解没关系,等到我们分析了对象的内存布局再回过头来看这块就明白了。

执行()方法

 一个对象的对象头被初始化后,虚拟机就算干完了他应该的事情:创建了一个对象,分配了空间,实例数据也有了初始值。但从我们的视角来看,他并没有,因为由我们分配的任务还没有干呀!
 所以,还有最后的一个步骤,按照我们的意愿对新生对象进行初始化。

 执行()方法:包含非静态成员变量的赋值、非静态代码块、构造函数三块。

()方法并不等同于我们的构造函数,它包含了我们对非静态成员变量(以下简称实例变量)的赋值、非静态代码块、构造函数几者的结合。
 这三块内容的执行顺序为:

  1. 非成员变量初始化
  2. 非静态代码块
  3. 构造函数

 并且和类加载过程相似,如果一个类继承自另一个类,那么在实例化这个类时会先执行父类的()方法。如下的代码片段所示:

    // 父类
    public class Parent {
        public int parentValue = 10;
        {
            System.out.println("Parent类的代码块开始...");
            System.out.println("Parent类实例的parentValue第一次 = " + parentValue);
            parentValue = 20;
            System.out.println("Parent类实例的parentValue第二次 = " + parentValue);
            System.out.println("Parent类的代码块结束...");
        }
        public Parent(){
            System.out.println("Parent类的构造方法...");
        }
    }
    // 子类
    public class Sub extends Parent {
        public int value = parentValue;
        {
            System.out.println("Sub类的代码块开始...");
            System.out.println("Sub类实例的value第一次 = " + value);
            System.out.println("Sub类的代码块结束...");
        }
        public Sub(){
            System.out.println("Sub类的构造方法...");
        }
    }
    // 入口
    public class ClinitTest {
        public static void main(String[] args){
            Sub sub = new Sub();
        }
    }
    /*******************结果********************/
    Parent类的代码块开始...
    Parent类实例的parentValue第一次 = 10
    Parent类实例的parentValue第二次 = 20
    Parent类的代码块结束...
    Parent类的构造方法...
    Sub类的代码块开始...
    Sub类实例的value第一次 = 20
    Sub类的代码块结束...
    Sub类的构造方法...
    /*******************解析********************/
    // 在main方法中,我们new了一个Sub类,所以肯定会去实例化Sub类
    // Sub类继承自Parent类,所以在实例化Sub之前会先实例化Parent类
    // 关于执行顺序,可自行验证

 在上一篇中我们也提到了类构造器()方法和构造方法()的区别,这里再简单做一下比较。

  1. 这两个方法都是在编译阶段生成的,即在.class文件中有对应的字节码描述;但用途不一样,clinit方法用于初始化类变量,而init方法用于初始化实例变量、执行非静态代码块和执行构造函数;
  2. ()方法一定比()方法先开始执行;

 对于()方法一定比()方法先开始执行,这个只是表示类的初始化一定会比实例的初始化先开始。但这并不能表明一定是()方法执行完毕后才会执行()。不信你看下面的代码片段:

    public class Com {
        // 类变量
        public static int A = 6;
        static {
            System.out.println("静态代码块开始");
            System.out.println("静态代码块中A = " + A);
            Com com = new Com();
            System.out.println("静态代码块结束");
        }
        // 实例变量 
        public int B = 5;
        {
            System.out.println("非静态代码块开始");
            System.out.println("非静态代码块中B = " + B);
            System.out.println("非静态代码块结束");
        }
        public Com() {
            System.out.println("构造函数开始");
            System.out.println("构造函数结束");
        }
    }
    
    public class ClinitTest {
        public static void main(String[] args){
            System.out.println("main方法中 Com.A = " + Com.A);
        }
    }
    // 输出:
    // 静态代码块开始
    // 静态代码块中A = 6
    // 非静态代码块开始
    // 非静态代码块中B = 5
    // 非静态代码块结束
    // 构造函数开始
    // 构造函数结束
    // 静态代码块结束
    // main方法中 Com.A = 6

对象的内存布局

 在HotSpot虚拟机中,对象在堆内存中的存储布局可以按逻辑划分为三块:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头(Header区域)

 这部分是重头戏,要把所有点都分析透彻其实相当繁琐。本篇文章也只会尽可能的说一些常见的点,不会在这里面下很多的功夫。
 总的来说,对象头里面可以分为三个部分:Mark Word、类型指针、数组长度。分别简单认识下。

Mark Word

 Mark Word在32位和64位的虚拟机中分别占用32个bit和64个bit(本章只讨论32位,64位的情况暂不讨论),用于存储对象自身的运行时数据。比如hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向锁的线程ID、对象分代年龄等。

类型指针

 类型指针,32位,虚拟机通过这个指针来确定该对象是哪个类的实例。

数据长度

 简单说,就是虚拟机有可能(当数组的长度不确定时)无法从数组的元数据中获取到数组的大小,所以数组长度要存放在对象头中。数组长度占用4个字节(32)位。

 下面分情况说明在32位的虚拟机中对象头的大小:

 如果对象不是Java数组,那就不需要数组长度区域,所以对象头是Mark Word + 类型指针 = 32bit + 32bit = 64bit(8个字节)
 如果对象是Java数组,那就需要数组长度区域,所以对象头是Mark Word + 类型指针 + 数组长度 = 32bit + 32bit + 32bit = 96bit(12个字节)

 重点关注下MarkWord区域,在对象未被同步锁锁定的情况下,25个bit放hashCode,4bit放该对象的分代年龄,2bit用于存储锁的标志位(00:轻量级锁/自旋锁;01:未被锁或者偏向锁;10:重量级锁;11:GC标记),1bit用于存储锁是否启用偏向锁(0:否;1:是)。如下表所示:

25位 4位 1位 2位
对象的hashCode 对象的分代年龄 是否启用偏向锁:0 锁标志位:01

 对于各种锁状态下的对象内存布局,将在锁升级的时候详细说明。毕竟放到这里来理解实在太繁琐且空洞,容易丢了西瓜捡芝麻。
关于锁的升级过程参见:此处应该插眼。

(一)CMS垃圾回收器真的可以增大GC分代年龄吗?

 答:不能。CMS垃圾回收器的默认GC分代年龄是15,根据对象的内存布局来看,Mark Word区域中给对象记录分代年龄的空间只有4bit,而4bit所能表示的最大值为"1111",15已经是最大值了。

(二)说说成员变量和局部变量的区别?
  1. 成员变量可以被public,private,static等修饰符所修饰,而局部变量不能被访问控制修饰符及static所修饰;但是,成员变量和局部变量都能被final所修饰;
  2. 如果成员变量是使用static修饰的,那么这个成员变量是属于类的(类变量),如果没有使用static修饰,这个成员变量是属于实例的(实例变量)。类信息存在于方法区,对象存在于堆内存,局部变量则存在于栈内存。
  3. 静态成员变量是类的一部分,随着类的卸载而消亡;实例成员变量是对象的一部分,它随着对象的回收而消亡,而局部变量随着方法的调用的结束而自动消失。
  4. 成员变量如果没有被赋初值,则会自动以类型的默认值而赋值(一种情况例外:被 final 修饰的成员变量也必须显式地赋值),而局部变量则不会自动赋值。

扩展区域

扩展区域主体

这是一个没有实现的扩展。


上一篇:聊一聊虚拟机类加载机制吧
上一篇:Java基础之你肯定用过的三个关键字static、super和this

你可能感兴趣的:(HotSpot虚拟机对象的创建)