【Java JVM】实例对象的创建

当我们涉及 Java 编程时, 对象的创建是一个基础而关键的概念。在 Java 中, 一切皆为对象, 而对象的创建方式直接影响代码的结构和性能。
本博客将探讨一下 Java 实例对象的创建过程。

1 创建对象的方法有哪些

在 Java 中如果要创建一个对象, 有哪些方式呢?

  1. 运用 new 关键字创建实例, 这是最常用的创建对象方法
  2. 通过反射, 调用 java.lang.Class 的 newInstance 方法, 相当于调用一个类的无参的构造函数创建对象
  3. 通过反射, 调用 java.lang.reflect.Constructor 类的 newInstance 方法, 支持无参/有参/私有的构造函数
  4. 通过对象的 clone 方法, 对象需要实现 java.lang.Cloneable 接口
  5. 通过反序列化, 对象需要实现 java.io.Serializable
  6. 通过 sun.misc.Unsafe 的 allocateInstance 方法

其中方法 1, 2, 3 本质都是通过类的构造函数创建对象, 就是 Java 的 new 机制。
而方法 4, 5, 6 不会调用构造函数。我们这里只讨论正常的构造函数创建对象的方式。

2 创建的过程

public class Demo {

    public static void main(String[] args) {
        Demo main = new Demo();
    }
}

上面是一个逻辑很简单, 就是通过 new 创建出了一个 Demo 的实例。
从 Java 层面这个对象的创建就完成了, 如何还需要进行深入分析的话, 我们需要进入到字节码的层面了。

对应如何将类文件转为字节码, 可以看一下后面的附录 1。

上面的 Demo 例子转为字节码后如下

Classfile /Users/lcn29/Projects/Demo/src/main/java/io/github/lcn29/Demo.class
  Last modified xxxx年xx月xx日; size 286 bytes
  SHA-256 checksum de9e200e3a5848520480df67e259986c46dca7342bbaf8f1b84b094815e04ee5
  Compiled from "Demo.java"
public class io.github.lcn29.Demo
  minor version: 0
  major version: 65
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #7                          // io/github/lcn29/Demo
  super_class: #2                         // java/lang/Object
  interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Class              #8             // io/github/lcn29/Demo
   #8 = Utf8               io/github/lcn29/Demo
   #9 = Methodref          #7.#3          // io/github/lcn29/Demo."":()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               SourceFile
  #15 = Utf8               Demo.java
{
  public io.github.lcn29.Demo();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #7                  // class io/github/lcn29/Demo
         3: dup
         4: invokespecial #9                  // Method "":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
}
SourceFile: "Demo.java"

备注:
从上面的字节码内容中可以看到很多注释的内容 (// 后面的内容), 在实际的字节码中是不会有后面的注释内容的, 只有一个简单的 指令 #参数 (也可能没这个参数)

后面注释的内容是 javap 为了方便我们阅读, 提前帮我们把 #参数 的内容转换了, 即后面注释的内容就是 #参数 的真正内容。

#参数 的真正内容如何查找的?
这里的 #参数 可以看作是一个坐标, 通过这个指标可以到字节码文件的常量池中获取对应的内容, 即字节码文件中的 Constant pool 项。

比如: #7 在我们的字节码文件的 Constant pool 从中找到的内容是 #8, 同理通过 #8Constant pool 中最终获取到了内容 io/github/lcn29/Demo, 也就是 #7 的内容就是 io/github/lcn29/Demo

OK, 转为字节码后, 我们可以看到 JVM 创建对象的更多步骤。
下面我们就围绕这个字节码过程, 简单梳理一下 JVM 层面创建对象的过程。

2.1 检查类的加载

从 main 方法入手, 我们遇到的第一个字节码

new           #7                  // class io/github/lcn29/Demo

JVM 虚拟机遇到一条 new 指令时, 首先会去检查这个指令的后面参数是否能在运行时常量池中定位到一个类的符号引用, 并且检查这个符号引用代表的类是否已被加载, 解析和初始化过。
如果没有, 那必须先执行相应的类加载过程。

类加载 的过程就不在这里展开了。

所以 new 的是 io/github/lcn29/Demo 这个类, 所以首先需要确保在内存中有这个类存在。

2.2 分配内存

类加载检查通过后, 接下来虚拟机将为新生对象分配内存。

一个对象需要分配内存的就 3 个部分

  1. 对象头 (Object Header) 的大小固定的
  2. 实例数据 (Instance Data) 的大小可以通过类的各个属性的大小计算出来
  3. 对齐填充 (Padding) 只需要在得到前 2 个的大小后, 保证整个对象为 8 个字节的倍数即可

所以一个对象所需内存的大小在类加载完成后便可完全确定, 这时就可以给这个对象分配内存空间。
这个过程实际就是把一块确定大小的内存从 Java 堆中划分出来。

2.2.1 内存分配方式

方式一
如果 Java 堆中内存是绝对规整的, 所有用过的内存都放在一边, 空闲的内存放在另一边, 中间放着一个指针作为分界点的指示器, 那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离, 这种分配方式称为 “指针碰撞 (Bump the Pointer)”

方式二
如果 Java 堆中的内存并不是规整的, 已使用的内存和空闲的内存相互交错, 那就没有办法简单地进行指针碰撞了, 虚拟机就必须维护一个列表, 记录哪些内存块是可用的, 在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录, 这种分配方式称为 “空闲列表 (Free List)”

至于选择哪种分配方式由 Java 堆是否规整决定。  
而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理 (Compact) 功能决定。  
Serial, ParNew 等带压缩整理过程的收集器就使用指针碰撞, 基于 CMS 这种清除算法就使用空闲列表
2.2.2 内存分配的安全问题

对象的创建需要申请内存, 这个过程不是线程安全的。
如果现在正在给对象 A 分配内存, 临界指针/空闲列表的值还未改变, 这时候又要一个对象 B 进行
内存的申请, 那么就导致线程不安全。

为了解决这个问题, 有 2 种方式

  1. 对分配内存空间的动作进行同步处理, 虚拟机是可以通过 CAS 加上失败重试的方式保证更新操作的原子性
  2. 把内存分配的动作按照线程划分在不同的空间之中进行, 每个线程在 Java 堆中预先分配一小块内存, 称为本地线程分配缓冲 (Thread Local Allocation Buffer, TLAB), 哪个线程要分配内存, 就在哪个线程的本地缓冲区中分配, 只有本地缓冲区用完了, 分配新的缓存区时才需要同步锁定。

2.3 空间初始化

内存分配完成后, 虚拟机需要将分配到的内存空间都初始化为默认值 (不包括对象头), 如果使用了 TLAB 的话, 这一项工作也可以提前至 TLAB 分配时顺便进行。
这步操作保证了对象的实例字段在 Java 代码中可以不赋自定义值就可以直接使用, 使程序能访问这些字段的数据类型所对应的初始值。

各种数据类型的初始值:

类型 默认值
boolean false(0)
char \u0000(unicode 编码, 转为十进制就是 0)
byte 0
short 0
int 0
float 0.0f
double 0.0d
long 0L
reference(引用类型) null

2.4 其他必要的设置

JVM 会对这个对象的对象头等相关的属性进行设置, 比如确定是哪个类的实例, 将 klass Pointer 指向对应的 Class, 设置对象的哈希码, 对象的 GC 分代年龄, 偏向锁标识等。

到了这一步, 字节码 new 引起的对象创建就完成。
但是这时创建出来的的对象所有的属性都是默认值, 还是一个未完整的对象的。

2.5 执行 方法

顺着字节码, 下一个 dup, 这个只是单纯的为了更方便地为后面的赋值操作而执行的,
本身不会改变到对象的任何属性, 所以跳过。

下一个 invokespecial #9 (Method “”)V) 字节码。

invokespecial 字节码, 可以先简单看作是调用后面参数指定的方法。

是 JVM 在编译时间, 根据我们的类生成的一个统一的属性初始化方法 (对应了上面的例子的public io.github.lcn29.Demo() 方法)。

举个例子:

public class A {

    private int num = 10;

    private int num2;  

    private int num3;

    {
      this.num2 = 20;
    }

    public Demo() {
        this.num3 = 100;
    } 
}

上面 A 有 3 个属性 num, num2, num3, 它们分别在 3 个地方被赋值了

  1. 声明赋值
  2. 代码块赋值
  3. 构造函数赋值

而编译为字节码后, 编译器会把所有的赋值操作都统一在自己生成的 方法中, 就像下面

public class A {

    private int num;

    private int num2;  

    private int num3;

    public <init>() {
      // 先调用父类的  方法, 确保父类的属性设置完成
      super.<init>();
      // 自己的属性赋值
      this.num = 10;
      this.num2 = 20;
      this.num3 = 100;
    }

    public Demo() {
    }
}

了解完 方法, 我们可以了解到 invokespecial #9 这条字节码指令的效果: 对自己的父类和属性进行真正的赋值。

到了这里, 一个真正完整的实例对象就创建完成。

后面的 astore_1 和 return 都不涉及到对象的情况的处理, 跳过。

至此一个 实例对象的创建就完成。

3 方法和 方法

方法是编译器生成的, 生成的字节码中一般都会有, 但是不一定就会执行。

一般来说 方法是否执行, 由 new 指令后面是否跟随 invokespecial 指令决定。
Java 编译器会在遇到 new 关键字的地方同时生成这 2 条指令, 如果不是通过 new 方式创建的, 则不会有。

在 Java 类的定义中, 除了正常的属性外, 我们还可以再类中定义静态属性, 同理编译器会将静态属性和静态代码块中的属性赋值, 统一到一个 方法中。

方法在我们创建类实例时调用, 而 则是在类加载时执行。

3.1 举个例子加深理解

public class Parent {

   private static int pNum1 = 10;

   private int pNum2 = 10;

   static {
      pNum1 = 11;
   }

   {
      pNum2 = 12;
   }

   public Parent() {
      this.pNum2 = 13;
   }
}

public class Son extends Parent {

   private static int sNum1 = 20;

   private int sNum2 = 20;

   static {
      sNum1 = 21;
   }

   {
      sNum2 = 22;
   }

   public Son() {
      this.sNum2 = 23;
   }
}

当我们创建 Son 的实例的时候, 上面的构造函数, 代码块, 静态代码块的执行顺序是怎么样的?

上面的执行顺序差不多是这样的

  1. Parent 的静态变量赋值
  2. Parent 的静态代码块执行
  3. Son 的静态变量赋值
  4. Son 的静态代码块执行
  5. Parent 的实例变量赋值
  6. Parent 的代码块执行
  7. Parent 的构造函数执行
  8. Son 的实例变量赋值
  9. Son 的代码块执行
  10. Son 的构造函数执行

出现上面的执行顺序, 主要是由 造成的。

  1. 主要针对我们当前类的初始化, 而 主要针对我们当前类的实例的初始化, 而且他的初始会先调用父级的无参 方法。
  2. 这里的初始化指的是类中的属性直接赋值执行, 代码块执行, 构造函数执行, 这三个执行最终会整合到(实例相关的), 或者(静态相关的), 并按照的执行顺序执行
  3. 类加载机制中, 会先加载父类, 再加载子类。

从 new Son() 时, 先加载 Parent 对应的类, 然后调用 Parent 的 方法

  1. 在 Parent 的 方法

1.1 执行 Parent 的属性直接赋值, 给 pNum1 赋值为 10
1.2 执行 Parent 的静态代码块, 给 pNum2 赋值为 11
1.3 静态代码块和直接赋值没有层级关系, 谁在前谁先, 如果这时静态代码块在直接赋值前, 那么先给 pNum2 赋值 (代码块和实例属性也是遵循这个规则)

  1. 调用 Son 的 方法

2.1 执行 Son 的属性直接赋值, 给 sNum1 赋值为 20
2.2 执行 Son 的静态代码块, 给 sNum1 赋值为 21

  1. 执行 Son 的 方法

3.1 第一步会直接调用他的直接父级的 方法, 也就是 Parent 的 方法, 然后调用自身的代码块执行, 再构造函数执行 (Parent 的 还会调用父类的, 这里省略)
3.2 Parent 的 方法, 先执行属性直接赋值, pNum2 赋值为 10
3.3 Parent 的 方法, 执行 Parent 的代码块, pNum2 赋值为 12
3.3 Parent 的 方法, 执行 Parent 的构造函数, pNum2 赋值为 13
3.4 Parent 的 执行完成, 执行 Son 自己的 方法, 直接属性赋值, sNum2 赋值为 20
3.5 Son 的代码块, sNum2 赋值为 22
3.6 Son 的构造函数, sNum3 赋值为 23

从上面的例子, 应该可以区分出 的作用和区别了吧
2 个方法都是编译器, 对我们编写的初始的整合, static 属性赋值和静态代码块整合为 , 实例属性赋值, 代码块和构造方法整合为 , 而且 方法会先调用直接父级的 init 的方法。

到此, 所有的内容就整理完成了。

4 参考

Java对象的创建过程详解

你可能感兴趣的:(#,Java,JVM,Java,JVM)