类加载机制
最后更新于
这有帮助吗?
最后更新于
这有帮助吗?
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机使用的 Java 类型,这就是虚拟机的类加载机制。
对于类加载过程开始执行「加载」的时机,JVM 规范并没有强制约束,但是对于类的「初始化」阶段,虚拟机规范则严格规定了有且只有5种情况必须立即对类进行初始化(加载、验证等过程自然需要在此之前开始):
遇到 new/getstatic/putstatic/invokestatic 这4条字节码指令时,Java 代码场景:使用new
实例化一个类时、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、调用一个类的静态方法。
使用 java.lang.reflect 包的方法对类进行反射调用时
初始化一个类时,如果它的父类未被初始化,则需要先初始化父类
虚拟机启动时,用户要指定一个要执行的主类(包含main方法),虚拟机会先初始化这个类
当使用JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄对应的类没有进行过初始化
以上的情况为「主动引用」,对于其他「被动引用」的情况不会触发类的初始化。
典型的「被动引用」场景:
不会触发 SuperClass 的初始化,但是会触发一个名为"[Lcom.test.SuperClass"的类的初始化,这个类时虚拟机自动生成的,直接继承自java.lang.Object,创建动作有字节码指令 newarray 触发。
虽然在 Java 源码中引用了 ConstClass 的常量,但是在编译阶段存在常量传播优化,已经将此常量的值存储到了 TestClass 类的常量池中,因此此处引用实际是对 TestClass 常量池中常量的引用。也就是说,TestClass 的 Class 文件之中并没有ConstClass 的引用符号,两个类在编译后就没有关系了。
对于接口的加载,编译器会为其生成<clinit>类构造器用于初始化接口中定义的成员变量。与类初始化不同的是,接口在出初始化时并不要求其父接口都完成了初始化,只有在真正使用到父接口时(比如使用父接口的常量)才回初始化。
加载过程需要完成下面三件事:
通过类的全限定名来获取定义此类的二进制字节流
将这个字节流代表的静态存储结构转化方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
对于字节流的获取,JVM 虚拟机规范并没有限定,所以有很多途径:
zip 包,比如 jar、EAR、WAR等形式
从网络中读取
运行时计算生成,动态代理技术。
由其他文件生成,JSP
....
非数组类的加载阶段是开发人员可控性最强的,因为加载阶段可以通过自定义的类加载器来完成,开发人员可以通过自定义类加载器来控制字节流的获取方式。
对于数组类来说,数组类本身不通过类加载器加载,它由JVM直接创建,但数组类的元素类型(数组去掉所有维度的类型)最终还是要靠类加载器去加载。
加载阶段完成后,二进制字节流就按照虚拟机所需的格式存储在方法区之中(方法区中的存储格式由虚拟机自行定义),然后在内存中实例化一个 Class 对象(并没有明确规定必须在堆内,对于 HotSpot 虚拟机,class 对象存储在方法区中),这个对象将作为程序访问方法区中类型数据的外部接口。
加载阶段与连接阶段的部分内容(如字节码格式验证)是交叉进行的。
连接的第一步,目的是确保Class文件字节流中包含的信息符合当前虚拟机要求,并且不会危害 虚拟机自身的安全。因为 Class 文件并不一定是由Java源码编译而成,可以通过任何途径产生。
验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。可能包括以下验证点:
是否以魔数 0xCAFEBABE 开头
主、次版本号是否在当前虚拟机处理范围中
常量池中的常量是否有不被支持的常量类型
指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
CONSTANT_Utf8_info 型的常量中是否有不符合 UTF-8编码的数据
...
这个验证阶段是基于字节流的,只有通过验证后字节流才回进入方法区,后面的验证将不会操作字节流,而是基于方法区中的存储结构
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,可能包括如下验证点:
这个类是否有父类(除 java.lang.Object 之外都应该有父类)
这个类的父类是否继承了不允许被继承的类(final 修饰)
如果类不是抽象类,是否实现了父类或接口中要求实现的方法
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,出现了不符合规的方法重载)
...
验证过程中最复杂的阶段,主要目的是通过数据流和控制流的分析,确定程序语义是合法的、符合逻辑的。这个阶段会对类的方法体进行校验分析,保证方法在运行时不会做出危害虚拟机的行为,例如:
保证任意时刻操作数栈的数据类型与指令代码序列能配合工作,不会出现如在操作数栈放了以int,使用时却按照long类型加载到本地变量表中的情况
保证跳转指令不会跳转到方法体外的字节码指令上
保证方法体的类型转换是有效的
为了避免字节码验证阶段消耗过多的时间,JDK 1.6 之后给方法表的Code属性的属性表增加了"StackMapTable"属性,这项属性描述了方法体的所有基本块(Basic Block,按照控制流拆分的代码块)开始时,本地变量表和操作数栈应有的状态,在字节码验证期间,就不用在根据程序推导这些状态的合法性,只需要检查 StackMapTable 的记录是否合法即可。
StackMapTable 也存在被篡改的可能。
这个阶段的验证发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在「解析」过程中发生。符号引用验证可以看做是对类自身以外的信息进行匹配性校验。通常校验以下内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类
指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
符号引用中的类、方法、字段的访问性(public/private/protected)是否可供当前类访问
...
符号引用验证的目的是为了保证解析阶段能够正常执行,如果没有通过符号引用验证,将会抛出java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError.
正式为类变量分配内存并设置变量初始值的阶段,这些变量使用的初始值都将在方法区分配。进行内存分配的是类变量而不是实例变量,变量会被赋值为类型的初始值。如果一个类变量定义如下:
那么在此阶段a的值为0,赋值为123的过程在初始化阶段。
只有被final修饰的常量才会被赋成设定的值:
编译时会为value生成 ConstantValue 属性,在准备阶段就会根据 ConstantValue 属性将 value 设置为 123。
将常量池中的符号引用替换为直接引用过程,符号引用在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。
符号引用(Symbolic References):以一组符号描述引用目标,可以是任何形式的字面量,只有能无歧义的定位到引用目标即可。符号引用于虚拟机的内存布局无关,引用的目标并不一定已经加载到内存。各虚拟机实现的内存布局可以不相同,但接受的符号引用必须是一致的。
直接引用(Direct References):可以是指向引用目标的指针、偏移量或者能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局是相关的。如果有了直接引用,那引用目标必然在内存中已存在。
解析工作主要针对类和接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
类加载过程最后一步,真正开始执行类中定义的 Java 程序代码,这个阶段会根据程序员制定的主观计划去初始化类变量和其他资源,即执行<clinit>构造器的过程。
虚拟机会保证一个类的<clinit>方法在多线程环境下被正确的加锁、同步,如果多个线程同时初始化一个类,只有一个线程会执行<clinit>方法,其他线程需要阻塞等待,直到活动线程执行<clinit>()方法完毕。
等待的线程不会再执行<clinit>()方法,同一个类加载器下,一个类只会被初始化一次。
类加载器实现了通过一个类的全限定名来加载这个类的二进制字节流这个动作。
对于任意一个类,都需要加载它的类加载器以及这个类本身共同确定它在JVM中的唯一性,每个类加载器,都拥有独立的类名称空间。即,比较两个类是否“相等”,只有这两个类是由同一个类加载器加载的前提下才有意义。
从 JVM 角度看,只有两种类加载器:
启动类加载器(Bootstrap ClassLoader):使用C++实现,是 JVM 的一部分
其他类加载器:使用 Java 实现,独立于JVM,且必须继承自抽象类 java.lang.ClassLoader
从开发人员的角度来说,系统提供了三种类加载器:
启动类加载器(Bootstrap ClassLoader):同上,这个类加载器负责把 <JAVA_HOME>/lib 目录中、或者被 -Xbootclasspath 参数中指定的路径中JVM能识别(仅按照文件名识别)的类库加载到内存中。启动类加载器无法被Java程序直接引用,在编写自定义类加载器时,如果需要委托给启动类加载器,返回null即可。
扩展类加载器(Extension ClassLoader):这个加载器由 sun.misc.Launcher$ExtClassLoader
实现,负责加载 <JAVA_HOME>/lib/ext 目录中或 java.ext.dirs 系统变量所制定的路径中的类库。开发者可以直接使用这个类加载器。
应用程序类加载器(Application ClassLoader):由 sun.misc.Launcher$ExtClassLoader
实现,由于这个类加载器是 ClassLoader 中 getSystemClassLoader() 方法的返回值,所以一般也成为系统类加载器。它负责加载用户类路径(ClassPath)上指定的类库,开发者可以直接使用这个类加载器,如果没有实现自定义类加载器,应用程序类加载器将会是默认的加载器。
类加载器的层次关系如下图,这被称为Parents Delegation Model(双亲委派模型),从 JDK 1.2开始引入。
除了顶层的启动类加载器外,其他类加载器都应该有自己的父类加载器。类加载器之间的父子关系通常使用组合(而非继承)关系来实现。
类加载器收到类加载请求时,会先委托父类加载器进行加载,每个层次的类加载器都是如此,因此所有的类加载请求都会传送到顶层的启动类加载器中,当父类加载器无法完成加载请求时(搜索范围没有找到对应类),子加载器才会尝试自己加载。
使用双亲委派模型组织类加载器,使得类同加载器一起具备带有优先级的层次关系。例如java.lang.Object ,无论哪个类加载器想要加载这个类,最终都会委派给启动类加载器进行加载,这保证了 Object 在不同类加载器环境中都是同一个类,防止出现多份同样的字节码以及核心类被篡改。
检查类是否已经加装
如果存在父类加载器,调用父类加载器进行加载
父类加载器不存在,调用启动类加载器加载
如果发生 ClassNotFound,调用自己的 findClass 方法加载
OSGI 框架原理: