jvm类加载机制

来源:互联网 发布:淘宝助理5.5无数据 编辑:程序博客网 时间:2024/06/05 11:54

概述

众所周知,class文件是一组以八进制为基础单位的二进制流,它是java语言实现“一次编写,随处运行”的关键。那么虚拟机如何加载class文件呢?class文件在虚拟机中又会发生什么变化呢?简单说来,虚拟机的类加载机制就是:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、软换解析和初始化,最终形成被虚拟机直接使用的java类型。

在java语言里,类型的加载、连接和初始化都是在程序运行期间完成的(与编译时进行连接的语言不同),这给java程序提供了高度的灵活性,java语言天生支持动态拓展。例如,编写一个面向接口的程序,可以等到运行时再指定其实际的实现类。

类加载的过程

从类被加载到虚拟机内存中开始,到卸出内存为止,它的生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。

这里的加载和类加载不是同一个概念,“加载”是“类加载”的一个阶段。

其中验证、准备和解析三个阶段统称为连接(Linking)。如图类的生命周期为:
这里写图片描述
其中,加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,解析阶段则不一定:它在特定情况下可以在初始化阶段之后再开始(为了支持java语言的运行时绑定/动态绑定/晚期绑定)。

1.加载

在加载阶段,虚拟机完成以下三件事:

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

这里虚拟机设计团队并没有限制具体的实施方式,给加载阶段提供了非常开放的舞台。例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条,它并未指定二进制字节流要从class文件获取(甚至根本没说从哪里获取,怎样获取)。因此出现了很多举足轻重的技术:JAR、EAR、WAR;Applet;JSP;java.lang.reflect.Proxy等。类加载阶段(非数组类)对于开发人员的可控性最强,用户可以用自定义的类加载器去完成,通过重写类加载器的loadClass()方法开发人员可以自定义获取字节流。

加载阶段完成后,虚拟机外部的二进制字节流就存储在了方法区之中,然后在内存中实例化一个java.lang.Class类的对象(并未指定是在java堆中,对于hotspot虚拟机而言,它存在方法区中),这个对象作为程序访问方法区中的这些类型数据的外部接口。

加载阶段与连接阶段的部分内容(如验证动作)是交叉进行的,加载未完成时可能连接已经开始,但是这些在加载阶段之中进行的动作仍然属于连接阶段的内容,这两个阶段的开始时间依然保持固定的先后顺序。

2.验证

验证是连接的第一步,这一阶段的目的为了确保Class文件的字节流中包含的信息符合虚拟机要求,并且不会危害虚拟机安全。验证阶段是非常重要的,它决定了java虚拟机能否承受恶意代码的攻击。验证阶段包括四个步骤:

  • 文件格式验证:这个阶段验证的是基于二进制字节流进行的,通过这个阶段,字节流才会进入内存的方法区中存储,后面三个验证阶段全部是基于方法区的存储结构进行的。这个阶段是验证Class文件格式,包括:
    1. 是否以魔数0xCAFEBABE开头。
    2. 主次版本号是否在当前虚拟机处理范围之内。
    3. 常量池的常量中是否有不被支持的常量类型。
    4. 指向常量的索引值中是否有指向不存在的常量或者不符合类型的常量。
    5. CONSTANT_Utf8_info型的常量中是否有不符合utf8编码的数据
    6. Class文件中各个部分及文件本身是否有被删除或附加的信息。
    7. ……
  • 元数据验证:这个阶段是对字节码描述的信息进行予以分析,保证其符合java语言规范。包括
    1. 这个类是否有父类。(除了java.lang.Object外,所以的类都有父类)。
    2. 这个类的父类是否继承了不允许被继承的类(final修饰的类)
    3. 如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。
    4. 类中的字段、方法是否与父类矛盾
    5. ……
  • 字节码验证:这个阶段是验证阶段最复杂的,主要目的是通过数据流和控制流确定程序语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析(元数据验证阶段可以简单理解为方法数据类型校验),保证被校验类的方法在运行时不会危害虚拟机安全。例如:
    1. 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。不会出现类似操作栈中一个int类型数据使用时按long类型来加载入本地变量表中。
    2. 保证跳转指令不会跳转到方法体以外的字节码指令上。
    3. 保证方法体中的类型转换是有效的。
    4. ……
  • 符号引用验证:最后一个阶段的校验发生在符号引用转化为直接引用的时候。这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证是对类自身以外的信息匹配性校验,目的是确保解析动作能正常执行,如果无法完成符号引用验证,将抛出一个java.lang.IncompatibleClassError异常的子类,通常需要验证:
    1. 符号引用能否通过字符串描述符的全限定名找到对应的类。
    2. 在指定的类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
    3. 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可以被当前类访问。
    4. ……

3.准备

准备阶段是正式为变量分配内存并设置类变量初始值的阶段,这些变量将被分配到方法区中。

注意:
1. 这里所分配的变量是被类变量(被static修饰的变量)而不包括实例变量,实例变量将会在对象实例化时分配在java堆中。
2. 变量初始值通常情况下被设置为数据类型的零值,例如:

public static int num = 1;

变量num在准备阶段过后的初始值是0而非1,将其赋值为1的过程将在初始化阶段。但是如果num加上final字段,类的字段属性表中存在ConstantValue属性,那么准备阶段num的值会被初始化为ConstantValue所指定的值,即:

public static final int num = 1;//准备阶段num被初始化为1

各个数据类型的零值如下表:

数据类型 零值 int 0 long 0L short (short)0 char ‘\u0000’ byte (byte)0 boolean false float 0.0f double 00d reference null

4.解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。关于符号引用和直接引用,可以参考JVM里的符号引用如何存储?,我浅显简单的理解为:符号引用使用一组符号(字符串或任何形式的字面量)来描述所引用的目标,符号引用的目标不一定就在内存中。而直接引用直接和目标相关,可以是指向目标的指针、相对偏移量等,直接引用的目标一定在内存中。

5.初始化

初始化阶段,开始执行类中定义的java代码(或者说是字节码)。前面说过准备阶段变量已经被初始化为零值,而初始化阶段将被正式赋值。初始化阶段是执行类构造器< clinit>()方法的过程,关于< clinit>()方法,注意:
1.< clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和static语句块中的语句合并产生的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值但无法访问。例如:

public class Test {    static{        //给变量赋值可以正常编译通过        i = 0;        System.out.println(i);//Cannot reference a field before it is defined    }    static int i = 1;}

2.父类的< clinit>()方法先执行,第一个被执行的< clinit>()方法的类肯定是java.lang.Object即父类中的static语句块优先于子类static语句块执行。但是接口中没有static语句块,执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法,只有在使用到父接口中的变量时父接口才被初始化。
3.< clinit>()方法对于类和接口来说是非必需的,若类中无静态语句块也没有对变量赋值操作,那么编译器可以不为这个类生成< clinit>()方法。
4.虚拟机保证< clinit>()方法在多线程环境中是安全的,如果多个线程去初始化一个类,只会有一个线程执行< clinit>()方法。

注意以下五种情况必须对类进行初始化:

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时。这四种指令常见的java代码场景为:使用new实例化对象、读取或设置一个类的静态字段(被final修饰的除外)、调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候, 要先初始化类。
  3. 初始化一个类时,会先触发其父类的初始化。
  4. 虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类。
  5. 使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getStatic,REF_putStatic,REF_involeStatic的方法句柄,并且这个方法句柄所对应的类没有初始化,则触发其初始化。

    有且只有上面五个情景会触发初始化,这五种行为称为对一个类主动引用,其他的为被动引用。被动引用例子如下:

1.

//子类引用父类的静态字段,子类不会初始化class SuperClass{    static{        System.out.println("SuperClass init");    }    public static int value = 123;}    class SubClass extends SuperClass{    static {        System.out.println("SubClass init");    }}public class Test{    public static void main(String[] args){        System.out.println(SubClass.value);    }   }//上述代码运行只会输出“SuperClass init”而不会输出“SubClass init”,对于静态字段,只有直接定义这个字段的类才会被初始化。

2.

//通过数组定义来引用类,不会触发此类的初始化class SuperClass{    static{        System.out.println("SuperClass init");    }    public static int value = 123;}    class SubClass extends SuperClass{    static {        System.out.println("SubClass init");    }}public class Test{    public static void main(String[] args){        SuperClass[] sc = new SuperClass[10];    }   }//该代码运行后不会输出“SuperClass init”.

3.

//常量在编译阶段会存入掉用类的常量池中,本质上并没有直接引用到定义常量的类,因此定义常量的类不会初始化。class ConstClass{    static{        System.out.println("ConstClass init");    }    public static final int VAKUE = 123;}public class Test{    public static void main(String[] args){        System.out.println(ConstClass.VAKUE);    }   }//上述代码不会输出“ConstClass init”,因为常量在编译阶段被存入Test类的常量池中了,Test类对常量ConstClass.VAKUE的引用被转化为对自身常量池的引用。实际上,Test的class文件中并没有ConstClass类的符号引用入口,这两个类在编译成class文件后就不存在联系了。

本文介绍了class文件的加载过程,参考《深入理解java虚拟机》

原创粉丝点击