JVM-类加载机制
JVM-类加载机制
一、类加载过程
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
1.1 加载
在加载阶段,Java虚拟机需要完成以下三件事情:
- 1)通过一个类的全限定名来获取定义此类的二进制字节流。
- 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
至于我们要加载 .class文件来源在哪里,并没有明确的说明从哪里获取,如何获取,我们可以是:
- 从本地系统中直接加载;
- 通过网络下载.class文件;
- 从zip,jar等归档文件中加载.class文件;
- 从专有数据库中提取.class文件;
- 将Java源文件动态编译为.class文件。
对于数组类的加载有些特殊,我们需要介绍一下:
-
首先对于非数组类型的加载阶段:
这个是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。
-
对于数组类来说:
数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:
- 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上。
- 如果数组的组件类型不是引用类型(例如:int[]数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。
数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。
1.2 验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
- 1、文件格式验证:
- 是否以魔数0xCAFEBABE开头;
- 主、次版本号是否在当前Java虚拟机接受范围之内;
- 常量池的常量中是否有不被支持的常量类型(检查常量tag标志);
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
- 等等。
- 2、元数据验证:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类);
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类);
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法,等等。
- 3、字节码验证:(是这几个阶段中最复杂的一个阶段)
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作;
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上;
- 保证方法体中的类型转换总是有效的,等等。
- 4、符号引用验证:(该阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候)
- 符号引用中通过字符串描述的全限定名是否能找到对应的类;
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段;
- 符号引用中的类、字段、方法的可访问性(private、protected、public、package)是否可被当前类访问。等等。
1.3 准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。
需要注意的是:这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值。
如:public static int i = 123456;
该变量 i
在准备阶段赋予的初始值是 0 ,而不是 123456;只有在初始化阶段的时候,i
才会被赋值为 123456
需要注意的是:如果变量被 final
修饰之后:public static final int i = 123456;
这样在准备阶段 i
将被赋予 123456
下表是所有的基础数据类型的默认值:
1.3.1 说明类变量和实例变量的区别
在Java中,类变量(也称为静态变量)是属于类本身而不是特定实例的变量。这意味着所有类的实例共享类变量的相同值。另一方面,实例变量(也称为非静态变量)是属于特定类实例的变量,每个实例可以具有自己的实例变量值。
public class InstantTest {
// 类变量
public static int classVar = 0;
// 实例变量
public int instanceVar = 0;
public static void main(String[] args) {
// 创建两个 InstantTest 实例
InstantTest obj1 = new InstantTest();
InstantTest obj2 = new InstantTest();
// 使用其中一个实例修改 classVar
obj1.classVar = 1;
// 使用两个实例修改 instanceVar
obj1.instanceVar = 2;
obj2.instanceVar = 3;
// 输出变量的值
System.out.println("classVar = " + InstantTest.classVar);
System.out.println("obj1.classVar = " + obj1.classVar);
System.out.println("obj2.classVar = " + obj2.classVar);
System.out.println("----------------------");
System.out.println("obj1.instanceVar = " + obj1.instanceVar);
System.out.println("obj2.instanceVar = " + obj2.instanceVar);
}
}
classVar = 1
obj1.classVar = 1
obj2.classVar = 1
----------------------
obj1.instanceVar = 2
obj2.instanceVar = 3
在这个示例中, classVar 是一个类变量,instanceVar 是一个实例变量。
当我们使用其中一个实例修改 classVar(obj1.classVar = 1)时,所有类实例的 classVar 值都会更改(obj2.classVar 也变为 1 了)。
但是,当我们使用两个实例修改 instanceVar ( obj1.instanceVar = 2 和 obj2.instanceVar = 3 )时, instanceVar 的值对于每个实例都是不同的。
这表明 classVar 对于两个实例具有相同的值(1),而 instanceVar 对于每个实例具有不同的值( obj1 的值为 2 , obj2 的值为 3 )。
1.4 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
-
符号引用(Symbolic References):
符号引用是一种编译时引用,它在编译时就已经确定了;是指用类、接口、方法、字段等符号来描述所引用的目标,而不是直接指向目标本身。在编译阶段,Java编译器会将符号引用替换为直接引用。符号引用可以理解为一种代号,它代表着实际的引用对象。
-
直接引用(Direct References):
直接引用是一种运行时引用,它是在程序运行时动态生成的;是指直接指向目标的指针、相对偏移量或者是一个能够定位到目标的句柄。在程序运行时,直接引用会被JVM解析为实际的内存地址。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和、CONSTANT_InvokeDynamic_info 8种常量类型。
对前4种类型解析进行介绍:
1、类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要包括以下3个步骤:
-
1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如:加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
-
2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似
[Ljava/lang/Integer
的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是java.lang.Integer
,接着由虚拟机生成一个代表该数组维度和元素的数组对象。 -
3)如果上面两步没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出
java.lang.IllegalAccessError
异常。
针对上面第3点访问权限验证,在JDK 9引入了模块化以后,一个public类型也不再意味着程序任何位置都有它的访问权限,我们还必须检查模块间的访问权限。
如果我们说一个D拥有C的访问权限,那就意味着以下3条规则中至少有其中一条成立:
-
被访问类C是public的,并且与访问类D处于同一个模块。
-
被访问类C是public的,不与访问类D处于同一个模块,但是被访问类C的模块允许被访问类D的模块进行访问。
-
被访问类C不是public的,但是它与访问类D处于同一个包中。
在后续涉及可访问性时,都必须考虑模块间访问权限隔离的约束,即以上列举的3条规则,这些内容在后面就不再复述了。
2、字段解析
如果解析成功完成,那把这个字段所属的类或接口用C表示,《Java虚拟机规范》要求按照如下步骤对C进行后续字段的搜索:
-
1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
2)否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
-
4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。
如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
3、方法解析
继续用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:
- 1)由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在类的方法表中发现class_index中索引的C是个接口的话,那就直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 2)如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出
java.lang.AbstractMethodError
异常。 - 5)否则,宣告方法查找失败,抛出
java.lang.NoSuchMethodError
。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError
异常。
4、接口方法解析
继续用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:
- 1)与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那么就直接抛出
java.lang.IncompatibleClassChangeError
异常。 - 2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
- 4)对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性。
- 5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
在JDK 9之前,Java接口中的所有方法都默认是public的,也没有模块化的访问约束,所以不存在访问权限的问题,接口方法的符号解析就不可能抛出java.lang.IllegalAccessError
异常。但在JDK 9中增加了接口的静态私有方法,也有了模块化的访问约束,所以从JDK 9起,接口方法的访问也完全有可能因访问权限控制而出现java.lang.IllegalAccessError
异常。
1.5. 初始化
1、初始化定义
初始化阶段:为类的静态变量赋予正确的初始值。
如:int a = 3;
在连接的准备期间,赋予的是默认值 a = 0 ,初始化阶段,就赋予正确的初始值 a = 3
进行准备阶段时,变量已经赋过一次系统要求的初始默认值。
其实,初始化阶段就是执行类构造器<clinit>()
方法的过程;<clinit>()方法
是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。
编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
如下面例子,给变量正常赋值可以,但是却不能访问:
public class Test {
static {
i = 0; // 给变量复制可以正常编译通过
System.out.print(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
2、初始化时机
-
Java程序对类的使用方式可分为两种:
- 主动使用
- 被动使用
-
所有的Java虚拟机实现必须在每个类或接口被Java程序”首次主动使用“时才能初始化他们。
也就是说:①被动使用时不初始化;②再次使用时不用初始化。即初始化只会执行一次。
主动使用的7种情况:
-
①、创建类实例
如:new一个object对象。 A a = new A()
-
②、访问某个类的或接口的静态变量,或者对该静态变量赋值
前半句可以理解为:对静态变量的 取值 :getstatic,后半句:对静态变量的赋值,putstatic
-
③、调用类的静态方法
invokestatic
-
④、反射(如:Class.forName("com.test.Test") )
-
⑤、初始化一个类的子类
如:有两个类,一个是Parent ,一个是Child
class Parent{} class Child extends Parent{} 当对Child初始化时,也会对Parent进行初始化
-
⑥、Java虚拟机启动时被标明为启动类的类: main
-
⑦、JDK1.7开始提供的动态语言支持
java.lang.invoke.MehtodHandle实例的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic句柄的类没有初始化,则初始化。
-
除了以上7种情况,其他使用Java类的方法都被看做是对类的被动使用,都不会导致类的初始化。
3、类、接口初始化规则
-
①、Java虚拟机会保证在子类的
<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。下面代码最终打印的结果是
2
而不是1
: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); }
-
② 与类不同,接口的初始化,不需要父类接口先初始化。
接口与类不同的是,执行接口的
<clinit>()
方法不需要先执行父接口的<clinit>()
方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()
方法。public class MyTest5 { public static void main(String[] args) { System.out.println(MyChild5.b); } } interface MyParent5{ public static int a = 6; } interface MyChild5 extends MyParent5{ public static int b = 5; }
运行结果:
结果分析:
可以看到,这里只有MyTest5被加载了,也就是MyParent5连加载都没有,更别说初始化了,得到的 5 是从常量池中获取的。
-
③、在初始化一个类时,并不会先初始化它所实现的接口。
public class MyTest5 { public static void main(String[] args) { System.out.println(MyChild5.b); } } interface MyParent5{ public static Thread thread = new Thread(){ { System.out.println("MyParent5 invoked"); } }; } class MyChild5 implements MyParent5{ public static int b = 5; }
运行结果:
说明,首先MyParent5、MyChild5被加载,MyChild5被初始化了(因为public static int b = 5;不是静态变量),如果MyParent5被初始化了,那么{…}代码一定会在构造方法之前被输出,这里没有输出,则证明:在初始化一个类时,并不会先初始化它所实现的接口。
4、代码块
-
实例一:
public class StaticLump { public static void main(String[] args) { new C(); new C(); } } class C{ public C(){ System.out.println("C"); } { System.out.println("Hello"); } } 运行结果: Hello C Hello C
-
实例二:
public class StaticLump { public static void main(String[] args) { new C(); new C(); } } class C{ public C(){ System.out.println("C"); } static { System.out.println("Hello"); } } 运行结果: Hello C C
-
总结:
- ①代码块在类中的位置并不会改变其"最先"被执行的结果,不论是否由static修饰。
- ②static修饰的静态代码块只会被执行一次[在构造方法执行之前],不被修饰的则在每一次类被初始化时构造方法之前都会执行一次。
二、双亲委派机制
2.1 类加载器
类的加载的最终产品是位于内存中的Class对象。Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。
Java虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
// 这里通过 重写 ClassLoader的loadClass方法,实现一个自定义类加载器
ClassLoader myLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try {
String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
InputStream is = getClass().getResourceAsStream(fileName);
// 这个步骤是一定要有的!!!
// 目的是为了防止:
// 当使用自定义类加载器加载的类依赖 像:java.lang.Object、java.lang.String这类的时候,
// 自定义加载器无法加载这些类。
if (is == null) {
// 这里就要 手动的调用super.loadClass(name)
// 目的就是为了使用 本身的三个类加载器去加载对应路径在的类。
return super.loadClass(name);
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
};
// 这里使用自定义类加载器加载jvm.part7.ClassLoaderTest对象
Object obj = myLoader.loadClass("jvm.part7.ClassLoaderTest").newInstance();
System.out.println(obj.getClass());
System.out.println("使用的类加载器:" + obj.getClass().getClassLoader());
// jvm.part7.ClassLoaderTest 这个是由 应用类加载器加载的。
System.out.println(obj instanceof jvm.part7.ClassLoaderTest );
System.out.println("使用的类加载器:" + jvm.part7.ClassLoaderTest.class.getClassLoader());
}
}
执行结果:
class jvm.part7.ClassLoaderTest
使用的类加载器:jvm.part7.ClassLoaderTest$1@4141d797 // 自定义类加载器
false
使用的类加载器:sun.misc.Launcher$AppClassLoader@18b4aac2 // 系统类加载器
2.2 双亲委派模型
站在Java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(BootstrapClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。
如果更细的划分就是 三层类加载器、双亲委派的类加载架构。
两种类型的类加载器:
-
Java虚拟机自带的加载器
-
根类加载器(Bootstrap):
这个类加载器负责加载存放在
<JAVA_HOME>\lib
目录,或者被-Xbootclasspath
参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。 如:
java.lang.*
,java.lang.Object就是由根类加载器加载的。 根类加载器是由C++语言实现的,属于虚拟机实现的一部分,并没有继承
java.lang.ClassLoader
类 -
扩展类加载器(Extension):
这个类加载器是在类
sun.misc.Launcher$ExtClassLoader
中以Java代码的形式实现的。 它负责加载
<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中所有的类库。 根据“扩展类加载器”这个名称,就可以推断出这是一种Java系统类库的扩展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
-
系统(应用)类加载器(System):
这个类加载器由
sun.misc.Launcher$AppClassLoader
来实现。由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,所以有些场合中也称它为“系统类加载器”。它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
-
-
用户自定义的类加载【除了上面提到的三种虚拟机自带的类加载】
- java.lang.ClassLoader的子类
- 用户可以定制类的加载方式 【即所有用户自定义的类加载器都应该继承ClassLoader】
双亲委派模型的工作过程是:
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
如下图所示:
2.3 破坏双亲委派模型
像我们平时用的一些SPI
:JDBC,JCE(Java Cryptography Extension 它是一组包,提供用于加密、密钥生成),JAXB(Java Architecture for XML Binding 是一个业界的标准,是用于 XML 绑定的 Java 体系结构(JAXB)是允许将 Java 类映射到 XML 表示形式的软件框架) ;这些组件都可以是不同厂商提供的,而且 Java本身的类加载器不能识别。
为了解决这个问题,就引入了:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread
类的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则。
转载自:https://juejin.cn/post/7345310754469740578