虚拟机类加载机制(一)
概述
Class文件中描述的各种信息最终都需要加载到虚拟机中之后才能运行和使用。在Java中,类型的加载和连接与初始化是在程序运行期间完成的,所以Java可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类。
类加载的时机
什么情况下需要开始类加载过程的第一个阶段:"加载"。虚拟机规范中并没强行约束,这点可以交给虚拟机的的具体实现自由把握,但是对于初始化阶段虚拟机规范是严格规定了如下几种情况,如果类未初始化会对类进行初始化。
- 创建类的实例
- 访问类的静态变量(除常量【被static修辞的静态变量】原因:常量一种特殊的变量,因为编译器把他们当作值(value)而不是域(field)来对待。如果你的代码中用到了常变量(constant variable),编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。这是一种很有用的优化,但是如果你需要改变final域的值那么每一块用到那个域的代码都需要重新编译。
- 访问类的静态方法
- 反射,如(Class.forName("my.xyz.Test"))
- 当初始化一个类时,发现其父类还未初始化,则先出发父类的初始化
- 虚拟机启动时,定义了main()方法的那个类先初始化,主类
以上情况称为称对一个类进行“主动引用”,除此种情况之外,均不会触发类的初始化,称为“被动引用”。
接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。
被动引用的例子
- 子类调用父类的静态变量,子类不会被初始化。只有父类被初始化。对于静态变量,只有直接定义这个字段的类才会被初始化;
- 通过数组定义来引用类,不会触发类的初始化;
- 访问类的常量(static final 修饰),不会初始化类。
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);// 被动应用1, 程序输出 superclass init , 123
SubClass[] sca = new SubClass[10];// 被动引用2
}
}
class ConstClass {
static {
System.out.println("ConstClass init");
}
public static final String HELLOWORLD = "hello world";
}
public class Test {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);// 调用类常量 输出 hello world 即没初始化类
}
}
类加载可以分为加载、连接(Linking)、初始化。连接包括验证、准备、解析。
类从被加载到虚拟机内存开始到卸载出内存为止,他的整个生命周期包括:
- 1、加载(Loading)
- 2、验证(Verification)
- 3、准备(Preparation)
- 4、解析(Resolution)
- 5、初始化(Initialization)
- 6、使用(Using)
- 7、卸载(Unloading)
解析阶段有时候可以在初始化之后在开始,这是为了支持Java 的运行时绑定(动态绑定或者晚期绑定)。
这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:
静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。
1、加载(Loading)
加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 1、通过一个类的全限定名来获取其定义的二进制字节流。
- 2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些类型数据的访问入口。
二进制流产生的方式:
- 从本地文件系统中读取
- 从网络上加载(典型应用:java Applet)
- 从jar,zip,war等压缩文件中加载
- 通过动态将java源文件动态编译产生(jsp的动态编译)
- 运行计算生成,最典型的就是动态代理技术,在java.lang.reflect.Proxy中,生成代理类的二进制流。
对于数组类,数组类本身不通过类加载创建,而是由JAVA虚拟机直接创建的。二数组类的元素类型最终还是靠类加载器出创建的。
加载完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式储存在方法区中,然后在内存中实例化一个java.lang.Class类的对象(虽然是对象,但是HotSpot虚拟机将它放在方法区中)
加载阶段和连接阶段是交叉进行的(比如一部分字节码文件格式验证)
2、连接
2.1、验证
验证阶段主要是为了确保Class文件的字节流中包含的信息是符合当前虚拟机的。Java语言本身是相对安全的语言,但是Class文件的来源不一定是Java源码编译来的。所以这个时候需要检查输入的字节流。
验证阶段主要是会完成4个阶段的验证:
- 1、文件格式验证
文件格式验证包括Class文件魔数、主次版本号、常量池的中常量等等;通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。 - 2、元数据验证
第二阶段就是语义分析,是否有父类、是否继承了不允许被继承的类、抽象类的规范等等。
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
这个主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。 - 3、字节码验证
对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件。 - 4、符号引用验证
对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,这个阶段发生在将符号引用转化为直接引用的时候(解析阶段中发生),目的是确保解析动作能正常执行。
2.2 准备
准备阶段是正式为类变量在方法区中分配内存并设置变量初始值的阶段。
类变量(static修饰)不是实例变量(在初始化阶段被分配到java堆);初始值为数据类型的零值,而不是程序定义的初始值(字面量)。
特殊情况:final修饰的字段赋值为程序定义的初始值,而不是数据类型零值,虚拟机不会给final修饰的变量赋初始值。
2.3 解析
解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。
-
符号引用
符号引用是一个字符串,它给出了被引用的内容的名字并且可能会包含一些其他关于这个被引用项的信息——这些信息必须足以唯一的识别一个类、字段、方法。这样,对于其他类的符号引用必须给出类的全名。对于其他类的字段,必须给出类名、字段名以及字段描述符。对于其他类的方法的引用必须给出类名、方法名以及方法的描述符。 -
直接引用
对于指向“类型”Class对象、类变量、类方法的直接引用可能是指向方法区的本地指针。
指向实例变量、实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量。实例方法的直接引用可能是方法表的偏移量。同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那说明引用的目标必定已经存在于内存之中了。
子类中方法表的偏移量和父类中的方法表的偏移量是一致的。比如说父类中有一个say()方法的偏移量是7,那么子类中say方法的偏移量也是7。
通过“接口引用”来调用一个方法,jvm必须搜索对象的类的方法表才能找到一个合适的方法。这是因为实现同一个接口的这些类中,不一定所有的接口中的方法在类方法区中的偏移量都是一样的。他们有可能会不一样。这样的话可能就要搜索方法表才能确认要调用的方法在哪里。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。 - 1、类或接口的解析
判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。 - 2、字段解析
对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从下往上递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下图所示:
- 3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
- 4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。
3 、初始化
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
该方法由编译器在编译阶段生成,它封装了两部分内容:静态变量的初始化语句和静态语句块。
<clinit>()方法的执行规则:
- 1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
- 2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
- 3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
- 4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
- 5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
类加载器明天再写。。。。