Java 何时会触发一个类的初始化

Java 何时会触发一个类的初始化?

  • 使用new关键字创建对象
  • 访问类的静态成员变量 或 对类的静态成员变量进行赋值
  • 调用类的静态方法
  • 反射调用类时,如 Class.forName()
  • 初始化子类时,会先初始化其父类(如果父类还没有进行过初始化的话)
  • 遇到启动类时,如果一个类被标记为启动类(即包含main方法),虚拟机会先初始化这个主类。
  • 实现带有默认方法的接口的类被初始化时(拥有被default关键字修饰的接口方法的类)
  • 使用 JDK7 新加入的动态语言支持时 MethodHandle

虚拟机在何时加载类

关于在什么情况下需要开始类加载的第一个阶段,《Java虚拟机规范》中并没有进行强制约束,留给虚拟机自由发挥。但对于初始化阶段,虚拟机规范则严格规定:当且仅当出现以下六种情况时,必须立即对类进行初始化,而加载、验证、准备自然需要在此之前进行。虚拟机规范中对这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用

1. 遇到指定指令时

在程序执行过程中,遇到 new、getstatic、putstatic、invokestatic 这4条字节码执行时,如果类型没有初始化,则需要先触发其初始化阶段。

new

这没什么好说的,使用new关键字创建对象,肯定会触发该类的初始化。

getstatic 与 putstatic

当访问某个类或接口的静态变量,或对该静态变量进行赋值时,会触发类的初始化。首先来看第一个例子:

// 示例1
public class Demo {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(Bird.a);
    }
}

class Bird {
    
    
    static int a = 2;
    // 在类初始化过程中不仅会执行构造方法,还会执行类的静态代码块
    // 如果静态代码块里的语句被执行,说明类已开始初始化
    static {
    
    
        System.out.println("bird init");
    }
}

执行后会输出:

bird init
2

同样地,如果直接给Bird.a进行赋值,也会触发Bird类的初始化:

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        Bird.a = 2;
    }
}

class Bird {
    
    
    static int a;
    static {
    
    
        System.out.println("bird init");
    }
}

执行后会输出:

bird init

接着再看下面的例子:

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        Bird.a = 2;
    }
}

class Bird {
    
    
    // 与前面的例子不同的是,这里使用 final 修饰
    static final int a = 2;
    static {
    
    
        System.out.println("bird init");
    }
}

执行后不会有输出。

本例中,a不再是一个静态变量,而变成了一个常量,运行代码后发现,并没有触发Bird类的初始化流程。常量在编译阶段会存入到调用这个常量的方法所在类的常量池中本质上,调用类并没有直接引用定义常量的类,因此并不会触发定义常量的类的初始化。即这里已经将常量a=2存入到Demo类的常量池中,这之后,Demo类与Bird类已经没有任何关系,甚至可以直接把Bird类生成的class文件删除,Demo仍然可以正常运行。使用javap命令反编译一下字节码:

// 前面已省略无关部分
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: iconst_2
       4: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
       7: return
}

从反编译后的代码中可以看到:Bird.a已经变成了助记符iconst_2(将int类型2推送至栈顶),和Bird类已经没有任何联系,这也从侧面证明,只有访问类的静态变量才会触发该类的初始化流程,而不是其他类型的变量

关于Java助记符,如果将上面一个示例中的常量修改为不同的值,会生成不同的助记符,比如:

// bipush  20
static int a = 20; 
// 3: sipush        130
static int a = 130
// 3: ldc #4   // int 327670
static int a = 327670;

其中:
iconst_n:将int类型数字n推送至栈顶,n取值0~5
lconst_n:将long类型数字n推送至栈顶,n取值0,1,类似的还有fconst_ndconst_n
bipush:将单字节的常量值(-128~127) 推送至栈顶
sipush:将一个短整类型常量值(-32768~32767) 推送至栈顶
ldc:将intfloatString类型常量值从常量池中推送至栈顶

再看下一个实例:

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(Bird.a);
    }
}

class Bird {
    
    
    static final String a = UUID.randomUUID().toString();
    static {
    
    
        System.out.println("bird init");
    }
}

执行后会输出:

bird init
d01308ed-8b35-484c-b440-04ce3ecb7c0e

在本例中,常量a的值在编译时不能确定,需要进行方法调用,这种情况下,编译后会产生getstatic指令,同样会触发类的初始化,所以才会输出bird init。看下反编译字节码后的代码:

// 已省略部分无关代码
public static void main(java.lang.String[]);
  Code:
    0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    3: getstatic     #3                  // Field com/hicsc/classloader/Bird.a:Ljava/lang/String;
    6: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    9: return

invokestatic

调用类的静态方法时,也会触发该类的初始化。比如:

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        Bird.fly();
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }
    static void fly() {
    
    
        System.out.println("bird fly");
    }
}

执行后会输出:

bird init
bird fly

通过本例可以证明,调用类的静态方法,确实会触发类的初始化。

2. 反射调用时

使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。来看下面的例子:

ublic class Demo {
    
    
    public static void main(String[] args) throws Exception {
    
    
        ClassLoader loader = ClassLoader.getSystemClassLoader();
        Class clazz = loader.loadClass("com.hicsc.classloader.Bird");
        System.out.println(clazz);
        System.out.println("——————");
        clazz = Class.forName("com.hicsc.classloader.Bird");
        System.out.println(clazz);
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }
}

执行后输出结果:

class com.hicsc.classloader.Bird
------------
bird init
class com.hicsc.classloader.Bird

本例中,调用ClassLoader方法load一个类,并不会触发该类的初始化,而使用反射包中的forName方法,则触发了类的初始化。

3. 初始化子类时

当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。比如:

public class Demo {
    
    
    public static void main(String[] args) throws Exception {
    
    
        Pigeon.fly();
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }
}

class Pigeon extends Bird {
    
    
    static {
    
    
        System.out.println("pigeon init");
    }
    static void fly() {
    
    
        System.out.println("pigeon fly");
    }
}

执行后输出:

bird init
pigeon init
pigeon fly

本例中,在main方法调用Pigeon类的静态方法,最先初始化的是父类Bird,然后才是子类Pigeon。因此,在类初始化时,如果发现其父类并未初始化,则会先触发父类的初始化。

对子类调用父类中存在的静态方法,只会触发父类初始化而不会触发子类的初始化。

看下面的例子,可以先猜猜运行结果:

public class Demo {
    
    
    public static void main(String[] args) {
    
    
        Pigeon.fly();
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }
    static void fly() {
    
    
        System.out.println("bird fly");
    }
}

class Pigeon extends Bird {
    
    
    static {
    
    
        System.out.println("pigeon init");
    }
}

输出:

bird init
bird fly

本例中,由于fly方法是定义在父类中,那么方法的拥有者就是父类,因而,使用Pigeno.fly()并不是表示对子类的主动引用,而是表示对父类的主动引用,所以,只会触发父类的初始化。

4. 遇到启动类时

当虚拟机启动时,如果一个类被标记为启动类(即:包含main方法),虚拟机会先初始化这个主类。比如:

public class Demo {
    
    
    static {
    
    
        System.out.println("main init");
    }
    public static void main(String[] args) throws Exception {
    
    
        Bird.fly();
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }
    static void fly() {
    
    
        System.out.println("bird fly");
    }
}

执行后输出:

main init
bird init
bird fly

5. 实现带有默认方法的接口的类被初始化时

当一个接口中定义了 JDK8 新加入的默认方法(被default关键字修饰的接口方法) 时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化

由于接口中没有static{}代码块,怎么判断一个接口是否初始化?来看下面这个例子:

public class Demo {
    
    
    public static void main(String[] args) throws Exception {
    
    
        Pigeon pigeon = new Pigeon();
    }
}

interface Bird {
    
    
    // 如果接口被初始化,那么这句代码一定会执行
    // 那么Intf类的静态代码块一定会被执行
    public static Intf intf = new Intf();
    default void fly() {
    
    
        System.out.println("bird fly");
    }
}

class Pigeon implements Bird {
    
    
    static {
    
    
        System.out.println("pigeon init");
    }
}

class Intf {
    
    
    {
    
    
        System.out.println("interface init");
    }
}

执行后输出:

interface init
pigeon init

可知,接口确实已被初始化,如果把接口中的default方法去掉,那么不会输出interface init,即接口未被初始化。

6. 使用JDK7新加入的动态语言支持时

当使用JDK7新加入的动态类型语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

简单点来说,当初次调用MethodHandle实例时,如果其指向的方法所在类没有进行过初始化,则需要先触发其初始化

什么是动态类型语言:

  • 动态类型语言的关键特性是它的类型检查的主体过程是在运行期进行的,常见的语言比如:JavaScript、PHP、Python等,相对地,在编译期进行类型检查过程的语言,就是静态类型语言,比如 Java 和 C# 等。
  • 简单来说,对于动态类型语言,变量是没有类型的,变量的值才具有类型,在编译时,编译器最多只能确定方法的名称、参数、返回值这些,而不会去确认方法返回的具体类型以及参数类型。
  • 而Java等静态类型语言则不同,你定义了一个整型的变量x,那么x的值也只能是整型,而不能是其他的,编译器在编译过程中就会坚持定义变量的类型与值的类型是否一致,不一致编译就不能通过。因此,「变量无类型而变量值才有类型」是动态类型语言的一个核心特征。

关于MethodHandle与反射的区别,可以参考周志明著「深入理解Java虚拟机」第8.4.3小节,这里引用部分内容,方便理解。

  1. Reflection 和 MethodHandle 机制本质上都是在模拟方法调用,但是 Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。
  2. 反射中的 Method 对象包含了方法签名、描述符以及方法属性列表、执行权限等各种信息,而 MethodHandle 仅包含执行该方法的相关信息,通俗来讲:Reflection 是重量级,而 MethodHandle 是轻量级。

总的来说,反射是为 Java 语言服务的,而 MethodHandle 则可为所有 Java 虚拟机上的语言提供服务。

来看一个简单的示例:

public class Demo {
    
    
    public static void main(String[] args) throws Exception {
    
    
        new Pigeon().fly();
    }
}

class Bird {
    
    
    static {
    
    
        System.out.println("bird init");
    }

    static void fly() {
    
    
        System.out.println("bird fly");
    }
}

class Pigeon {
    
    
    void fly() {
    
    
        try {
    
    
            MethodHandles.Lookup lookup = MethodHandles.lookup();
            // MethodType.methodType 方法的第一个参数是返回值
            // 然后按照目标方法接收的参数的顺序填写参数类型
            // Bird.fly() 方法返回值是空, 没有参数
            MethodType type = MethodType.methodType(void.class);
            MethodHandle handle = lookup.findStatic(Bird.class, "fly", type);
            handle.invoke();
        } catch (Throwable a) {
    
    
            a.printStackTrace();
        }
    }
}

Pigeon类中,使用MethodHandle来调用Bird类中的静态方法fly,按照前面所述,初次调用MethodHandle实例时,如果其指向的方法所在类没有进行过初始化,则需要先触发其初始化。所以,这里一定会执行Bird类中的静态代码块。而最终的运行结果也与我们预计的一致:

bird init
bird fly

虚拟机如何加载类 - 类的加载过程

类的加载全过程包括:加载、验证、准备、解析初始化 5 个阶段,是一个非常复杂的过程。

在这里插入图片描述

在这里插入图片描述

加载 Loading

Loading 阶段主要是找到类的class文件,并把文件中的二进制字节流读取到内存,然后在内存中创建一个java.lang.Class对象。

加载完成后,就进入连接阶段,但需要注意的是,加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序,也就是只有加载阶段开始后,才有可能进入连接阶段。

验证 Verification

验证是连接阶段的首个步骤,其目的是确保被加载的类的正确性,即要确保加载的字节流信息要符合《Java虚拟机规范》的全部约束要求,确保这些信息被当做代码运行后不会危害虚拟机自身的安全。

其实,Java 代码在编译过程中,已经做了很多安全检查工作,比如,不能将一个对象转型为它未实现的类型、不能使用未初始化的变量(赋值除外)、不能跳转到不存在的代码行等等。但 JVM 仍要对这些操作作验证,这是因为 Class 文件并不一定是由 Java 源码编译而来,甚至你都可以通过键盘自己敲出来。如果 JVM 不作校验的话,很可能就会因为加载了错误或有恶意的字节流而导致整个系统受到攻击或崩溃。所以,验证字节码也是 JVM 保护自身的一项必要措施。

整个验证阶段包含对文件格式、元数据、字节码、符号引用等信息的验证

准备 Preparation

这一阶段主要是为类的静态变量分配内存,并将其初始化为默认值。这里有两点需要注意:

  • 仅为类的静态变量分配内存并初始化,并不包含实例变量
  • 初始化为默认值,比如int0,引用类型初始化为null

需要注意的是,准备阶段的主要目的并不是为了初始化,而是为了为静态变量分配内存,然后再填充一个初始值而已。就比如:

// 在准备阶段是把静态类型初始化为 0,即默认值
// 在初始化阶段才会把 a 的值赋为 1
public static int a = 1;

来看一个实例加深印象,可以先考虑一下运行结果。

public class StaticVariableLoadOrder {
    
    
    public static void main(String[] args) {
    
    
        Singleton singleton = Singleton.getInstance();
        System.out.println("counter1:" + Singleton.counter1);
        System.out.println("counter2:" + Singleton.counter2);
    }
}

class Singleton {
    
    

    public static Singleton instance = new Singleton();

    private Singleton() {
    
    
        counter1++;
        counter2++;
        System.out.println("构造方法里:counter1:" + counter1 + ", counter2:" + counter2);
    }

    public static int counter1;
    public static int counter2 = 0;

    public static Singleton getInstance() {
    
    
        return instance;
    }
}

其运行结果是:

构造方法里:counter1:1, counter2:1
counter1:1
counter2:0

在准备阶段counter1counter2都被初始化为默认值0,因此,在构造方法中自增后,它们的值都变为1,然后继续执行初始化,仅为counter2赋值为0counter1的值不变。

如果你理解了这段代码,再看下面这个例子,想想会输出什么?

// main 方法所在类的代码不变
// 修改了 counter1 的位置,并为其初始化为 1
class Singleton {
    
    
    public static int counter1 = 1;
    public static Singleton instance = new Singleton();

    private Singleton() {
    
    
        counter1++;
        counter2++;
        System.out.println("构造方法里:counter1:" + counter1 + ", counter2:" + counter2);
    }

    public static int counter2 = 0;
    public static Singleton getInstance() {
    
    
        return instance;
    }
}

运行后输出:

构造方法里:counter1:2, counter2:1
counter1:2
counter2:0

counter2并没有任何变化,为什么counter1的值会变成2?其实是因为类在初始化的时候,是按照代码的顺序来的,就比如上面的示例中,为counter1赋值以及执行构造方法都是在初始化阶段执行的,但谁先谁后呢?按照顺序来,因此,在执行构造方法时,counter1已经被赋值为1,执行自增后,自然就变为2了。

解析 Resolution

解析阶段是将常量池类的符号引用替换为直接引用的过程。在编译时,Java 类并不知道所引用的类的实际地址,只能使用符号引用来代替。符号引用存储在class文件的常量池中,比如类和接口的全限定名、类引用、方法引用以及成员变量引用等,如果要使用这些类和方法,就需要把它们转化为 JVM 可以直接获取的内存地址或指针,即直接引用。

因此,解析的动作主要是针对类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这 7 类符号引用进行的。

初始化 Initialization

准备阶段我们只是给静态变量设置了类似0的初值,在这一阶段,则会根据我们的代码逻辑去初始化类变量和其他资源。

更直观的说初始化过程就是执行类构造器<clinit>方法的过程

类的初始化是类加载过程的最后一个步骤,直到这一个步骤,JVM 才真正开始执行类中编写的 Java 代码。初始化完也就差不多是类加载的全过程了,什么时候需要初始化也就是我们最前面讲到的几种情况。

类初始化是懒惰的,不会导致类初始化的情况,也就是前面讲到的被动引用类型,再讲全一点:

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
  • 访问类对象.class不会触发初始化
  • 创建该类的数组不会触发初始化
  • 执行类加载器的 loadClass 方法不会触发初始化
  • Class.forName(反射)的参数2为false时(为true才会初始化)

在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init

1. 类初始化方法:<clinit>()

  • Java 编译器在编译过程中,会自动收集类中所有静态变量赋值语句静态代码块中的语句,将其合并到类构造器<clinit>()方法收集的顺序由源代码文件中出现的顺序决定。类初始化方法一般在类初始化阶段执行。
  • 如果两个类存在父子关系,那么在执行子类的<clinit>()方法之前,会确保父类的方法已执行完毕,因此,父类的静态代码块会优先于子类的静态代码块

例子:

public class ClassDemo {
    
    
    static {
    
    
        i = 20;
    }
    static int i = 10;
    static {
    
    
        i = 30;
    }
   // init 方法收集后里面的代码就是这个,当然你是看不到该方法的
    init() {
    
    
      i = 20;
      i = 10;
      i = 30;
    }
}
  • <clinit>()方法不需要显示调用,类解析完了会立即调用,且父类的<clinit>()永远比子类的先执行,因此在jvm中第一个执行的肯定是Object中的<clinit>()方法。
  • <clinit>()方法不是必须的,如果没有静态代码块和变量赋值就没有
  • 接口也有变量复制操作,因此也会生成<clinit>(),但是只有当父接口中定义的变量被使用时才会初始化。

这里有一点需要特别强调,JVM 会保证一个类的<clinit>()方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其它线程都需要等待,直到<clinit>()方法执行完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那么可能会造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。因此,在实际开发过程中,我们都会强调,不要在类的构造方法中加入过多的业务逻辑,甚至是一些非常耗时的操作。

另外,静态代码块中只能访问定义它之前的变量,定义在它之后的变量可以赋值但不能访问:

class Class{
    
    
    static {
    
    
        c = 2; // 赋值操作可以正常编译通过
        System.out.println(c);//编译器提示 Illegal forward reference,非法向前引用
    }
    static int c = 1;
}

2. 对象初始化方法:init()

  • init()是实例对象自动生成的方法。编译器会按照从上至下的顺序,收集 「类成员变量」赋值语句、普通代码块,最后收集构造函数的代码,最终组成对象初始化方法。对象初始化方法一般在实例化类对象的时候执行。

例子:

public class ClassDemo {
    
    
    int a = 1;
    {
    
    
        a = 2;
        System.out.println(2);
    }
    {
    
    
        b = "b2";
        System.out.println("b2");
    }
    String b = "b1";
    public ClassDemo(int a, String b) {
    
    
        System.out.println("构造器赋值前:"+this.a+" "+this.b);
        this.a = a;
        this.b = b;
    }
    public static void main(String[] args) {
    
    
        ClassDemo demo = new ClassDemo(3, "b3");
        System.out.println("构造结束后:"+demo.a+" "+demo.b);
//        2
//        b2
//        构造器赋值前:2 b1
//        构造结束后:3 b3
    }
}

上面的代码的init()方法实际为:

public init(int a, String b){
    
    
	super(); // 不要忘记在底层还会加上父类的构造方法
	this.a = 1;
	this.a = 2;
	System.out.println(2);
	this.b = "b2";
	System.out.println("b2");
	this.b = "b1";
	System.out.println("构造器赋值前:" + this.a + " " + this.b); // 构造方法在最后
	this.a = a;
	this.b = b;
}

类执行过程小结:

  1. 确定类变量的初始值。在类加载的准备阶段JVM 会为「类变量」初始化默认值,这时候类变量会有一个初始的零值。如果是被 final 修饰的类变量,则直接会被初始成用户想要的值。
  2. 初始化入口方法。当进入类加载的初始化阶段后,JVM 会寻找整个 main 方法入口,从而初始化 main 方法所在的整个类。当需要对一个类进行初始化时,会首先初始化类构造器,之后初始化对象构造器。
  3. 初始化类构造器。JVM 会按顺序收集「类变量」的赋值语句、静态代码块,将它们组成类构造器,最终由 JVM 执行。
  4. 初始化对象构造器。JVM 会按顺序收集「类成员变量」的赋值语句、普通代码块,最后收集构造方法,将它们组成对象构造器,最终由 JVM 执行。

如果在初始化 「类变量」时,类变量是一个其他类的对象引用,那么就先加载对应的类,然后实例化该类对象,再继续初始化其他类变量。


参考:

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/134914637