Java JVM 从程序员的角度看对象初始化过程,变量加载顺序及内存分配

你说的曾经没有我的故事 提交于 2020-01-22 22:49:31

专栏原创出处:github-源笔记文件 github-源码 ,欢迎 Star,转载请附上原文出处链接和本声明。

Java JVM-虚拟机专栏系列笔记,系统性学习可访问个人复盘笔记-技术博客 Java JVM-虚拟机

一、前言

前面我们分析过 从虚拟机的角度看对象的创建与访问

现在我们站在程序员角度,来看一下我们定义的一个个类及类里面的成员变量是怎么初始化的,分别什么时候初始化,以及初始化顺序和内存分配。

公网上的文章写一堆代码打印一些信息进行分析,有些把语句块的加载顺序都下结论了,这种理解对于初学者来说可行,但是随着深入的学习我们应该试着从虚拟机角度去分析整个过程。

本文从字节码及类加载过程结合虚拟机内存模型进行分析,无需进行大量源码打印,愿意验证的朋友可以采用该方法进行更复杂的初始化过程分析。

首先我们统一一下概念:

  • 类变量,表示 static 修饰的成员变量
  • 类常量,表示 static final 修饰的基本数据类型与字符串成员变量
  • 静态语句块,使用 static{} 包起来的语句块
  • 实例变量,表示随着类初始化而初始化的成员变量
  • 实例构造器语句块,使用 {} 包起来的语句块
class Father {
    // 类常量
    public static final String STATIC_FINAL = "static-final-Father";
    // 类变量
    public static String STATIC = "static-Father";
    // 类变量
    public static final String STATIC_METHOD = staticMethod();
    // 静态语句块
    static {
        System.out.println("static{}-Father");
        System.out.println(STATIC_FINAL);
        System.out.println(STATIC);
        System.out.println(STATIC_METHOD);
    }  
    // 类方法(静态方法)
    public static String staticMethod() {
        return "staticMethod";
    }

    // 实例变量
    private String name = "name-Father";
    private Object o = new Object();
    // 实例构造器
    public Father(String name) {
        this.name = name;
        System.out.println("Constructor-Father");
    }
    
    // 实例构造器语句块
    {
        System.out.println(name);
    }
}

二、先下结论

如果在类里定义了类变量(static 修饰的变量),则为类生成一个类构造器 <clinit> 方法用于初始化类变量。同时为对象生成实例构造器 <init> 方法用于初始化对象实例。

在此确认理解了「类构造器」「对象实例构造器(构造函数)」这两个概念。

当我们第一次创建一个类的实例对象时,大体流程如下:

  1. 类加载中的准备阶段

    • 会为类常量(static final 修饰的基本数据类型与字符串成员变量)在常量池分配内存并设值。
    • 同时对类变量初始化(int 设为 0,对象设为 null 等)。
  2. 类加载中的初始化阶段,执行类构造器 <clinit> 方法为类变量赋值。

  3. 此时,类变量初始化完成,类变量在类加载阶段仅初始化一次。(后续创建对象实例时不再初始化了)

  4. 使用 new 关键字创建对象实例时,虚拟机会将声明的实例变量、实例构造器语句块合并为一个 <init> 方法一起执行。

以上过程中如果加载类有父类,则先加载父类。如果 new 创建对象实例时,对象有父类,则先调用父类的构造方法。

三、逐步验证结论

1. 类变量的初始化及赋值

class Father {
    public static final String STATIC_FINAL = "static-final-Father";
    public static String STATIC = "static-Father";
    public static final String STATIC_METHOD = staticMethod();

    public static String staticMethod() {
        return "staticMethod";
    }
}
最终生成的 `<clinit>` 方法字节码:
 0 ldc          #10 将常量 <static-Father> 字符串压入栈顶
 2 putstatic    #11 将栈顶常量 <static-Father> 字符串赋值给 <Father.STATIC>
 5 invokestatic #12 执行静态方法 <Father.staticMethod> 返回字符串
 8 putstatic    #13 将静态方法返回的字符串赋值给 <Father.STATIC_METHOD>
11 return

我们可以发现 STATIC_FINAL 常量并没有在 clinit 方法中,因为在准备阶段已经在常量池分配好了。

但是呢,STATIC_METHOD 我们也声明为 final 了,为什么还在 clinit 方法中赋值了呢?因为赋值的是一个方法调用,需要在类加载的初始化阶段调用一次方法进行赋值(字节码 8 putstatic)。

2. 实例变量的初始化及赋值

class RefObjInit {
    public final String stringFinal = "ref-final-ConstantInit";
    public String string = "ref-ConstantInit";
    public Object o = new Object();
}
最终生成的 `<init>` 方法字节码:
 0 aload_0          // 将 this 压入栈顶
 1 invokespecial #1 // 调用父类(Object.<init>)构造方法
 4 aload_0          // 将 this 压入栈顶
 5 ldc           #2 // 将常量 <ref-final-ConstantInit> 压入栈顶
 7 putfield      #3 // 将常量赋值给 <RefObjInit.stringFinal>
10 aload_0          // 将 this 压入栈顶
11 ldc           #4 // 将常量 <ref-ConstantInit> 压入栈顶
13 putfield      #5 // 将常量赋值给 <RefObjInit.string>
16 aload_0          // 将 this 压入栈顶
17 new           #6 // 创建一个 Object 准备赋值给 o
20 dup          
21 invokespecial #1 
24 putfield      #7 // 创建的 Object 赋值给 o
27 return

我们可以发现该类中未声明任何实例构造器,但是编译器为我们生成的 <init> 方法对上面声明的实例变量进行了赋值操作。

虽然 stringFinal 实例变量声明为 final,但是还是进行一次赋值。

四、关于类成员内存分配

class 文件编译后会生成一个常量池。

常量池中主要存放两大类常量:
字面量和符号引用。字面量比较接近于 Java 语言层面的常量概念,如文本字符串、被声明为 final 的常量值等。符号引用可以理解为类、接口、字段、方法、方法句柄等描述符。

比如说 a.method() ,a 会编译为一个符号引用,在执行方法的时候 a 被替换为真正的实例对象引用。

  • 字符串,分配在字符串常量池(JDK 8 后在字符串常量池分配在堆里)

  • 类常量,如果基础数据类型分配在常量池。如果引用类型引用对象的实例数据分配在堆。

  • 类变量,分配在常量池,如果是引用类型,引用对象的实例数据分配在堆。

  • 实例变量,无论基础数据类型还是引用类型,都分配在堆。

  • 局部变量(方法内部创建的变量)。基础数据类型分配在栈桢。引用类型引用分配在栈桢,引用的实例数据分配在堆上。

实例变量中的基础类型特例:如果在方法内创建了对象,但是经过「方法逃逸」分析后该对象并没有逃逸出方法,则可以在栈桢上直接分配基础类型,用完即毁。

此处声明一个误区

大部分人一般说内存分配时,直接会说基础数据类型分配在栈,对象数据在堆上是有问题的。这种理解是错误的。

class RefInit {
    private int i = 10;
    public RefInit o = new RefInit(); // o 里面的 i 字段在堆里
}

我们可以总结为在未被优化(比如逃逸分析)的概念中,对象的实例数据都是在堆上。对象的引用(指针)数据根据作用域及修饰词的不同,可能会被分配在常量池、栈桢的局部变量表、堆里。

专栏更多文章笔记

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!