JVM类加载机制(一)

郭朝 2020-05-01

类的加载、连接、初始化

在Java代码中,类型的生命周期分为五个步骤:

  1. 加载
  2. 连接
  3. 初始化
  4. 使用
  5. 卸载

Java作为编译型语言,与其他编译型语言不同的是,很多编译型语言类型的加载、连接、初始化都是在编译阶段,而Java是在程序运行期间完成的,这位我们提供了更大的灵活性,比如我们可以在Java运行期间生成一个新的类型,这就是动态代理技术,著名的Spring、Hibernate框架都使用这项技术。下面,我们来了解下这五个过程:

  1. 加载:最常见的加载,就是将存储在磁盘上已经编译好的class文件,加载到内存里面。
  2. 连接:连接过程又分为三个阶段:
    • 验证:对字节码的正确性进行校验,虽然字节码一般是编译后生成的,但依旧是一份二进制文件,依然可以用人为的方式去篡改甚至破坏原先的字节码内容。
    • 准备:为类的静态变量分配内存,并将其初始化为默认值。如int类型的静态变量默认值为0,boolean类型的静态变量为false,对象的静态变量为null。
    • 解析:将类与类之间的符号引用,转换为直接引用,即目标类在内存中的地址引用。
  3. 初始化:对类里面的一些静态变量赋值。连接阶段的准备仅仅是为类中的静态变量分配内存,但并没有将程序员赋予变量的默认值分配给变量,此过程才是将程序员分配给变量的默认值分配给变量。
  4. 使用:程序通过类来创建对象、调用类的方法。
  5. 卸载:class既然能加载到内存,也可以在内存中被销毁掉,类一旦被卸载就不能通过类来创建对象了,不过我们还是能重新加载类,再通过类来创建对象。

Java程序对类的使用方式可分为两种:

  • 主动使用
  • 被动使用

所有Java虚拟机实现必须在每个类或接口被Java程序“首次主动使用”时才初始化它们。如果是被动使用,就会不会初始化,而首次主动使用,也代表着是第一次使用的时候才初始化,后续即便是第二次或者第N次主动使用,也不会初始化,初始化行为只执行一次。下面我们来看看,何为主动何为被动?

主动使用:

  • 创建类的实例。
  • 访问某个类或接口的静态变量,或者对该静态变量赋值。
  • 调用类的静态方法。
  • 反射,如:Class.forName("com.test.Test")。
  • 初始化一个类的子类。
  • Java虚拟机启动时被标明为启动类的类(包含main方法的类),即程序的入口。
  • JDK1.7开始提供的动态语言支持:java.lang.invoke.MethodHandle实例的解析结果REF_getStatic、REF_putStatic、Ref_invokeStatic句柄对应的类没有初始化,则初始化。

上面第二种情况和第三种情况在字节码的层面上可以划分为一种情况,调用类静态变量的Java字节码助记符为getstatic,赋值为putstatic,调用类的静态方法为invokestatic。

除了以上七种情况,其他使用Java类的方式都被看作是对类的被动使用,不会导致类的初始化。但是,不导致类的初始化并不意味着不加载这个类,也许会对类加载并进行连接,但能确定不会导致初始化行为的发生。

类的加载

类的加载指的是将类的class文件中的二进制数据读入到内存,将其放在运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象(规范并未说明Class对象位于哪里,HotSpot虚拟机将其放在了方法区中)用来封装类在方法区的数据结构。

加载class文件的方式:

  • 从本地系统中直接加载
  • 通过网络下载class文件
  • 从zip、jar等归档文件中加载class文件
  • 从数据库中提取class文件
  • 将Java源文件动态编译为class文件

下面,我们用几个例子来验证我们上面所阐述的理论。

我们先来看一个例子,访问某个类的静态变量,会对类进行初始化:

package com.leolin.jvm;

public class MyTest1 {
    public static void main(String[] args) {
        System.out.println(MyChild1.str);
    }
}

class MyParent1 {
    public static String str = "hello world";

    static {
        System.out.println("MyParent1 static block");
    }
}

class MyChild1 extends MyParent1 {
    static {
        System.out.println("MyChild1 static block");
    }
}

运行结果:

MyParent1 static block
hello world

我们通过子类访问父类的静态变量,结果父类的静态代码块执行,而子类没有。对于静态字段来说,只有直接定义了该字段的类才会被初始化,虽然我们用MyChild1去访问,但这并不算对MyChild1的主动使用,所以静态代码块没有执行。

既然MyChild1没有被初始化,那有没有被加载呢?这里我们需要额外介绍一下JVM参数的使用方式。JVM的参数的使用方式如下:

  • -XX:+<option>,表示开启option选项。
  • -XX:-<option>,表示关闭option选项。
  • -XX:<option>=value,表示将option的值设置为value,比如设置堆空间大小。

追踪类加载信息的TraceClassLoading默认是关闭的,为了追踪类加载信息,我们要将它打开,于是我们配置JVM参数-XX:+TraceClassLoading后,重新执行上面的程序:

……
[Loaded com.leolin.jvm.MyTest1 from file:/D:/F/work/java_space/jvm-lecture/target/classes/]
……
[Loaded com.leolin.jvm.MyParent1 from file:/D:/F/work/java_space/jvm-lecture/target/classes/]
[Loaded com.leolin.jvm.MyChild1 from file:/D:/F/work/java_space/jvm-lecture/target/classes/]
MyParent1 static block
hello world
……

可以看到,JVM加载了MyChild1,只是没有初始化而已。

现在,我们修改MyTest1.java中的代码如下:

public class MyTest1 {
    public static void main(String[] args) {
        System.out.println(MyChild1.str2);
    }
}

class MyParent1 {
    public static String str = "hello world";

    static {
        System.out.println("MyParent1 static block");
    }
}

class MyChild1 extends MyParent1 {
    public static String str2 = "hello";

    static {
        System.out.println("MyChild1 static block");
    }
}

修改后的代码比较中规中矩,没有通过子类访问父类的的静态变量,我们运行程序,输出如下:

MyParent1 static block
MyChild1 static block
hello

MyParent1和MyChild1的静态代码块先后输出,既然我们引用了MyChild1的str2,那么对MyChild1的初始化是必然的,但子类的初始化,也要求父类初始化完毕。

我们修改MyChild1的代码,将str2从静态变量改为静态常量:

public static final String str2 = "hello";

重新运行程序,输出如下:

hello

仅仅是新增了一个常量的声明,MyChild1和MyParent1的静态代码块都没执行,这是因为如果在编译阶段,常量的值可以确认的话,常量会被存放到调用常量的方法所对应的类的常量池中,即MyChild1的hello会被存放到MyTest1的常量池中,之后MyTest1和MyChild1旧没有关系了,于是就没有MyTest1访问MyChild1的常量,不会触发常量类的初始化。甚至,我们可以将MyChild1和MyParent1对应的class文件删除,MyTest1依旧可以正常运行。

我们反编译MyTest1.class文件,输出如下:

D:\F\java_space\jvm-lecture\target\classes\com\leolin\jvm>javap -c MyTest1.class
Compiled from "MyTest1.java"
public class com.leolin.jvm.MyTest1 {
  public com.leolin.jvm.MyTest1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String hello
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}

我们专注主程序Code下的代码第零行的getstatic字节码助记符正如我们之前所说,获取System类下的静态变量IO流,用于打印常量;第三行ldc助记符,这个助记符表明原先的MyChild1.str2被替换为hello,ldc表示将int、float、String类型的常量值从常量池中推送至栈顶。

我们再来下面这段代码: 

package com.leolin.jvm;

public class MyTest2 {
    public static void main(String[] args) {
        System.out.println(MyParent2.str);
        System.out.println(MyParent2.i);
        System.out.println(MyParent2.j);
        System.out.println(MyParent2.s);
    }
}

class MyParent2 {
    public static final String str = "hello world";
    public static final int i = 500;
    public static final int j = 3;
    public static final short s = 7;

    static {
        System.out.println("MyParent2 static block");
    }
}

 

由于MyTest2引用的都是MyParent2的常量,所以MyParent2的静态代码块不会打印,而MyParent2的常量都是编译期可确定的,所以引用的常量会被放到MyTest2的常量池中,这里就不再输出运行了。我们重点来看看MyTest2反编译之后的结果:

D:\F\java_space\jvm-lecture\target\classes\com\leolin\jvm>javap -c MyTest2.class
Compiled from "MyTest2.java"
public class com.leolin.jvm.MyTest2 {
  public com.leolin.jvm.MyTest2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #4                  // String hello world
       5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      11: sipush        500
      14: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
      17: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      20: iconst_3
      21: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
      24: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      27: bipush        7
      29: invokevirtual #6                  // Method java/io/PrintStream.println:(I)V
      32: return
}

  

上面的代码不单单出现了我们之前所说的ldc(第三行),还出现了sipush(第11行)、iconst_3(第20行)、bipush(第27行)。我们来说一说这几个助记符的作用:

  • sipush:表示将一个短整型值(-32768-32369)推送至栈顶。
  • iconst_3:表示将int型的3推送至栈顶,JVM的字节码对数值-1到5做了优化,当发现需要推送的数值介于-1到5之间,会使用iconst_m1(-1)到iconst_5的字节码。
  • bipush:表示将单字节(-128-127)的常量值推送到栈顶。

我们来看看下面一个例子,如果一个静态常量的值,在编译期间无法确定,那么调用静态常量的类,是否会将其常量值纳入到自身的常量池中。

package com.leolin.jvm;

import java.util.UUID;

public class MyTest3 {
    public static void main(String[] args) {
        System.out.println(MyParent3.str);
    }
}

class MyParent3 {
    public static final String str = UUID.randomUUID().toString();

    static {
        System.out.println("MyParent3 static code");
    }
}

运行代码,有如下输出:

MyParent3 static code
ed5036b9-b1e3-42b5-a2f3-aa4e5682a7d4

可以看到,MyParent3的静态代码块输出了,由此我们可以得出结论,当一个常量的值不能在编译期确定,那么它的值就不会被放到调用它的类的常量池中,当程序运行时,会主动使用这个常量所在的类,从而导致这类的初始化。

相关推荐