类加载的过程
# 类加载的过程
了解的JVM跟字节码,或许你会好奇,JVM是怎么读取的字节码文件的,或者说,读取完成后,它主要做了哪些工作,是否有进行优化。
运行 java 程序的第一步就是加载 class 文件/或输入流里面包含的字节码。对应到实际的操作就是,使用命令来运行.class文件,或者jar文件。
java -jar hello.jar
Jar 包打开后实际上就等价于一个文件夹,里面有很多 class 文件和资源文件,但是为了方便就打包成 zip 格式。 当然解压了之后照样可以直接用 java 命令来执行。
那之后发生了什么呢?
# 类加载的几个阶段
按照 Java 语言规范和 Java 虚拟机规范的定义, 我们用 “类加载
(Class Loading)” 来表示: 将 class/interface 名称映射为 Class 对象的一整个过程。 通俗一些,类加载就是,JVM读取字节码文件到JVM把数据准备好的这个过程。
这个过程(类加载)还可以划分为更具体的阶段: 加载,链接和初始化(loading, linking and initializing)。
类加载的过程均JVM中完成。以下是详细说明,且按顺序依次进行:
- 加载(Loading)
- 连接(Linking)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
# 加载
加载阶段也可以称为“装载”阶段。 这个阶段主要的操作是: 根据明确知道的 class 完全限定名, 来获取二进制 classfile 格式的字节流,简单点说就是找到文件系统中/jar 包中/或存在于任何地方的“class 文件
”。 如果找不到二进制表示形式,则会抛出 NoClassDefFound
错误。
装载阶段并不会检查 classfile 的语法和格式。
或者用网上某个大佬总结的话,
加载是一个读取Class文件
,将其转化为某种静态数据结构
存储在方法区
内并在堆
中生成一个便于用户调用的java.lang.Class类型的对象
的过程。
# 链接
链接可以再次细分为3个阶段。
# 验证/校验
链接过程的第一个阶段是 校验
,确保 class 文件里的字节流信息符合当前虚拟机的要求,不会危害虚拟机的安全。
校验过程检查 classfile 的语义,判断常量池中的符号,并执行类型检查, 主要目的是判断字节码的合法性,比如 magic number, 对版本号进行验证。 这些检查过程中可能会抛出 VerifyError
, ClassFormatError
或 UnsupportedClassVersionError
。
因为 classfile 的验证属是链接阶段的一部分,所以这个过程中可能需要加载其他类,在某个类的加载过程中,JVM 必须加载其所有的超类和接口。
如果类层次结构有问题(例如,该类是自己的超类或接口,死循环了),则 JVM 将抛出 ClassCircularityError
。 而如果实现的接口并不是一个 interface,或者声明的超类是一个 interface,也会抛出 IncompatibleClassChangeError
。
实现方式:由类加载器(ClassLoader
)完成,遵循双亲委派模型。
# 准备
然后进入准备阶段,这个阶段将会创建静态字段
, 并将其初始化为标准默认值(比如null
或者0 值
),并分配方法表,即在方法区中分配这些变量所使用的内存空间。
请注意,准备阶段并未执行任何 Java 代码。
例如:
public static int i = 1;
在准备阶段i
的值会被初始化为 0,后面在类初始化阶段才会执行赋值为 1;但是下面如果使用 final 作为静态常量,某些 JVM 的行为就不一样了:
public static final int i = 1;
对应常量 i,在准备阶段就会被赋值 1,其实这样还是比较 puzzle,例如其他语言(C#)有直接的常量关键字 const,让告诉编译器在编译阶段就替换成常量,类似于宏指令,更简单。
# 解析
然后进入可选的解析符号引用阶段。 也就是解析常量池,主要有以下四种:类或接口的解析、字段解析、类方法解析、接口方法解析。
简单的来说就是我们编写的代码中,当一个变量引用某个对象的时候,这个引用在 .class
文件中是以符号引用来存储的(相当于做了一个索引记录)。
在解析阶段就需要将其解析并链接为直接引用(相当于指向实际对象)。如果有了直接引用,那引用的目标必定在堆中存在。
加载一个 class 时, 需要加载所有的 super 类和 super 接口。
Java中的多态特性就是发生在这阶段,多态就是所谓的后期绑定,因为之前是符号引用,现在替换成具体的直接引用。但因为有多个实现,不清楚链接哪个,这个时候就需要等待,通过解析,明确具体调用的对象。
# 初始化
JVM 规范明确规定, 必须在类的首次“主动使用”时才能执行类初始化。
初始化的过程包括执行:
- 类构造器方法
- static 静态变量赋值语句
- static 静态代码块
如果是一个子类进行初始化会先对其父类进行初始化,保证其父类在子类之前进行初始化。所以其实在 java 中初始化一个类,那么必然先初始化过 java.lang.Object
类,因为所有的 java 类都继承自 java.lang.Object。
只要我们尊重语言的语义,在执行下一步操作之前完成 装载,链接和初始化这些步骤,如果出错就按照规定抛出相应的错误,类加载系统完全可以根据自己的策略,灵活地进行符号解析等链接过程。 为了提高性能,HotSpot JVM 通常要等到类初始化时才去装载和链接类。 因此,如果 A 类引用了 B 类,那么加载 A 类并不一定会去加载 B 类(除非需要进行验证)。 主动对 B 类执行第一条指令时才会导致 B 类的初始化,这就需要先完成对 B 类的装载和链接。
以上5个阶段就是类加载,但类的生命周期实际还要包括:
- 使用
- 卸载
类的生命周期一共是7个大步骤,这里注意区别,别混淆了。
# 类加载器机制
类加载(也就是类加载的第一阶段)过程可以描述为“通过一个类的全限定名 a.b.c.XXClass 来获取描述此类的 Class 对象”,这个过程由“类加载器(ClassLoader)”来完成。这样的好处在于,子类加载器可以复用父加载器加载的类。系统自带的类加载器分为三种:
- 启动类加载器(BootstrapClassLoader)
- 扩展类加载器(ExtClassLoader)
- 应用类加载器(AppClassLoader)
以下是它们三者的详细介绍
- 启动类加载器(bootstrap class loader): 它用来加载 Java 的核心类,是用原生 C++ 代码来实现的,并不继承自 java.lang.ClassLoader(负责加载JDK中jre/lib/rt.jar里所有的class)。它可以看做是 JVM 自带的,我们再代码层面无法直接获取到启动类加载器的引用,所以不允许直接操作它, 如果打印出来就是个
null
。举例来说,java.lang.String 是由启动类加载器加载的,所以 String.class.getClassLoader() 就会返回 null。但是后面可以看到可以通过命令行参数影响它加载什么。 - 扩展类加载器(extensions class loader):它负责加载 JRE 的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的 JAR 包的类,代码里直接获取它的父类加载器为 null(因为无法拿到启动类加载器)。
- 应用类加载器(app class loader):它负责在 JVM 启动时加载来自 Java 命令的 -classpath 或者 -cp 选项、java.class.path 系统属性指定的 jar 包和类路径。在应用程序代码里可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取应用类加载器。如果没有特别指定,则在没有使用自定义类加载器情况下,用户自定义的类都由此加载器加载。
所以开发者编写的类是由哪种加载器进行加载呢?
答案是:应用类加载器。
开发者编写的类(即应用程序类,位于用户类路径 ClassPath
下)默认由 应用程序类加载器(Application ClassLoader,也称系统类加载器) 加载。这是JVM双亲委派模型
中的第三层类加载器,负责加载用户代码中的类。
# 其他类加载器(用户自定义类加载器)
刚刚介绍的是系统自带的加载器,除了上述三种核心类加载器,开发者也可以 自定义类加载器(继承ClassLoader
类),常见于以下场景:
- 热部署:如Tomcat为每个Web应用提供独立的
WebappClassLoader
,实现类隔离和动态加载。 - 类隔离:避免依赖冲突(如OSGI框架)。
- 加密加载:动态解密字节码后加载。
# 双亲委派模型的工作流程
那有这么多类加载器,一个类过来了,系统怎么知道它是来自哪里的,用哪种加载器进行加载呢?实时上它不会主动检查类来自哪里,而是通过一种流程来进行解决,也就是我们说的双亲委派机制
,当类加载器收到加载请求时,按以下顺序处理:
- 先委派给父类加载器加载。
- 若父类加载器无法完成(在自己的搜索范围内找不到类),则由当前类加载器加载。
示例:
- 用户编写一个
java.lang.String
类:- 应用程序类加载器委派给扩展类加载器,再委派给启动类加载器。
- 启动类加载器发现已加载核心
String
类,直接返回,用户自定义的String
类不会被加载,避免核心类被篡改。
# 类加载器的归属关系总结
类加载器类型 | 归属 | 实现方式 | 典型代表 |
---|---|---|---|
启动类加载器 | JVM核心 | C/C++ | 加载rt.jar |
扩展类加载器 | JVM提供 | Java | 加载ext 目录 |
应用程序类加载器 | JVM提供 | Java | 加载用户ClassPath |
自定义类加载器 | 开发者实现 | Java(继承) | Tomcat的WebappClassLoader |
# 关键区别与注意
- JVM内置类加载器 vs 自定义类加载器:
- JVM内置的类加载器是JVM启动时自动创建的,负责加载基础类库和用户类。
- 自定义类加载器需显式实例化,用于扩展类的加载来源或实现特殊逻辑。
- 双亲委派模型的打破:
- 默认情况下,所有类加载器遵循双亲委派模型(优先委派父加载器)。
- 自定义类加载器可通过重写
loadClass()
方法打破此模型(如Tomcat优先加载Web应用自身的类)。
# 加载机制特点
类加载机制有三个特点:
- 双亲委托:当一个自定义类加载器需要加载一个类,比如 java.lang.String,它很懒,不会一上来就直接试图加载它,而是先委托自己的父加载器去加载,父加载器如果发现自己还有父加载器,会一直往前找,这样只要上级加载器,比如启动类加载器已经加载了某个类比如 java.lang.String,所有的子加载器都不需要自己加载了。如果几个类加载器都没有加载到指定名称的类,那么会抛出 ClassNotFountException 异常。
- 负责依赖:如果一个加载器在加载某个类的时候,发现这个类依赖于另外几个类或接口,也会去尝试加载这些依赖项。
- 缓存加载:为了提升加载效率,消除重复加载,一旦某个类被一个类加载器加载,那么它会缓存这个加载结果,不会重复加载。