虚拟机的类加载机制
虚拟机的类加载机制
概述
在java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为java应用程序提供高度的灵活性。例如:用户可以通过java预定义的和自定义的类加载器,让一个本地的应用程序可以在程序运行时从网络或者其他地方加载一个二进制流作为程序代码的一部分。
类加载的时机
类从被加载到虚拟机内存开始,到卸载出内存开始,它的生命周期包括加载、验证、准备、解析、初始化、使用、卸载。其中,验证、准备和解析过程称为连接。什么时候进行类加载的开始,这个虚拟机规范并没有进行强制的约束,这点可以交给虚拟机的具体实现去自由把握。但是,对于初始化阶段,虚拟机规范则是严格规定了有且仅有5中情况必须立即对类进行初始化。而加载、验证、准备、解析肯定是要在初始化之前执行。
01)、遇到 new、getstatic、putstatic或者 invokestatic这4条字节码指令时候,如果类没有进行初始化,则需要先触发其初始化。生成这这4条指令最常见的java代码场景是:使用new关键字实例化对象的时候、读取或者设置一个类的静态字段(被final修饰、已经在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
02)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发类的初始化。
03)当初始化一个类的时候,如果发现其父类还没有初始化,则需要先触发其父类的初始化。
04)当虚拟机启动的时候,用户需要指定一个执行的主类(包含main方法的那个类),虚拟机最先初始化这个类。
05)当使用JDK1.7动态语言支持的时候,如果一个java.lang.invoke.MethodHandle实例最后的解析结果是REF_getstatic、REF_pubStatic、REF_invokstatic的方法句柄时候,并且这个方法句柄对应的类没有进行初始化,则需要先对其进行初始化。
这五种场景的行为称为对一个类进行主动引用。除此以外,所有引用类的方法都不会触发其初始化,称为被动引用。
实例一:只会触发引用定义静态字段且未被final修饰的类的初始化
程序的运行结果是:
实例二:
程序执行结果:
上述代码运行之后并没有输出"ConstClass init",这是因为虽然在java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将ConstClass中常量值"hell world "存储到NotInitialzation类的常量池中,以后NotInitialzation对常量ConstClass. HELLOWORLD的引用都变为NotInitialzation类对自身常量池的引用。也就是说,实际上NotInitialzation的class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。
接口与类的初始化的区别:当一个类进行初始化的时候,会要求其父类全部初始化。而接口在初始化的时候,并不要求其父类全部进行初始化,只有当用到了父类的时候才会进行初始化(如引用接口中定义的常量)。
类加载过程
01)、加载:
加载时类加载过程的一个阶段。在加载阶段,虚拟机需要完成三件事情:
1.通过一个类的全限定名获取定义的此类的二进制字节流
2.将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
3.在内存中生成一个代表这个类的java.lang.Class对象,作为这个方法区这个类的各种数据的访问入口。
相对于类加载过程的其他阶段 加载阶段是开发人员可控性最强的,因为加载阶段既可以用系统提供的引导类加载器完成,也可以由用户自定义的类加载器完成。但是对于数组列而言 情况就有所不同,数组类本身不能通过类加载器直接创建,他是由java虚拟机直接创建的。但是数据类和类加载器有很大的关系,因为数组类的元素类型(是指数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类创建过程如下:
如果创建数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组类将在加载该组件类型的类加载的类名称空间上被标识。
如果组件的组件类型不是引用类型(例如:int[]数组),java虚拟机将会把数组类标记为与引导类加载器相关联。
数组类的可见性与他的组件类型的可见性一致,如果组件类型不是引用数据类型,那数组类的可见性默认为 public。
类加载完成以后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。
然后在内存中实例化一个java.lang.Class类的对象,将这个对象作为程序访问方法区的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的。
02)、验证
验证是连接阶段的第一步,这一个阶段的目的是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。java语言是相对安全的语言,使用纯粹的java代码无法做到诸如访问数组边界以外的数据,将一个对象类型转型为他未实现的数据类型,跳转到不存在的代码行之类的事情,如果这样做了虚拟机将拒绝编译
但前面已经说过,class文件并不一定要求用java源代码编译而来,可以使用任何途径产生,甚至包括十六进制编辑器直接编写来产生class文件。在字节码语言层面上面,上述java代码无法做到的事情都是可以实现的,至少语义是可以表达出来的。如果虚拟机不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段主要分为四个工作:
1.文件格式的校验
第一阶段主要校验字节流是否符合Class文件的规范,并且能被当前版本的虚拟机处理。比如:检查是否以魔数开头等等。该阶段的验证是基于二进制字节流进行的,只有通过了这个验证后,字节流才会进入到内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构的验证,不会直接操作字节流。
2.元数据的验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范要求。比如这个类是否有父类。第二阶段的目的保证是对类的元数据信息进行语义校验,保证不存在不符合java语言规范的元数据信息。
3.字节码校验
第三个阶段是整个验证过程中最复杂的阶段,主要目的是通过数据流和控制流的分析,确定程序是否合法、是否符合逻辑的。主要包括对方法体内部的验证。
4.符号引用的校验
最后一个阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个装换动作将在连接的第三个阶段解析阶段发生。符号引用可以看做是对类自身以外的信息进行匹配性校验。
03)、准备阶段
准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区进行分配。这个阶段中有两个容易产生混淆的概念需要注意一下,首先,这个时候进行内存分配的仅包括类变量(被static修饰的变量),而不是包括实例变量,实例变量将会在对象实例化的时候随着随着对象一起分配在堆内存中。其次,这个所说的初始值“通常情况下面”是数据类型的零值,假设一个类变量定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值是0而不是123,因为这个时候尚未开始执行任何java方法,而把value赋值给123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值给123的动作将在初始化阶段才被执行。
基本数据类型的零值;
数据类型 |
零值 |
Int |
0 |
Long |
0L |
short |
(short)0 |
char |
'\u0000' |
byte |
(byte)0 |
boolean |
false |
float |
0.0f |
double |
0.0d |
reference |
null |
在上面提到的通常情况下面初始值是零值,那相对的会有一些特殊情况,如果:类字段的字段属性表中存在ConstantValue属性,那么在准备阶段value值就会被初始化为ConstantValue 属性所指定的值,假设上面类变量value的定义变为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。
03)、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在前一章讲解Class文件格式的时候已经出现过很多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
解析阶段中 符号引用和直接引用的关联:
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时候能无歧义定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用目标并不一定加载到内存中。各种虚拟机实现的内存布局可以不相同,但是他们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范之中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,则证明引用的目标必定已经在内存中存在。
对于同一个符号引用进行多次解析请求是很常见的事情,除了invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免了解析动作的重复进行。
04)、初始化
类初始化时类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户对应的程序可以通过自定义类加载器参与之外,其余阶段全由虚拟机主导和控制,到了初始化的阶段才真正执行类中定义的java程度代码或者说是字节码。
在准备阶段,变量已经赋值过一次系统要求的初始化值,而在初始化阶段,则根据程序员通过通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度去表达:初始化阶段是执行类构造器<clinit>方法的过程。
1.<clinit>()方法是由编译器自动收集类中所有类变量的赋值操作和静态语句块的操作(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所确定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在他之后的变量,在前面的静态语句块中只能赋值,不能访问。
2.<clinit>()方法与类的构造函数或者说与实例构造器不同<init>方法不同,他不需要显示的调用父类的构造器,虚拟机会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。因此在虚拟机中第一个执行完毕的<clinit>方法一定是java.lang.Object.
3.由于父类的<clinit>方法先执行,也就意味着父类中定义的静态语句块要优于子类变量赋值操作。如图结果为2 不是1:
4.<clinit>方法对于类或者接口并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>方法。
5.接口中不能使用静态语句块,但是任然有变量初始化的赋值操作,因此接口和类一样都会生成<clinit>方法。但是接口与类不同的是,执行接口的<clinit>方法不需要先执行父类的<clinit>方法。只有当父类接口中定义的变量被使用时候,父接口才会被初始化。
6.虚拟机是会保证一个类的<clinit>方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程都需要等待,直到活动线程执行<clinit>方法完毕。
05)类加载器
对于任意一个类都需要加载他的类加载器和这个类本身一同来确立其在java虚拟机中唯一性。只有两个类是由同一个类加载器加载的前提下面才有意义,否则这两个类来源于同一个class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那么这两个类就必定不相等。
加载器的分类
启动类加载器:
这个类加载器将负责将<JAVA_HOME>\lib目录中的或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的的类库加载到内存中。启动类加载器无法被java程序直接引用。
扩展类加载器:
它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器:
一般也称为系统类加载器。他负责将用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果程序中没有自定义的类加载器,那么这个就是默认的类加载器。
这些类加载器和用户自定义的类加载器之间的额关系如图
类加载器之前形成了双亲委派模型:
如果一个类加载器收到了类加载的请求,他首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都是都应该传送到顶层的启动类加载器中,只有当父类加载器反馈无法加载的时候,字加载器才会去加载这个请求。双亲委派模型保证一个类只是被加载一次。