类加载机制(2):虚拟机类加载过程

0 阅读7分钟

类加载

JVM将描述类的数据从Class文件加载到内存,并且对数据进行校验转换解析初始化,最后形成可以被虚拟机直接使用的Java类型。这个过程被称之为虚拟机的类加载机制

C/C++这些在编译的时候进行连接的语言不同,Java中类型的加载连接初始化的过程都是在程序运行期间完成的。

虽然这个特性让Java进行提前编译显得困难,并且让类加载时会增加一些性能的开销,但是为Java带来了极高的扩展性和灵活性

一个类型被加载到虚拟机到卸载,整个生命周期分为7个阶段:

  1. 加载
  2. 验证
  3. 准备
  4. 解析
  5. 初始化
  6. 使用
  7. 卸载

image.png

类加载的过程

加载

在加载阶段,JVM需要完成三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转为方法区的运行时数据结构
  3. 在堆中生成一个该类的Class对象,作为方法区中该类的各个数据的访问入口

相对于类加载的其他过程,非数组类型的加载阶段是开发人员可控的最强阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

而数组类型不太一样,数组类本身不通过类加载器创建,它是由JVM直接在内存中动态构造出来的,但是其元素类型依然要靠类加载器加载。

加载阶段和一部分字节码文件的格式验证动作是交叉执行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

Class文件并不一定只能由Java源码编译而来,它可以使用包括靠键盘0和1直接在二进制编辑器中敲出Class文件在内的任何途径产生。一些Java代码无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施

准备

准备阶段是正式为类中定义的静态变量分配内存并设置静态变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域

需要注意的是,此时初始化的是类变量而不是对象变量,并且初始值通常情况下是零值

public static int value = 123;

上述代码准备阶段过后的值是0,而不是123。这时尚未执行任何Java方法,而将value赋值为123的putstatic指令是程序被编译之后,存放在类构造器<clinit>()方法中的,这个方法在初始化阶段被调用。

什么是clinit()方法?

clinit方法,全称 “类初始化方法”。它不是由程序员在源代码中直接编写的,而是 Java编译器(javac)自动生成的一个特殊方法。 它的核心职责是:执行类的静态成员变量的显式初始化赋值,以及执行静态代码块(static {})中的语句。

那么不同常的情况是:

public static final int value = 123;

这时准备阶段会直接将value设置为123。

解析

解析阶段Java虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用

  • 定义:一组用来描述所引用目标的符号。这些符号以字面量的形式存储在Class文件的常量池中。

  • 内容:它包含了足够的信息,让虚拟机在运行时能定位到目标,但它本身不是目标的直接地址。常见的符号引用包括:

    • 类和接口的全限定名(如 java/lang/Object
    • 字段的名称和描述符(如 Ljava/lang/String;
    • 方法的名称和描述符(如 main:([Ljava/lang/String;)V

直接引用

  • 定义:直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄

  • 内容:它是虚拟机在内存中布局完成后,可以直接用来访问目标的“门牌号”。例如:

    • 指向方法区中类对象的指针。
    • 指向方法区中方法字节码的入口地址。
    • 指向中对象实例的指针,以及对象实例中字段的偏移量。

初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控 制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序

在准备阶段,变量已经赋过一次系统要求的初始零值。而在初始化阶段,会根据程序员编码的计划去初始化类变量和其他资源

其实初始化阶段就是执行类构造器clinit方法的过程,它是由javac编译器的自动生成物。

clinit方法由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{}块)中的语句合并而成的。

编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问

public class Test { 
    static { 
        i = 0; // 给变量复制可以正常编译通过 System.out.print(i); // 这句编译器会提示“非法向前引用”
    } 
    static int i = 1;
}

clinit()方法与类的构造函数(即在虚拟机视角中的实例构造器init()方法)不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的clinit()方法执行前,父类的clinit()方法已经执行完毕。因此在第一个被执行的clinit()方法的类型肯定是java.lang.Object

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}
static class Sub extends Parent {
    public static int B = A;
}
public static void main(String[] args) {
    System.out.println(Sub.B);
}

JVM虚拟机保证了一个类的clinit方法在多线程环境下能够被正确地加锁同步,如果有多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的clinit方法,其他线程都需要加锁等待,因此在这种场景可能造成多个进程阻塞。

public class Main {
    static class DeadLoopClass{
        static {
            if(true){
                System.out.println(Thread.currentThread().getName()+" 在初始化 DeadLoopClass");
                while(true){

                }
            }

        }

    }
    public static void main(String[] args) throws IOException, InterruptedException {
        Runnable task=new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName() + " 启动");
                DeadLoopClass deadLoopClass = new DeadLoopClass();
                System.out.println(Thread.currentThread().getName() + " 结束");
            }
        };
        Thread thread1=new Thread(task,"线程1");
        Thread thread2=new Thread(task,"线程2");
        thread1.start();
        thread2.start();
    }
}

其结果如下:线程1在初始化DeadLoopClass中死循环,线程2直接阻塞了。

image.png