Java虚拟机的类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这便是Java虚拟机的重载机制。

类的加载时机

类从被加载到内存开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading),其中验证、准备、解析3个阶段同称为连接。
这里写图片描述
加载、验证、准备、初始化、卸载顺序是固定的,但是解析阶段却不一定,在某些情况下可以在类初始化完成之后再进行解析,这是为了支持Java的动态绑定。
什么情况下开始类加载,Java虚拟机规范并没有进行强制约束,可以交给虚拟机的实现者自由把握。但是对于初始化阶段,Java虚拟机规范规定有且仅有以下5种情况才进行类的初始化操作:

  1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果没有进行类的初始化,先要进行类的初始化。这4条指令常见的Java代码场景是:使用new关键字实例化对象的时候,读取或者设置一个类的静态字段(不包括final修饰的静态字段),以及调用一个类的静态方法的时候。
  2. 使用反射技术对类进行反射调用时,如果类没有进行初始化,则先进行初始化。
  3. 初始化一个类时,如果发现其父类没有初始化,则先对其父类进行初始化。
  4. 虚拟机启动时需要先指定一个主类,虚拟机先对主类进行初始化。
  5. 当使用jdk1.7的动态语言支持时,java.lang.invoke.MethodHandle的实例最后的解析结果是REF_getstatic、REF_putstatic、REF_Invokestatic的方法句柄时,并且该方法句柄所对应的类没有初始化,那么先对该类进行初始化。

除以上5中场景之外的所有引用类的方式都不会初始化,称为被动引用。

  1. 通过子类引用父类的静态字段,不会引起子类的初始化。
  2. 通过数组定义来引用类,不会引发此类的初始化。
  3. 常量在编译阶段会存储在类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

另外,接口在初始化时并不要求其父接口已经完成了初始化,只有在真正使用父接口的时候才会引起接口的初始化。

类加载的过程

加载

在加载阶段,虚拟机需要完成以下3件事情;

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

加载阶段和连接阶段的部分内容是交叉进行的。加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区中,方法区中的数据存储格式由虚拟机自行定义。

验证

验证时连接的第一步,这一步的主要目的是确保class文件的字节流中包含的信息符合Java虚拟机的规范要求,并不会危害虚拟机自身的安全。验证阶段非常重要,该阶段是否严谨,直接决定Java虚拟机能否接受恶意代码的攻击,并且验证阶段在类加载该过程占据相当大的一部分。
从整体上看,验证阶段大体会执行以下4个阶段的检验动作:

文件格式验证

验证字节流是否符合class文件的格式规范,并且能被当前的虚拟机处理,主要验证内容有是否以魔数0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型等等。
这一阶段的验证时基于二进制字节流进行的,只有文件格式验证,字节流才能进入方法区进行存储,转化为方法区的动态数据结构,为后面的基于方法区数据结构的连接提供接口。

元数据验证

第二阶段是对字节码描述信息进行语义分析,保证描述信息符合Java语言规范,可能包含的验证点有:该类是否有父类、是否继承了不该被继承的类、如果不是抽象类,是否实现了接口或者父类的方法、类中的字段等是否和父类冲突等等。
第二阶段的主要是对元数据进行语义校验,保证符合Java语义规范的元数据信息。

字节码验证

字节码验证时整个验证最为复杂的阶段,主要是通过数据流和控制流的分析,确定程序语义的合法性,符合逻辑。这个阶段是对类的方法进行校验,保证方法在运行时不会危害虚拟机。

符号引用验证

该阶段发生在虚拟机将符号引用转换为直接引用的时候,这个转换动作将在连接的第三阶段解析阶段发生,可以看做是对类自身以外的信息进行匹配性校验。
符号验证的目的是确保解析能顺利执行,对于虚拟机的加载机制来说,验证是一个非常重要的阶段,但是不是必须的阶段,如果运行的全部代码都已经反复使用或者验证过,那么可以通过参数关闭大部分的类验证措施,以缩短虚拟机类的加载时间。

准备

准备阶段是正式为类变量设置初始值的阶段,这些变量所使用的内存都将在方法区中分配。这个阶段进行的内存分配仅仅包括类变量而不包括实例变量,实例变量将在对象初始化时在Java堆中分配。其次这里的初始值包括两种情况,非final变量将在准备阶段赋值为数据类型的0值,即0或FALSE或者null,final变量将会直接赋值为实际值。

解析

解析是虚拟机将常量池中的符号引用替换为直接引用的过程。首先看一下符号引用和直接引用的区别:

  • 符号引用(Symbolic References):符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时无歧义的定义到目标即可,符号引用的字面量形式明确定义在Java虚拟机规范的class文件格式中。
  • 直接引用(Direct References):直接引用可以是一个直接定位到目标额指针、相对偏移量或者间接定位到目标的句柄。同一个符号引用在不同的虚拟机翻译出来的直接引用一般不会相同。

解析主要针对类或接口、字段、类方法、借口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

初始化

初始化阶段是执行类构造器()方法的的过程。

  1. <clinit>()方法是由编译器自动收集类中所有变量的赋值动作和静态语句块中的语句合并产生的,收集的顺序由语句在源文件中出现的顺序决定,静态语句块只能访问到定义在静态语句之前的变量,定义在它之后的变量只能进行赋值操作,不能访问。
public class TestInit {

    static{
        i = 0; //可以赋值
        System.out.println(i); //不能访问,非法向前引用错误
    }
    static int i = 1;
}
  1. 类构造器<clinit>()方法和实例构造器<init>()不同,不需要显式调用父类构造器,虚拟机会保证子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。
  2. 父类的<clinit>()方法先执行,因此父类定义的静态语句块优先于子类的变量赋值操作。
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);
    }
  1. <clinit>()方法对于类或接口来说并不是必须的,如果类中没有静态语句块,也么有对变量的赋值操作,编译器可以不为该类生成 <clinit>()方法。
  2. 接口和类一样也会生成 <clinit>()方法。但是接口 <clinit>()方法不需要先执行父接口的 <clinit>()方法,只有接口使用时才会执行 <clinit>()方法;另外接口的实现类初始化时也不需要执行接口的 <clinit>()方法。
  3. 虚拟机会保证一个类的 <clinit>()方法在多线程中会正确枷锁、同步,如果多个线程同时初始化一个类,只有一个线程执行 <clinit>()方法,其余线程阻塞等待,直到活动线程执行完 <clinit>()方法。

类加载器

虚拟机把“通过一个类的全限定名区获得此类的二进制字节流”这个动作放到虚拟机的外部去实现,让应用程序自己决定如何去获取使用的类,实现这个动作的代码模块称为“类加载器”。

类与类加载器

对于任何一个类,都需要由加载它的类加载器和这个类本身一同确定类在Java虚拟机中的唯一性,每一个类加载器都拥有独立的命名空间。例如以下程序将会返回false。

public class ClassLoaderTest {

    public static void main(String[] args) throws Exception {
        ClassLoader myClassLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
                    InputStream stream = getClass().getResourceAsStream(fileName);
                    if(stream == null)
                        return super.loadClass(name);
                    byte[] b = new byte[stream.available()];
                    stream.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    e.printStackTrace();
                    throw new ClassNotFoundException();
                }
            }
        };
        Object object = myClassLoader.loadClass("init.ClassLoaderTest").newInstance();
        System.out.println(object.getClass());
        System.out.println(object instanceof ClassLoaderTest);
    }
}

双亲委派模型

从Java虚拟机的角度来讲,只存在两种类加载器,一种是启动类加载器(BootStrap ClassLoader),这个类加载器用c++语言实现,是虚拟机的一部分;另外一种是其他所有的类加载器,用Java语言实现,独立于虚拟机,全部继承与java.lang.ClassLoader
从开发人员的角度看,类加载器可以分为以下3种:

  1. 启动类加载器(BootStrap ClassLoader):这个类加载器负责将JAVA_HOME/lib目录下,或者被-Xbootclasspath参数指定的路径中可以被虚拟机识别的类库加载到虚拟机内存,启动类加载器无法被java程序直接引用,如果需要将加载请求委派给启动类加载器直接使用null即可。
  2. 扩展类加载器(Extention ClassLoader):这个加载器有sun.misc.Launcher$ExtClassLoader实现,负责加载JAVA_HOME/lib/ext目录中,或者被java.ext.dirs系统变量所指定的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader):这个加载器有sun.misc.Launcher$AppClassLoader实现,一般也成为系统类加载器,负责加载calsspath路径上的类,可以直接使用该类加载器,如果开发者没有自定义类加载器,那么程序默认使用这个类加载器。

这里写图片描述
双亲委派模型并不是一个强制的约束,而是java设计者推荐给开发者的开发模式。
双亲委派模型的工作过程是:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

参考文献

  1. 周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值