×

Java虚拟机--类加载机制

96
贾博岩
2018.04.04 13:09* 字数 2713

文末有彩蛋!!!!!!

类加载

对于虚拟机来说,一个对象的创建十分复杂,包含了很多步骤。首先,我们要从类加载说起。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这7个阶段,类加载包含了前五个,具体如图:

1522982931(1).png

一个.java文件在编译后会形成一个或多个class文件(若有内部类,则编译后会产生多个.class文件),但这些class文件中的信息,只有被加载到虚拟机中才能被运行和使用。

虚拟机把类的数据从class文件加载到内存,并对class文件中的数据进行校验、转换、解析、初始化等操作后,最终形成可以被虚拟机识别并使用的过程就叫做“虚拟机的类加载”。

接下来,笔者就对于其中的一些步骤进行详细的说明。

加载

“加载”是类加载中的一个过程,并且是通过类加载器来完成的。但是,什么时候会进行“加载”,虚拟机规范中并没有强制约束,而是交给具体的虚拟机实现来完成。

在此阶段中,虚拟机需要完成以下步骤:

通过全限定类名来获取类的二进制字节流,获取的方式可以通过jar包、war包、网络、JSP文件等方式获取,通过都是通过.class文件中获取的。

获取到字节流后,会将字节流中的信息转化为方法区中的运行时数据结构。

在内存中,生成代表该类的Class对象,作为访问该类的数据入口。在HotSpot中,Class对象并不存在于JVM虚拟机堆中,而是存在于方法区中。

在虚拟机中,当程序主动使用某个类时,如果该类还未被加载到内存中,JVM虚拟机会进行加载操作,直至初始化完成。

关于类加载器的话题,笔者在后面会有单独一章进行阐述。

验证

在完成加载阶段后,JVM虚拟机开始验证阶段,此阶段的目的很简单,很纯粹,就是为了保证class文件中的内容符合虚拟机的规范要求,在实际运行时不会威胁到虚拟机自身的安全。

你可能会问,在javac编译时,不是已经对java程序进行了校验了吗,我写的代码有问题也不会通过编译阶段啊!

你说的没错,在javac编译期是进行了一次代码校验,例如类型转换、代码语法等常见错误。如果你写的代码有错误,在编译期就会暴露出来,不修改是无法通过编译,更别说后续的运行了。

但是,在编译期完成后,我们是可以随意修改生成的class文件,如果此时将class文件修改,等到实际代码执行时候,程序就有可能抛出异常,威胁到整个系统的可用性。在类加载时验证就十分有必要,可以理解为对代码的二次校验。

此外,class文件并一定是本地的Java源码编译得来的,可以是网络中下载而来,可以是jsp页面解析得来,也甚至是你自己用16进制编写而来,这些都会跳过javac编译阶段,所以Java虚拟机如果不进行校验的话,很有可能因为载入了有问题的字节流而导致系统崩溃,无法保障系统的健壮性。

在实际的验证过程中,如果验证到输入的字节流不符合class文件的格式约束,那么虚拟机就会抛出异常,拒绝继续执行。

但是,关于class文件格式如何验证,直到2011年才有了具体的规范,在Java虚拟机规范(Java SE 7版)中,大致规定了4类验证:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证:

文件格式验证,就是验证class文件字节流是否符合class文件格式规范。

其中,包含:字节流文件是否以魔数0xCAFEBABE开头;主、次版本号是否在虚拟机处理范围之内;class常量池中是否有不支持的常量类型....等等。

该阶段验证的主要目的是为了保障输入的class文件字节流能正确地解析,格式上符合要求。在通过该阶段验证后,class文件字节流会进入内存方法区中存储,后续的三种验证都是基于方法区的。

元数据验证:

接下来,进行元数据验证,对字节码描述的信息进行语义化分析,保证其描述的内容符合java语言的语法规范。验证点如下:这个类是否有父类;这个类的父类是否继承了不允许被继承的类(final修饰的类);这个类是否实现了其父类或者接口中所要求实现的所有方法(抽象类可不实现);类中的字段、方法是否与父类产生矛盾(不符合规则的重载、覆盖了父类中的final字段等)。

该阶段的验证,类似于我们编译期间的校验,对于java语法上的检查。

字节码验证:

通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

符号引用验证:

主要来验证一些引用的真实性与可行性,比如代码里面引用了其他类,这时就需要去检测一下类是否存在;或者说代码中访问了其他类的一些属性,就对那些属性是否可以访问进行检验。

如果无法通过符号引用验证,那么就会抛出异常,我们在编写程序时,经常会遇到java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError,这就是符号引用验证所产生的。

对于,虚拟机类加载阶段来说,符号引用验证可以省略,如果你所运行的代码都已经被反复使用和验证过,那么你可以考虑省略此步骤,虚拟机也提供了一个参数供我们修改,使用-Xverify:none参数可关闭验证措施,以缩短类加载阶段耗时。

准备

验证阶段完成后,就到了准备阶段。准备阶段,说直白点,就是对类变量设置初始值。这里所说的类变量,是被static修饰的变量,而不是实例变量,这一点要搞清楚。

而准备阶段的初始值,是数据类型的零值。即

public static int value = 123;

对于变量value来说,变量value的初始值是0,并不是123,123在初始化阶段才会被赋值给value。

在Java中,有两大的数据类型,一种是基本类型、另一种是引用类型。对于这两种类型的初始值如下:

image

值得注意的是,初始值也会存在一些特殊情况,如果类的静态变量被final所修饰,那么在准备阶段,该值会直接赋予变量中,而不再需要零值了。

解析

解析阶段主要是将常量池内的符号引用替换为直接引用的过程。

Java在编译阶段,会将.java文件编译成.class文件,在生成的.class文件中,static修饰的静态变量就是我们常说的符号引用,但是在编译阶段该符号引用并不知道引用类的实际内存地址(虚拟机还没运行)。直到解析阶段,虚拟机加载了该类才能真正解析到具体的内存地址。

例如:在com.jiaboyan.Person类中引用了com.jiaboyan.Animal类,在编译阶段,Person类并不知道Animal的实际内存地址,因此只能用com.jiaboyan.Animal来代表Animal真实的内存地址。在解析阶段,JVM可以通过解析该符号引用,来确定com.jiaboyan.Animal类的真实内存地址(如果该类未被加载过,则先加载)。

初始化

初始化,是类加载阶段的最后一步。在这步中,才真正开始执行类中定义的Java代码。

在Java虚拟机规范中,如果有以下几种情况时必须立即对类进行“初始化”操作:

(1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候;读取或设置一个类的静态字段(被final修饰除外)的时候;调用一个类的静态方法的时候。

(2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

(3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

在之前的准备阶段,类中定义的static静态变量已经被赋过一次零值。而在初始化阶段,则会调用类构造器<clinit>来完成初始化操作,为静态变量赋原始值。

此处,需要注意的是类构造器<clinit>和类构造方法<init>区别。

类构造方法<init>就不用多说了,每创建一次对象,都会自动调用一次;例如:

public class Student{
    public Student(){}
}

类构造器<clinit>类似于一个无参的构造函数,只不过该函数是静态修饰,只会初始化静态所修饰的代码。

public class Student{

    public static int x = 10;

    public String zz = "jiaboyan";

    static{
        System.out.println("12345");
    }
}

类构造器<clinit>是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块static{}中的代码合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。综上所述,对于上面的例子来说,类构造器<clinit>为:

public class Student{
    
    <clinit>{
        public static int x = 10;
        System.out.println("12345");
    }
    
}
image

类构造器<clinit>和类构造方法<init>不同的是,类构造器不需要显式地调用父类类构造器,虚拟机会保证子类类构造器执行之前,父类类构造器已经执行完毕。

image

此外,虚拟机会保证类构造器<clinit>在多线程环境中被正确执行,如果有多个线程同时去初始化一个类,那么只会有一个线程去执行该类的类构造器<clinit>,其他线程都需要阻塞的等待。并且,类构造器<clinit>只会执行一次。

image
可伸缩服务架构-框架与中间件

京东购买链接:可伸缩服务架构-框架与中间件

日记本
Web note ad 1