JVM系列第二篇——类加载子系统
Java 虚拟机把描述类的数据从Class文件加载到本地内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称为虚拟机的类加载机制。
与编译时需要进行连接的语言不同,Java中类型的加载、连接和初始化过程都是在程序运行期间完成的,虽然这让类加载时稍微增加一些性能开销,但却让Java应用提供了极高的拓展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
例如,编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络或其他地方上加载一个二进制流作为其程序代码的一部分。
1、类加载的时机
一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eLklU7KX-1655885353835)(https://fastly.jsdelivr.net/gh/Shark-Ele/image_files/img/202206142111370.png)]
加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定,这是为了支持Java语言的运行时绑定特性(即动态绑定或晚绑定)。《Java虚拟机规范》并没有强制约束何时“加载”,但是严格规定了有且仅有以下六种情况必须立即对类进行“初始化”:
-
遇到new、getstatic、putstatic 或 invokestatic 这四条字节码指令(分别对应Java代码:使用 new 实例化对象、读取或设置一个static类型的字段,但被final修饰和已在编译期把结果放入常量池的静态字段除外、调用一个类型的静态方法)
-
使用 java.lang.reflect 包的方法对类型进行反射调用
-
初始化类时,发现其父类还未初始化,则需要先触发其父类的初始化
-
虚拟机启动时,用户需要指定一个要执行的主类(包含main方法的类),虚拟机会先初始化主类
-
当使用JDK 7新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄
-
接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)
接口与类的初始化主要区别在于第三种情况,初始化接口时并不要求其父接口全部完成初始化,只有在使用到父接口时才会初始化。
除上述六种情况外,其它使用Java类的方式都会被看做是被动引用,都不会导致类的初始化。
// 被动引用实例一:通过子类引用父类的静态字段,不会导致子类初始化
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 10;
}
class SubClass extends SuperClass{
static {
System.out.println("SubClass init!");
}
}
public class JvmDemo01 {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
// 打印 SuperClass init! 和 10,未打印 SubClass init!,说明子类未初始化
说明:只有直接定义静态字段的类才会被初始化,上述通过子类调用父类中的静态字段时不会触发子类初始化,但是否触发子类的加载和验证阶段,这取决于不同虚拟机的具体实现。
// 被动引用实例二:通过数组定义来引用类,不会触发此类的初始化
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 10;
}
public class JvmDemo02 {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}
// 未打印 SuperClass init!,说明没有初始化 SuperClass 类
说明:上述数组引用并未初始化SuperClass类,但是触发“[Lorg.fenixsoft.classloading.SuperClass” 类的初始化,该类由虚拟机自动生成,代表了元素类型为 SuperClass 的一维数组,数组的属性、方法都实现在该类中。由此可见,Java语言通过这个包装类访问数组,相比于C/C++直接操作数组的地址要更加安全,如Java访问数组越界可以会报异常,避免非法内存访问。
// 被动引用实例三:常量在编译时放入常量池,本质上没有直接引用到定义常量的类,因此不触发类的初始化
class ConstClass {
static {
System.out.println("SuperClass init!");
}
public static final int value = 10;
}
public class JvmDemo03 {
public static void main(String[] args) {
System.out.println(ConstClass.value);
}
}
// 只打印 10,说明未初始化ConstClass类,直接打印常量池中的数据
说明:编译阶段通过常量传播优化,类中的常量已经直接存储在常量池中,之后对类中常量的引用实际上是对自身常量池的引用。上述例子中编译后的常量 value 已经在 JvmDemo06 类的常量池中,两个类在编译后已经不存在联系。
2、类加载的过程
类加载:加载、验证、准备、解析和初始化。
2.1 加载
加载阶段,Java虚拟机完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成代表这个类的 java.lang.Class 对象,作为方法区该类数据的访问入口
2.2 验证
验证阶段是为了确保Class文件的字节流包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当做代码运行后不会危害虚拟机自身的安全。
Java语言本身相对安全,如果程序中出现一些非法的代码,会被编译器抛出异常甚至拒绝编译。但Class文件不一定只能由Java源码编译,熟悉字节码原理的人甚至可以直接敲出Class文件,且字节码层面可以做到Java代码层面无法实现的事情,若Java虚拟机不检查输入的字节流,对其完全信任,那么可能会因有错误或恶意攻击的字节码流导致系统崩溃。
验证主要包括四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
2.3 准备
准备阶段是正式为类变量(静态变量)分配内存(方法区)并设置初始值(零值)。注意 静态常量 在编译时已经被存入常量池,在准备阶段就会显式赋值;不会为 实例变量 分配内存,因为实例变量将会在对象实例化时随着对象一起分配在Java堆中。
public class HelloApp {
// 准备阶段后value=0,此时未执行任何Java程序
// 程序编译后会把value赋值的putstatic指令存放到<clinit>中,因此初始化阶段后value=1
private static int value1 = 1;
// 编译时javac将会为value生成ConstantValue属性,准备阶段就会将value赋值为2
private static final value2 = 2;
public static void main(String[] args) {
System.out.println(value1);
}
}
2.4 解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7类符号引用进行,分别对应于常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dynamic_info 和 CONSTANT_InvokeDynamic_info 8种常量类型。
- 符号引用(Symbolic References):以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到当前目标即可
- 直接引用(Direct References):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄
2.5 初始化
前面介绍的几个阶段,除了在加载阶段用户可以自定义类加载器的方式外,其余动作完全由Java虚拟机来主导。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
在准备阶段,变量已经赋过一次初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类和其它资源,或者说 初始化阶段就是执行类的构造器<clinit>()方法的过程。
<clinit>()方法
- <clinit>()方法是 Javac 编译器的自动生成物,是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句结合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。注意静态代码块只能访问到定义在它之前的变量,定义在它之后的变量只能对其赋值,不能访问。
public class JvmDemo04 {
static {
value = 2; // value定义在静态代码块之后,可以赋值
System.out.println(value); // 不能访问value,编译器会提示“非法前向引用”
}
static int value = 1; // 类变量赋值和静态代码块按定义顺序执行
}
- <clinit>()方法与类的构造函数不同,它不需要显式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
class Parent {
// 父类先于子类初始化,且类变量赋值和静态代码块按定义顺序执行,这里初始化后A=2
public static int A = 1;
static {
A = 2;
}
}
class Child extends Parent{
public static int B = A;
}
public class JvmDemo05 {
public static void main(String[] args) {
System.out.println(Child.B); // 结果为2
}
}
- <clinit>()方法对于类或接口来说并不是必需的,若类中没有静态代码块,也没有对类变量的赋值操作,那么编译器可以不为这个来生成<clinit>()方法。
- 接口中不能使用静态代码块,但仍然有类变量初始化的赋值动作,因此和类一样存在<clinit>()方法。但执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有父接口中变量被使用到才会被初始化。
- Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类中的<clinit>()方法,其它线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。
class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread().getName() + " init DeadLoopClass");
while (true) {
}
}
}
}
public class JvmDemo06 {
public static void main(String[] args) {
Runnable r = () -> {
System.out.println(Thread.currentThread().getName() + " start");
DeadLoopClass d = new DeadLoopClass();
System.out.println(Thread.currentThread().getName() + " run over");
};
Thread t1 = new Thread(r, "thread1");
Thread t2 = new Thread(r, "thread2");
t1.start();
t2.start();
}
}
// 结果为:thread1 start、thread2 start、thread1 init DeadLoopClass
// 这说明程序阻塞在线程1的静态代码块中,线程2必须要等线程1初始化结束才能得到执行
3、类加载器
Java虚拟机团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”(Class Loader)。
3.1 类与类加载器
从Java虚拟机角度来看只存在两种类加载器:(1)启动类加载器(Bootstrap ClassLoader),由C++语言实现,是虚拟机自身的一部分;(2)自定义类加载器(User-Defined ClassLoader),由Java语言实现,独立于虚拟机外,全部继承自抽象类 java.lang.ClassLoader。
从开发角度看,类加载器可以进一步划分,自 JDK 1.2 以来,Java一直保持三层类加载器、双亲委派的类的加载架构,绝大多数Java程序都会用到3种类加载器:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-POSDW6jI-1655885353836)(https://fastly.jsdelivr.net/gh/Shark-Ele/image_files/img/202206152147124.png)]
- 启动类加载器(Bootstrap Class Loader)
负责加载存放在 <JAVA_HOME>\lib 目录下的Java核心类库,出于安全考虑,启动类加载器只加载包名为 java、javax、sun等开头的类。启动类加载器无法被Java程序直接引用,若自定义加载器需要把加载请求委托给引导类加载器处理,直接使用null值替代即可,java.lang.ClassLoader.getClassLoader() 方法的注释和代码实现也明确以null代表引导类加载器。
- 拓展类加载器(Extension Class Loader)
负责加载 <JAVA_HOME>\lib\ext 目录中,或者被 java.ext.dirs 系统变量所指定的路径中所有的类库。用户可以将具有通用性的类库放置在 ext 目录中以拓展 Java SE 的功能。由于拓展类加载器时由 Java 代码实现的,开发者可以在程序中直接使用拓展类加载器来加载 Class 文件。
- 系统类加载器(Application Class Loader)
负责加载用户类路径下的所有类库,开发者可以直接在代码中使用这个类加载器,若应用程序中没有自定义自己的类加载器,默认就是使用这个系统类加载器。
// 程序使用的类加载器示例
public class JvmDemo07 {
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取上层:拓展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader@1b6d3586
// 获取上层:启动类加载器(为null)
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader); // null
// 对于用户自定义类,默认使用系统类加载器进行加载
ClassLoader classLoader = JvmDemo09.class.getClassLoader();
System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// Java的核心类库使用引导类加载器进行加载
ClassLoader stringClassLoader = String.class.getClassLoader();
System.out.println(stringClassLoader); // null
}
}
每个类加载器都有一个独立的类名称空间,类和类加载器共同确定其在Java虚拟机中的唯一性。因此两个类是否“相等”,只有两个类由同一个类加载器加载的前提下才有意义,否则即使两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类也必定不相等。
ClassLoader类:抽象类,所有的自定义类加载器都继承自 ClassLoader 类。
获取ClassLoader的途径:
- 获取当前类的ClassLoader:clazz.getClassLoader
- 获取当前线程上下文的ClassLoader:Thread.currentThread().getContextClassLoader()
- 获取系统的ClassLoader:ClassLoader.getSystemClassLoader()
- 获取调用者的ClassLoader:DriverManager.getCallerClassLoader()
public class JvmDemo08 {
public static void main(String[] args) {
ClassLoader classLoader1 = String.class.getClassLoader();
ClassLoader classLoader2 = Thread.currentThread().getContextClassLoader();
ClassLoader classLoader3 = ClassLoader.getSystemClassLoader().getParent();
}
}
3.2 双亲委派模型
双亲委派机制的流程:若一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围内找不到所需的类)时,子加载器才会尝试自己去完成加载。
// 自定义java.lang.String类,运行以下代码报错
// 原因是由于双亲委派机制,String先由顶层的启动类加载器加载
// 启动类加载器在自己管理的类路径下找到了String核心类,但是找不到main方法,加载报错
package java.lang;
public class String {
static {
System.out.println("测试");
}
public static void main(String[] args) {
System.out.println("Hello, String");
}
}
// 自定义java.lang.JvmDemo09类,启动运行后报错
// 原因是java.lang注册在启动类加载器中,启动类加载器在该路径下找不到JvmDemo09类
// 该设计也是为了保护Java核心类库的安全性,避免核心类库被篡改
package java.lang;
public class JvmDemo09 {
public static void main(String[] args) {
System.out.println("Hello, Java");
}
}
3.3 沙箱安全机制
沙箱是一个限制程序运行的环境。沙箱机制就是将Java代码限定在Java虚拟机特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。https://www.csdn.net/tags/MtTaAg5sMDIzMzQtYmxvZwO0O0OO0O0O.html
类加载采用的双亲委派模型是沙箱安全机制的一种实现,通过三层类加载器,从上往下依次加载不同等级的类库,实现对Java核心源代码的保护。