Java程序运行于Java虚拟机之上,JVM屏蔽了底层细节,使得Java程序能够“一次编译,到处运行”。在Java语言中,一切皆是对象,代码一般由类、接口、enum等构成,是一种面向对象的编程语言。本文将为你揭示Java虚拟机如何加载类,一窥Java底层的秘密。
类在虚拟机中的生命周期,可以分为加载、验证、准备、解析、初始化、使用、卸载几个阶段,其中的验证、准备、解析统称为连接。在这里,读者可以回忆一下以C语言为代表的面向过程语言如何实现动态链接库,以更好地理解Java面向对象编程。
通常情况下,虚拟机都会按照上图流程管理类的生命周期。然而,Java语言的一大特性——多态支持方法的动态绑定,即,调用方法前无法知道具体调用了那个方法,只有运行到调用的时刻才能确定方法的具体实现。因此,解析也可能发生在初始化之后,在多态调用时才解析出具体的直接引用。
加载
在Java虚拟机规范中,并没有强制要求什么时候加载类,由虚拟机自行把握。在加载阶段,虚拟机通过一个类的全限定名获取类的二级制字节流,把字节流的静态存储结构转换为运行时数据结构,在内存中生成一个Class对象,Class对象将作为方法区的访问入口。
在Java中,能够根据全限定名获取字节流的代码块被称为类加载器。主要包括启动类加载器、扩展类加载器、应用程序类加载器和用户自定义类加载器。其中,
启动类加载器加载jre的lib目录下的类,如rt.jar,在Hotspot虚拟机中用c++实现,是虚拟机的一部分;
扩展类加载器加载jre的lib/ext或者由系统变量 java.ext.dir指定目录中的类,一般Java语言实现;
应用程序类加载器加载CLASSPATH中的类,一般Java语言实现;
自定义类加载器用于程序实现个性化的类加载,如spring提供的ClassLoader、用于热升级的ClassLoader、从网络加载jar包的ClassLoader。
在加载类的过程中,Java采用了双亲委派机制。而这种父子关系并不是通过继承实现的,而是组合关系。一个类加载器需要加载类时,首先委托父类加载器进行加载,并逐级向上,如果父类加载器加载成功则返回成功,如果父类加载器加载失败,则自己进行加载。在Java中,类的唯一性是由类和所属的类加载器共同确定的。两个类加载器加载的同一个class,在虚拟机看来也是不同的类。通过双亲委派机制,Java能够保证核心类不会被用户覆盖,因用户企图覆盖核心类时类加载器总能找到已由父类加载器加载的核心类。
验证
只要符合class文件的格式要求的class文件都能被虚拟机加载,不管class文件是不是由Java编译器所产生。Java虚拟机出于自身安全的考虑,会对加载的类进行合法性验证。
在验证阶段,虚拟机将进行文件格式验证、元数据验证、字节码验证和符号引用验证。此阶段主要的目标是确保Class文件的字节流包含的信息符合虚拟机的要求。
文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如是否以魔数0xCAFEBABE开头,主、次版本号是否在当前虚拟机处理范围之内等。
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,如是否有父类,父类是否继承了不允许被继承的类,类中的字段、方法是否与父类产生矛盾等。
字节码验证:对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。如保证跳转指令不会跳转到方法体以外的字节码指令上。
符号引用验证:对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,如符号引用中通过字符串描述的全限定名是否能找到对应的类,符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问等。
准备
在准备阶段,虚拟机将为类变量在方法区分配内存并设置类变量的初始值。此时的初始值并不是源代码中的初始值,而是各种类型变量的默认初始值,如int类型为0、boolean类型为false。源代码中的变量初始值,会在<clinit>方法中赋值,在初始化阶段完成。
解析
在解析阶段,虚拟机将常量池内的符号引用替换为直接引用。在类初始化之前,解析操作只能解析静态绑定的符号引用。
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
虚拟机规范要求有且只有以下几种情况触发类的初始化,也就是说连接之后的类并没有立即执行初始化,而是在使用前才进行初始化:
①使用new创建对象、读写类的静态字段、调用类的静态方法时需要进行初始化,但final修饰的字段除外。读写静态字段时,只有静态字段所在的类会被初始化。
②使用反射调用的时候,如果类没有初始化则先初始化。
③初始化一个类的时候,如果它的父类还没有初始化,则先触发其父类的初始化。
④虚拟机启动时,用于执行的包含main方法的类需要先初始化;
⑤使用动态语言支持时,如果解析结果引用的类没有进行初始化,则需要先初始化。
在编译阶段,编译器会扫描源文件,根据类中的变量赋值和静态语句块生成<clinit>方法,并在初始化阶段执行<clinit>方法。
<clinit>方法中初始化过程与源代码中语句顺序保持一致,静态语句块只能访问之前的变量,对于之后的变量只能赋值不能访问。如果类中没有变量赋值和静态语句块,则不会生成<clinit>方法。在讲解继承的时候,通常都会提到父类会先于子类进行初始化,一定程度上也是因为父类的<clinit>方法会先于子类执行。
如果接口定义了常量,也会生成<clinit>方法,与类不同的是,接口初始化时不需要先调用父接口的<clinit>方法,只有在用到父接口的变量时才执行父接口的<clinit>方法。并且,接口的实现类在初始化时也不会调用接口的<clinit>方法,因此方法属于接口不属于实现类。
在初始化过程中,虚拟机会保证多线程并发情况下类能够被正确初始化,即<clinit>方法会被虚拟机加锁和同步,同一时间只有一个线程能够执行<clinit>方法。
总结
虚拟机的类加载机制,分为加载、验证、准备、解析、初始化五个阶段。采用双亲委派机制从类的文件二进制流载入,然后进行类的合法性验证,分配类的运行时数据空间,对符号引用进行解析转为直接引用,最后执行类的初始化。
原文地址:Java虚拟机类加载机制