概述
发生位置
.java -> javac编译 -> .class -
--No-> 解释器-> 机器可理解的代码 -> 具体的机器
--> 热点代码
--Yes-> JIT -> 机器可理解的代码 -> 具体的机器
类加载过程发生在:解析字节码的过程
加载流程
从.class文件到加载到内存中的类,到类卸载出内存位置,它的整个生命周期包括如下七个阶段:
-
加载(Loading)
:通过类加载器读取.class文
件中的二进制字节流,并将其转换成Java虚拟机中的Class对象
。 -
连接(Linking)可再分为以下三步
验证(Verification)
:对类的字节码进行格式、语义和字节码等方面的检查
,以确保它是正确、安全且符合规范的。准备(Preparation
):为类的静态变量
分配内存,并将其初始化为默认值。解析(Resolution)
:将类的符号引用替换为直接引用
,确定类的实际位置。
-
初始化(Initialization)
:执行
类的静态初始化器
和静态初始化块
,对类的静态变量进行赋值操作
。 -
使用(Using)
:创建
类的实例,调用类的方法,访问类的字段等。JVM运行
-
卸载(Unloading)
:回收
类所占用的内存空间。
使用和卸载两个过程,不属于类加载过程。
加载、验证、准备、解析、初始化五个步骤的执行过程,就是类的加载过程
符号引用 和 直接引用
类型 | 符号引用 | 直接引用 |
---|---|---|
存储形式 | 文本描述(如 java/lang/Object) | 内存地址或偏移量(如方法入口指针 0x7f3a2c) |
产生时机 | 编译时生成(.cla | ss文件) |
依赖关系 | 与虚拟机内存布局无关 | 绑定具体内存结构(依赖 JVM 实现) |
典型场景 | 跨模块调用未加载的类/方法 | 已加载类的方法调用、字段访问 |
<clinit> 与 <init>
方法
特征 | <clinit>() (类初始化方法) | <init>() (实例构造方法) |
---|---|---|
作用对象 | 类级别(初始化 static 资源) | 对象实例级别(初始化非静态资源) |
触发时机 | 类首次被 主动使用(加载、访问静态成员、new 实例等) | 每次创建对象实例时 |
执行次数 | JVM 保证仅执行 1 次(线程安全) | 根据 new 的次数执行 多次 |
内容来源 | 合并所有 静态变量赋值 + static {} 代码块 | 合并 成员变量赋值 + 构造器代码 + {} 代码块 |
线程安全 | JVM 隐式同步锁(避免多线程重复执行) | 开发者需自行控制同步(若涉及共享资源) |
参数与重载 | 无参数,不可重载 | 支持多参数,对应不同构造器重载 |
<clinit>(类初始化方法)
- 生成条件:类中包含 static 字段的显式赋值或 static {} 代码块。
- 执行顺序:
父类 <clinit> → 子类 <clinit>
(确保先初始化父类静态资源)。
<init>
(实例构造方法)
- 生成规则:
每个构造器(包括默认无参构造器)对应一个 <init> 方法
类加载过程
类加载过程:加载->连接->初始化。
连接过程又可分为三步:验证->准备->解析。
第一步:加载
类加载过程的第一步,主要完成下面 3 件事情:
-
取二进制字节流:通过全类名获取
定义此类的二进制字节流
。这个过程
由Java虚拟机的类加载器(ClassLoader)来完成
(见下面的类加载器加载过程)
不同类型和功能的类加载器,可以从不同的途径和方式来获取二进制字节流 -
转化运行时数据结构:将字节流所代表的
静态存储结构转换为方法区的运行时数据结构(Java类模型)
。在转化运行时数据结构时,虚拟机会根据Class文件中存储的信息,
在方法区中创建一个Class对象,并为其分配内存空间
然后,虚拟机会将Class文件中除了常量池之外的其他信息(如版本号、修饰符、字段表、方法表等)复制到Class对象
中,并对其中一些信息进行必要的处理
虚拟机还会将Class文件中存储的常量池表复制到方法区
中,并对其中一些信息进行必要的处理 -
在
内存中生成一个代表该类的 Class 对象(java.lang.Class对象)
,作为方法区(JDK1.8后为元数据区)这些数据的访问入口。
这个过程为类加载器 完成的
第二步:连接
验证
是连接阶段的第一步
这一阶段的目的是:确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
- 文件格式验证(Class 文件格式检查):基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求
- 元数据验证(字节码语义检查):基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
- 字节码验证(程序语义检查):基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
- 符号引用验证(类的正确性检查):基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,如下
- java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。
- java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常
- java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。
如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是该过程只是尽可能地检查出可以预知的明显的问题。
因为
100%
准确地判断一段字节码是否可以被安全执行是无法实现的
准备
准备阶段是正式为类的静态变量分配内存
并 设置其始值
的阶段,这些内存都将在方法区中分配
注意这里不会为实例变量分配初始化
,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆
中
在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行
java虚拟机为各类型变量默认的初始值如表所示:
这里不包含基本数据类型的字段用static final修饰的情况,因为
final在编译的时候就会分配
,准备阶段会显式赋值
类型 | 默认初始值 |
---|---|
byte | (byte)0 |
short | (short)0 |
in | t 0 |
long | 0L |
float | 0.0f |
double | 0.0 |
char | \u0000 |
boolean | false |
reference | null |
public String x0 = "xx0";
public static String x1 = "xx1";
public static final x2 = "xx2";
如上所示
- x0 在这个阶段不会被分配内存
- x1 在这个阶段 会被分配内存 但是值 为 null
- x2 在加载的时候便被分配内存 并且 值为xx2(因为是final static 类似常量,)
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
Java虚拟机
为每个类都准备了一张方法表
,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。
通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。
如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。如下
- 类或接口的解析
- 类方法解析
- 接口方法解析
- 字段解析
在HotSpot JVM中,加载、验证、准备和初始化会按照顺序有条不紊地执行
,但连接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行
第三步:初始化
初始化阶段是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
在连接阶段中的准备阶段,类变量已经被赋过默认初始值
在当前的初始化阶段,类变量将被赋值为代码期望赋的值
(初始化阶段是执行类构造器方法<clinit>() 方法
)
主要有下面两个操作:
执行 类构造器 初始化方法 <clinit> ()方法的过程
,-
<clinit> ()方法
是编译之后自动生成的 -
<clinit>()方法
对于类或接口来说并不是必需的,如果一个类中没有静态变量
,或者静态变量都是在编译期间确定的常量(被final修饰的基本数据类型或String类型)
,那么编译器可以不为这个类生成<clinit>()方法
。
-
- 执行类的主动引用,触发类的初始化。
类的主动引用是指直接使用该类或接口的情况
初始化阶段是执行Java代码(字节码)的过程,
因此可能会出现异常或错误。如果一个类在初始化过程中失败了,那么后续对该类的访问都会抛出NoClassDefFoundError异常。
在初始化完成后,类将被完全加载并且可以被使用。
类使用过程
类在代码中进行调用和使用
类卸载过程
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
- 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
- 该类没有在其他任何地方被引用
- 该类的类加载器的实例已被 GC
如果以上三个条件都满足,那么该类就有可能被卸载
。但是,并不是一定会被卸载,因为虚拟机会根据自身的情况来决定是否执行垃圾回收和类卸载
类加载器
类加载器在上面的 第一步:加载
中被使用到
基本概念
类加载器:类加载器的主要作用就是动态加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象
- 类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
- 每个 Java 类都有一个引用指向加载它的 ClassLoader。
- 数组类不是通过 ClassLoader 创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的。
数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的
除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源
类加载器分类
JVM 中内置了三个重要的 ClassLoader:
-
BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来
加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jar、resources.jar、charsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类
。 -
ExtensionClassLoader(扩展类加载器):主要负责
加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类
。 -
AppClassLoader(应用程序类加载器):面向我们用户的加载器,
负责加载当前应用 classpath 下的所有 jar 包和类
。通常是你的应用类和第三方库
-
当然,还可以
用户自定义类加载器:Java 允许用户创建自己的类加载器
,通过继承 java.lang.ClassLoader 类的方式实现
需要动态加载资源、实现模块化框架或者特殊的类加载策略时非常有用
每个 ClassLoader 可以通过getParent()获取其父 ClassLoader
如果获取到 ClassLoader 为null就是 BootstrapClassLoader 加载
BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。
类加载器加载规则
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。
对于一个类加载器来说,相同二进制名称的类只会被加载一次。
在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载
双亲委派模型
介绍
每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 类名.class.getClassLoader() 可以获取到此引用
;然后通过 loader.getParent() 可以获取类加载器的上层类加载器
想要加载一个类的时候,具体是哪个类加载器加载,这就需要双亲委派模型。
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器
类加载器之间的层次关系被称为类加载器的 双亲委派模型
(Parents Delegation Model),如下所示
双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法
类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
类加载器的层级结构(双亲委派模型)如下图所示:
如果
一个类加载器收到了加载类的请求,它会先把请求委托给上层加载器去完成
,上层加载器又会委托上上层加载器,一直到最顶层的类加载器;
如果上层加载器无法完成类的加载工作时,当前类加载器才会尝试自己去加载这个类
Bootstrap ClassLoader
↑
│
Extension ClassLoader
↑
│
System/Application ClassLoader
↑
│
Custom ClassLoader
分析
委派给父加载器:当一个类加载器接收到类加载的请求时,它首先不会尝试自己去加载这个类,而是将这个请求委派给它的父加载器
递归委派:这个过程会递归向上进行,从启动类加载器(Bootstrap ClassLoader)开始
,再到扩展类
加载器(Extension ClassLoader),最后到系统类
加载器(System ClassLoader)
加载类
:如果父加载器可以加载这个类,那么就使用父加载器的结果。如果父加载器无法加载这个类(它没有找到这个类),子加载器才会尝试自己去加载
安全性和避免重复加载:这种机制可以确保不会重复加载类,并保护 Java 核心 API 的类不被恶意替换
双亲委派实现原理
实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中
执行流程
双亲委派模型的执行流程:
- 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
- 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
- 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
- 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。
源码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
// 1.检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
//2.优先委派父加载器(非"父类",是层级关系)
// 若没有加载则调用父加载器的loadClass()方法进行加载
c = parent.loadClass(name, false);
} else {
// 若父加载器为空则默认使用启动类加载器作为父加载器。
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
JVM 判定两个 Java 类是否相同的具体规则
JVM 不仅要看类的全名是否相同
,还要看加载此类的类加载器是否一样
。
两者都相同的情况,才认为两个类是相同的
即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
双亲委派模型的优点
实现了两个关键的安全目标:
避免类的重复加载
: 双亲委派模型确保核心类总是由 BootstrapClassLoader 加载,保证了核心类的唯一性防止核心 API 被篡改
。
自定义类加载器
实现
自定义加载器的话,需要继承 ClassLoader
。
loadClass
loadClass():就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中
如果 想打破双亲委派模型则需要重写 loadClass() 方法
。
为什么是重写 loadClass() 方法打破双亲委派模型:
类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)
。
findClass
findClass():根据名称或位置加载.class字节码
想定义一个类加载器,但是不想破坏双亲委派模型的时候,以继承ClassLoader,并且重写findClass方法。
如果我们
不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法
即可,无法被父类加载器加载的类最终会通过这个方法被加载。
definclass
definclass():把字节码转化为Class
示例
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
public class CustomClassLoader extends ClassLoader {
// 指定类文件的加载路径
private final String classPath;
public CustomClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
// 将类名转换为文件路径(例如:com.example.Hello -> com/example/Hello.class)
String filePath = className.replace('.', File.separatorChar) + ".class";
File classFile = new File(classPath, filePath);
try (FileInputStream fis = new FileInputStream(classFile);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
// 读取类文件的字节流
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
bos.write(buffer, 0, bytesRead);
}
byte[] classBytes = bos.toByteArray();
// 将字节数组转换为Class对象(JVM核心方法)
return defineClass(className, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("类未找到: " + className, e);
}
}
public static void main(String[] args) throws Exception {
// 使用自定义加载器加载类
String path = "/data/custom_classes/";
CustomClassLoader loader = new CustomClassLoader(path);
// 加载并实例化类(假设路径下存在com.example.HelloWorld)
Class<?> clazz = loader.loadClass("com.example.HelloWorld");
Object instance = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("sayHello").invoke(instance);
}
}
双亲委派被破坏的例子
Tomcat 的类加载器
Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制
Tomcat 四个自定义的类加载器对应的目录如下:
CommonClassLoader对应<Tomcat>/common/*
:CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。CatalinaClassLoader对应<Tomcat >/server/*
:用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。SharedClassLoader对应 <Tomcat >/shared/*
:作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、MybatisWebAppClassloader对应 <Tomcat >/webapps/<app>/WEB-INF/*
:每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器为 WebAppClassLoader
。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。
如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类
。
所以,Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器
。
其他情况
高层的类加载器需要加载低层的加载器才能加载的类。
根据双亲委派原则,第三方的类不能被根加载器加载
SPI
默认情况下,一个类及其依赖类由同一个类加载器加载。所以,加载 SPI 的接口的类加载器(BootstrapClassLoader)也会用来加载 SPI 的实现
按照双亲委派模型,BootstrapClassLoader 是无法找到 SPI 的实现类的,因为它无法委托给子类加载器去尝试加载
Spring 的 jar 包
Spring 的 jar 包,由于其是 Web 应用之间共享的,因此会由 SharedClassLoader 加载(Web 服务器是 Tomcat)。
- 项目中有一些用到了 Spring 的业务类,比如实现了 Spring 提供的接口、用到了 Spring 提供的注解
- 所以,加载 Spring 的类加载器(也就是 SharedClassLoader)也会用来加载这些业务类
- 但是业务类在 Web 应用目录下,不在 SharedClassLoader 的加载路径下,所以 SharedClassLoader 无法找到业务类,也就无法加载它们。
线程上下文类加载器
为了解决:高层的类加载器需要加载低层的加载器才能加载的类。这个问题,便需要用到 线程上下文类加载器(ThreadContextClassLoader)
线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用
这个类加载器通常是由应用程序或者容器(如 Tomcat)设置的
Java.lang.Thread 中的getContextClassLoader()和 setContextClassLoader(ClassLoader cl)分别用来获取和设置线程的上下文类加载器
,如果没有通过setContextClassLoader(ClassLoader cl)进行设置的话,线程将继承其父线程的上下文类加载器。
Spring 获取线程线程上下文类加载器的代码示例
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}