JVM从入门到实战:从字节码组成、类生命周期到双亲委派及打破双亲委派机制
摘要:本文围绕 Java 字节码与类加载机制展开,详解字节码文件组成、类的生命周期,介绍类加载器分类、双亲委派机制及打破该机制的方式,还阐述了线程上下文类加载器与 SPI 机制在 JDBC 驱动加载中的应用,帮助深入理解 Java 类加载核心原理。
1. Java 字节码文件与类加载机制
1.1 Java 虚拟机的组成
Java 虚拟机主要分为以下几个组成部分:
类加载子系统:核心组件是类加载器,负责将字节码文件中的内容加载到内存中。
运行时数据区:JVM 管理的内存,创建出来的对象、类的信息等内容都会放在这块区域中。
执行引擎:包含即时编译器、解释器、垃圾回收器。执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
本地接口:调用本地使用 C/C++ 编译好的方法,本地方法在 Java 中声明时,都会带上
native
关键字。
1.2 字节码文件的组成
1.2.1 以正确的姿势打开文件
字节码文件中保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读。通过 NotePad++ 使用十六进制插件查看 class 文件:
无法解读出文件里包含的内容,推荐使用 jclasslib 工具查看字节码文件。
1.2.2 字节码文件的组成
字节码文件总共可以分为以下几个部分:
基础信息:魔数、字节码文件对应的 Java 版本号、访问标识、父类和接口信息
常量池:保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
字段:当前类或接口声明的字段信息
方法:当前类或接口声明的方法信息,核心内容为方法的字节码指令
属性:类的属性,比如源码的文件名、内部类的列表等
1.2.2.1 基本信息
基本信息包含了 jclasslib 中能看到的 “一般信息” 相关内容,具体如下:
Magic 魔数
每个 Java 字节码文件的前四个字节是固定的,用 16 进制表示为0xcafebabe
。文件无法通过扩展名确定类型(扩展名可随意修改),软件会通过文件头(前几个字节)校验类型,不支持则报错。
常见文件格式的校验方式如下:
文件类型 | 字节数 | 文件头 |
---|---|---|
JPEG (jpg) | 3 | FFD8FF |
PNG (png) | 4 | 89504E47(文件尾也有要求) |
bmp | 2 | 424D |
XML (xml) | 5 | 3C3F786D6C |
AVI (avi) | 4 | 41564920 |
Java 字节码文件 (.class) | 4 | CAFEBABE |
Java 字节码文件的文件头称为 magic 魔数,Java 虚拟机会校验字节码文件前四个字节是否为0xcafebabe
,若不是则无法正常使用,会抛出错误。
主副版本号
主副版本号指编译字节码文件时使用的 JDK 版本号:
主版本号:标识大版本号,JDK1.0-1.1 使用 45.0-45.3,JDK1.2 为 46,之后每升级一个大版本加 1;1.2 之后大版本号计算方法为 "主版本号 – 44",例如主版本号 52 对应 JDK8。
副版本号:主版本号相同时,用于区分不同版本,一般只需关注主版本号。
版本号的作用是判断当前字节码版本与运行时 JDK 是否兼容。若用较低版本 JDK 运行较高版本 JDK 编译的字节码文件,会显示错误:
类文件具有错误的版本 52.0,应为 50.0,请删除该文件或确保该文件位于正确的类路径子目录中。
解决兼容性问题的两种方案:
其他基础信息
其他基础信息包括访问标识、类和接口索引,具体说明如下:
名称 | 作用 |
---|---|
访问标识 | 标识是类 / 接口 / 注解 / 枚举 / 模块;标识 public、final、abstract 等访问权限 |
类、父类、接口索引 | 通过这些索引可找到类、父类、接口的详细信息 |
1.2.2.2 常量池
字节码文件中常量池的作用是避免相同内容重复定义,节省空间。例如,代码中编写两个相同的字符串 “我爱北京天安门”,字节码文件及后续内存使用时只需保存一份,将该字符串及字面量放入常量池即可实现空间节省。
常量池中的数据都有编号(从 1 开始),例如 “我爱北京天安门” 在常量池中的编号为 7,字段或字节码指令中通过编号 7 可快速找到该字符串。字节码指令中通过编号引用常量池的过程称为符号引用,示例如下:
字节码指令:
ldc #7
(符号引用编号 7 对应的字符串)常量池:编号 7 对应数据 “我爱北京天安门”
为什么需要符号引用?
编译期(如
javac
编译.java
为.class
)根本不知道:
- 被引用的类 / 方法在运行时会被加载到内存的哪个位置(内存地址由 JVM 动态分配);
- 同一资源在不同 JVM、不同操作系统中的内存地址可能完全不同。
符号引用通过 “延迟绑定” 解决这个问题:编译期只记录 “要引用什么”,等到运行期类加载的 “解析阶段”,JVM 再根据符号引用的信息,在内存中找到对应的资源,将其转换为 “直接引用”(即内存地址)。
1.2.2.3 字段
字段中存放当前类或接口声明的字段信息,包含字段的名字,描述符(字段类型:int,long),访问标识(修饰符:public、static、final 等)
1.2.2.4 方法
字节码中的方法区域是存放字节码指令的核心位置,字节码指令的内容存放在方法的 Code 属性中。例如,分析以下代码的字节码指令:
要理解字节码指令执行过程,需先了解操作数栈和局部变量表:
操作数栈:存放临时数据的栈式结构,先进后出
局部变量表:存放方法的局部变量(含参数、方法内定义的变量)
1. iconst_0
:将常量 0 放入操作数栈,此时栈中只有 0。
2. istore_1
:从操作数栈弹出栈顶元素(0),放入局部变量表 1 号位置(编译期确定为局部变量 i 的位置),完成 i 的赋值。
3. iload_1
:将局部变量表 1 号位置的数据(0)放入操作数栈,此时栈中为 0。
4. iconst_1
:将常量 1 放入操作数栈,此时栈中有 0 和 1。
5. iadd
:将操作数栈顶部两个数据(0 和 1)相加,结果 1 放入操作数栈,此时栈中只有 1。
6. istore_2
:从操作数栈弹出 1,放入局部变量表 2 号位置(局部变量 j 的位置)。
7. return
:方法结束并返回。
同理,可分析i++
和++i
的字节码指令差异:
i++ 字节码指令:iinc 1 by 1
指将局部变量表 1 号位置值加 1,实现 i++ 操作。
++i 字节码指令:仅调整了iinc
和iload_1
的顺序。
面试题:
int i = 0; i = i++;
最终 i 的值是多少?答:答案是 0。通过字节码指令分析:i++ 先将 0 取出放入临时操作数栈,接着对 i 加 1(i 变为 1),最后将操作数栈中保存的临时值 0 放入 i,最终 i 为 0。
1.2.2.5 属性
属性主要指类的属性,如源码的文件名、内部类的列表等。例如,在 jclasslib 中查看 SimpleClass 的属性,会显示 SourceFile 属性:
1.2.3 玩转字节码常用工具
1.2.3.1 javap
javap 是 JDK 自带的反编译工具,可通过控制台查看字节码文件内容,适合在服务器上使用。
查看所有参数:直接输入
javap
。查看具体字节码信息:输入
javap -v 字节码文件名称
。若为 jar 包:需先使用
jar –xvf jar包名称
命令解压,再查看内部 class 文件。
1.2.3.2 jclasslib 插件
jclasslib 有 Idea 插件版本,开发时使用可在代码编译后实时查看字节码文件内容。
1. 打开 Idea 的插件页面,搜索 “jclasslib Bytecode Viewer” 并安装。
2. 选中要查看的源代码文件,选择 “视图(View)- Show Bytecode With Jclasslib”,右侧会展示对应字节码文件内容。
3. 文件修改后需重新编译,再点击刷新按钮查看最新字节码。
1.2.3.3 Arthas
Arthas 是一款线上监控诊断产品,可实时查看应用 load、内存、gc、线程状态信息,且能在不修改代码的情况下诊断业务问题,提升线上问题排查效率。
安装方法
1. 将下载好的 arthas-boot.jar 文件复制到任意工作目录。
2. 使用java -jar arthas-boot.jar
启动程序。
3. 输入需要 Arthas 监控的进程 ID(启动后会列出当前运行的 Java 进程)。
常用命令
dump:将字节码文件保存到本地。
示例:将java.lang.String
的字节码文件保存到/tmp/output
目录:
jad:将类的字节码文件反编译成源代码,用于确认服务器上的字节码是否为最新。
示例:反编译demo.MathGame
并显示源代码
1.3 类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程,整体分为:
加载(Loading)
连接(Linking):包含验证、准备、解析三个子阶段
初始化(Initialization)
使用(Using)
卸载(Unloading)
类加载本身是一个过程,这个过程又细分为多个阶段,包含加载,连接和初始化阶段
1.3.1 加载阶段
1. 加载阶段第一步:类加载器根据类的全限定名,通过不同渠道以二进制流的方式获取字节码信息,程序员可通过 Java 代码拓展渠道,常见渠道如下:
2. 类加载器加载完类后,Java 虚拟机会将字节码中的信息保存到方法区,生成一个InstanceKlass
对象,该对象保存类的所有信息(含实现多态的虚方法表等)。
3. Java 虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class
对象,作用是在 Java 代码中获取类的信息,以及存储静态字段的数据(JDK8 及之后)。
步骤 1:类的 “来源获取”
类的字节码可以从多种来源被加载,如图 1 所示:
- 本地文件:最常见的情况,类的
.class
文件存储在本地磁盘(如项目的classes
目录、jar
包中),类加载器从本地文件系统读取这些字节码文件。- 网络传输:在分布式应用(如 Applet、远程服务调用)中,类的字节码可通过网络(如 HTTP、RPC)从远程服务器传输到本地 JVM。
- 动态代理生成:运行时通过字节码生成库(如 JDK 动态代理、CGLIB)动态生成类的字节码,无需预先存在物理文件。
步骤 2:类加载器(
ClassLoader
)的 “加载动作”类加载器(如图 1 右侧的
ClassLoader
)是加载阶段的核心执行者,它的工作是:
- 根据类的 “全限定名”(如
java.lang.String
),找到对应的字节码数据。JVM 不仅要加载我们自己写的应用类,还必须加载像
java.lang.String
这样的核心类
- 将字节码数据以二进制流的形式读取到 JVM 中。
步骤 3:生成
InstanceKlass
对象(方法区存储类元数据)如图 2 所示,JVM 在方法区生成一个
InstanceKlass
对象:
InstanceKlass
是 JVM 内部用于表示类的核心数据结构,包含类的全部元数据:
- 基本信息:类的访问修饰符(public、final 等)、类名、父类、接口等。
- 常量池:存储类中用到的常量(如字符串、符号引用等)。
- 字段(Field):类中定义的成员变量信息。
- 方法:类中定义的方法信息(包括方法名、参数、返回值、字节码指令等)。
- 虚方法表:支持多态的关键结构,存储方法的动态调用入口。
步骤 4:生成
java.lang.Class
对象(堆中供开发者访问)如图 3、图 4 所示:
- JVM 在堆区生成一个
java.lang.Class
对象,这个对象是开发者(Java 代码)能直接访问的 “类的镜像”。Class
对象与方法区的InstanceKlass
对象关联:Class
对象中保存了访问InstanceKlass
的 “入口”,但屏蔽了底层复杂的元数据细节。步骤 5:开发者与
Class
对象的交互(访问控制)如图 5 所示:
- 开发者无需直接操作方法区的
InstanceKlass
(包含 JVM 内部实现的敏感 / 复杂信息)。- 开发者只需通过堆中的
Class
对象,就能获取类的公开可访问信息(如通过Class.getMethods()
获取方法、Class.getFields()
获取字段等)。【反射】- 这种设计既让开发者能便捷地反射(Reflection)操作类,又由 JVM 控制了访问范围(避免开发者直接篡改方法区的核心元数据)。
1.3.2 连接阶段
连接阶段分为三个子阶段:
验证(Verification)
验证的主要目的是检测 Java 字节码文件是否遵守《Java 虚拟机规范》的约束,无需程序员参与,主要包含四部分(具体详见《Java 虚拟机规范》):
文件格式验证:如文件是否以
0xCAFEBABE
开头,主次版本号是否满足要求。
元信息验证:例如类必须有父类(super 不能为空)。
语义验证:验证程序执行指令的语义,如方法内指令跳转至不正确的位置。
符号引用验证:例如是否访问了其他类中 private 的方法。
JDK8 源码中对版本号的验证逻辑如下:
编译文件主版本号不高于运行环境主版本号;若相等,副版本号不超过运行环境副版本号。
准备(Preparation)
准备阶段为静态变量(static)分配内存并设置初值。
不同数据类型的初值如下:
解析(Resolution)
解析阶段主要是将常量池中的符号引用替换成指向内存的直接引用:
符号引用:字节码文件中使用编号访问常量池中的内容。
直接引用:使用内存地址访问具体数据,无需依赖编号。
1.3.3 初始化阶段
初始化阶段会执行字节码文件中clinit
(class init,类的初始化)方法的字节码指令,包含静态代码块中的代码,并为静态变量赋值。
1. iconst_1
:将常量 1 放入操作数栈。
2. putstatic #2
:弹出操作数栈中的 1,放入堆中静态变量value
的位置(#2
指向常量池中的value
,解析阶段已替换为变量地址),此时value=1
。
3. iconst_2
:将常量 2 放入操作数栈。
4. putstatic #2
:弹出 2,更新value
为 2。
5. return
:clinit
方法执行结束,最终value=2
。
触发类初始化的场景
clinit 不执行的情况
无静态代码块且无静态变量赋值语句。
有静态变量的声明,但没有赋值语句(如
public static int a;
)。静态变量的定义使用 final 关键字(这类变量在准备阶段直接初始化)。
面试题 1
分析步骤:
步骤 1:类加载时执行静态代码块
当 JVM 首次加载
Test1
类时,会执行静态代码块(被static
修饰的代码块)。静态代码块在类加载阶段执行,且只执行一次(无论创建多少个类的实例,静态代码块都只执行一次)。所以,程序启动后,JVM 加载
Test1
类,首先执行static
块中的代码:此时输出:D
步骤 2:执行
main
方法中的代码
main
方法是程序入口,加载完类后,执行main
方法内的代码:
- 第一行:
System.out.println("A");
→ 输出:A
- 第二行:
new Test1();
→ 创建Test1
的实例,触发实例初始化。- 第三行:
new Test1();
→ 再次创建Test1
的实例,再次触发实例初始化。步骤 3:实例初始化的顺序(重点)
每次创建
Test1
实例时,实例初始化的顺序是:
- 执行实例初始化块(类中直接用
{}
包裹的代码块);- 执行构造方法。
所以,每次
new Test1()
时,执行顺序为:
- 实例初始化块:
System.out.println("C");
→ 输出:C
- 构造方法:
System.out.println("B");
→ 输出:B
两次
new Test1()
的输出第一次
new Test1()
:
- 实例初始化块输出:
C
- 构造方法输出:
B
第二次
new Test1()
:
- 实例初始化块再次输出:
C
- 构造方法再次输出:
B
最终输出顺序
D
(静态代码块,类加载时执行)→A
(main
方法第一行)→C
(第一次实例的初始化块)→B
(第一次实例构造方法)→C
(第二次实例的初始化块)→B
(第二次实例构造方法)
面试题 2
分析步骤:
调用
new B02()
创建对象,需初始化 B02,优先初始化父类 A02。执行 A02 的初始化代码,
a
赋值为 1。执行 B02 的初始化代码,
a
赋值为 2。输出
B02.a
,结果为 2。变化:若注释
new B02();
,仅访问B02.a
(父类 A02 的静态变量),则只初始化父类 A02,a=1
,输出结果为 1。
1.4 类加载器
1.4.1 什么是类加载器
类加载器(ClassLoader)是 Java 虚拟机提供给应用程序,用于实现获取类和接口字节码数据的技术。类加载器仅参与加载过程中 “字节码获取并加载到内存” 这一部分,具体流程如下:
类加载器通过二进制流获取字节码文件内容。
将获取的数据交给 Java 虚拟机。
虚拟机会在方法区生成
InstanceKlass
对象,在堆上生成java.lang.Class
对象,保存字节码信息。
1.4.2 类加载器的分类
JDK8 及之前的默认类加载器
JDK8 及之前版本中,默认类加载器有三种,其关系如下:
启动类加载器(Bootstrap):无父类加载器,加载 Java 最核心的类。
扩展类加载器(Extension):父类加载器为启动类加载器,允许扩展 Java 中通用的类。
应用程序类加载器(Application):父类加载器为扩展类加载器,加载应用使用的类。
可通过 Arthas 的
classloader
命令查看类加载器信息
1.4.3 启动类加载器
实现方式:由 Hotspot 虚拟机提供,使用 C++ 编写。
默认加载路径:Java 安装目录
/jre/lib
下的类文件(如 rt.jar、tools.jar、resources.jar 等)。扩展示例:-Xbootclasspath/a:D:/jvm/jar/classloader-test.jar
说明:
String
类由启动类加载器加载,但 JDK8 中启动类加载器用 C++ 编写,Java 代码中无法直接获取,故返回 null。
1.4.4 扩展类加载器和应用程序类加载器
扩展类加载器
扩展类加载器加载用户 jar 包示例
- 扩展示例:-Djava.ext.dirs="C:\Program Files\Java\jdk1.8.0\_181\jre\lib\ext;D:\jvm\jar"
应用程序类加载器
应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。
默认加载路径:classpath 下的类文件(项目中的类、maven 引入的第三方 jar 包中的类)。
说明:项目类和第三方依赖类均由应用程序类加载器加载。
可通过 Arthas 的classloader -c 类加载器hash值
查看加载路径
1.5 双亲委派机制
双亲委派机制指:当一个类加载器接收到加载类的任务时,会自底向上查找是否已加载,再由顶向下尝试加载。
类加载器的父子关系
详细流程
1. 类加载器接收到加载任务后,先检查自身是否已加载该类,若已加载则直接返回。
2. 若未加载,将任务委派给父类加载器,父类加载器重复步骤 1-2。
3. 若父类加载器(直至启动类加载器)均未加载,且启动类加载器无法加载(类不在其加载路径),则由扩展类加载器尝试加载。
4. 若扩展类加载器也无法加载,由应用程序类加载器尝试加载。
案例分析
案例 1:类在启动类加载器路径中
假设com.itheima.my.A
在启动类加载器加载目录(如/jre/lib
),应用程序类加载器接收到加载任务:
1. 应用程序类加载器未加载过A
,委派给父类(扩展类加载器)。
2. 扩展类加载器未加载过A
,委派给父类(启动类加载器)。
3. 启动类加载器已加载过A
,直接返回。
案例 2:类在扩展类加载器路径中
假设com.itheima.my.B
在扩展类加载器加载目录(如/jre/lib/ext
),应用程序类加载器接收到加载任务:
1. 应用程序类加载器未加载过B
,委派给扩展类加载器。
2. 扩展类加载器未加载过B
,委派给启动类加载器。
3. 启动类加载器未加载过B
,且B
不在其加载路径,委派给扩展类加载器。
4. 扩展类加载器加载B
成功,返回。
补充问题:
双亲委派机制的作用
保证类加载安全性:避免恶意代码替换 JDK 核心类库(如
java.lang.String
),确保核心类库完整性和安全性。避免重复加载:同一类不会被多个类加载器重复加载。
如何指定类加载器加载类
在 Java 中可通过两种方式主动加载类:
1.使用Class.forName
方法:使用当前类的类加载器加载指定类,示例:
Class<?> clazz = Class.forName("com.itheima.my.A");
2.获取类加载器,调用loadClass
方法:指定类加载器加载,示例:
// 获取应用程序类加载器
ClassLoader classLoader = Demo1.class.getClassLoader();
// 使用应用程序类加载器加载com.itheima.my.A
Class<?> clazz = classLoader.loadClass("com.itheima.my.A");
Class.forName():
java.lang.Class
类的静态方法,加载指定全类名的类时会主动执行类的初始化(如静态代码块、静态变量初始化),常用于反射或需触发类初始化的场景。loadClass():
java.lang.ClassLoader
类的实例方法,仅将类加载到 JVM 但默认不进行初始化,主要用于类加载器自定义实现与类加载控制。- 区别:二者均可能抛出
ClassNotFoundException
,核心区别在于是否主动初始化类及调用主体、适用场景不同。
面试题
问:若一个类重复出现在三个类加载器的加载位置,由谁加载?
答:启动类加载器加载,双亲委派机制中启动类加载器优先级最高。
问:String 类能覆盖吗?在项目中创建java.lang.String
类,会被加载吗?
答:不能。启动类加载器会优先加载rt.jar
中的java.lang.String
类,项目中的String
类不会被加载。
问:类的双亲委派机制是什么?
答:当类加载器加载类时,自底向上查找是否已加载,若均未加载则由顶向下尝试加载。应用程序类加载器父类是扩展类加载器,扩展类加载器父类是启动类加载器。好处是保证核心类库安全、避免重复加载。
1.6 打破双亲委派机制
打破双亲委派机制历史上有三种方式,本质上仅第一种真正打破:
自定义类加载器并重写
loadClass
方法(如 Tomcat 实现应用间类隔离)。线程上下文类加载器(如 JDBC、JNDI 使用)。
Osgi 框架的类加载器(历史方案,目前很少使用)。
自定义类加载器
背景
原理
ClassLoader
核心方法
1. public Class<?> loadClass(String name)
:类加载入口,实现双亲委派机制,内部调用findClass
。
2. protected Class<?> findClass(String name)
:子类实现,获取二进制数据并调用defineClass
。
3. protected final Class<?> defineClass(String name, byte[] b, int off, int len)
:校验类名,调用虚拟机底层方法将字节码加载到内存。
4. protected final void resolveClass(Class<?> c)
:执行类生命周期的连接阶段。
1. 入口方法:
2. 再进入看下:
如果查找都失败,进入加载阶段,首先会由启动类加载器加载,这段代码在findBootstrapClassOrNull
中。如果失败会抛出异常,父类加载器加载失败就会抛出异常,回到子类加载器的这段代码,这样就实现了加载并向下传递。
3. 最后根据传入的参数判断是否进入连接阶段:
自定义类加载器实现
重新实现下面的核心代码(loadclass)就可以打破双亲委派机制
package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;import org.apache.commons.io.IOUtils;import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
import java.util.regex.Matcher;/*** 打破双亲委派机制 - 自定义类加载器*/public class BreakClassLoader1 extends ClassLoader {private String basePath;private final static String FILE_EXT = ".class";//设置加载目录public void setBasePath(String basePath) {this.basePath = basePath;}//使用commons io 从指定目录下加载文件private byte[] loadClassData(String name) {try {String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);try {return IOUtils.toByteArray(fis);} finally {IOUtils.closeQuietly(fis);}} catch (Exception e) {System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());return null;}}//重写loadClass方法@Overridepublic Class<?> loadClass(String name) throws ClassNotFoundException {//如果是java包下,还是走双亲委派机制if(name.startsWith("java.")){return super.loadClass(name);}//从磁盘中指定目录下加载byte[] data = loadClassData(name);//调用虚拟机底层方法,方法区和堆区创建对象return defineClass(name, data, 0, data.length);}public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {//第一个自定义类加载器对象BreakClassLoader1 classLoader1 = new BreakClassLoader1();classLoader1.setBasePath("D:\\lib\\");Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");//第二个自定义类加载器对象BreakClassLoader1 classLoader2 = new BreakClassLoader1();classLoader2.setBasePath("D:\\lib\\");Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");System.out.println(clazz1 == clazz2);Thread.currentThread().setContextClassLoader(classLoader1);System.out.println(Thread.currentThread().getContextClassLoader());System.in.read();}
}
问题一:为什么这段代码打破了双亲委派机制?
双亲委派机制的核心是:类加载器在加载类时,会先委托给父类加载器加载,只有父类加载器无法加载时,才自己尝试加载。
而这段代码通过重写
loadClass()
方法打破了这一机制:
- 对于非
java.
开头的类(如自定义类com.itheima.my.A
),代码直接跳过父类加载器,自己从指定目录加载类(loadClassData()
方法读取字节码)- 只有
java.
开头的核心类才遵循双亲委派(调用super.loadClass(name)
让父类加载器处理)正常情况下,
loadClass()
方法的默认实现会先委托父类加载器,而这里重写后改变了这一流程,因此打破了双亲委派机制。问题二:两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,原因是:
在 JVM 中,一个类的唯一性由「类的全限定名 + 加载它的类加载器」共同决定。即:
- 即使两个类的全限定名完全相同,只要由不同的类加载器加载,JVM 会认为它们是两个不同的类
- 代码中
classLoader1
和classLoader2
是两个不同的实例(不同的类加载器对象),因此它们加载的com.itheima.my.A
会被视为两个不同的类- 这也是为什么代码中
clazz1 == clazz2
的输出结果为false
这种特性保证了即使类名相同,只要加载器不同,就不会产生冲突,这也是 Java 类加载机制的重要设计。
关键说明
自定义类加载器的父类:默认情况下,自定义类加载器的父类加载器是应用程序类加载器(
AppClassLoader
),因ClassLoader
构造方法中parent
由getSystemClassLoader()
(返回AppClassLoader
)设置。
线程上下文类加载器
背景
双亲委派机制核心:类加载器在加载类时,优先委托父类加载器去加载。只有当父类加载器无法加载(比如父类加载器的搜索路径里没有该类),当前类加载器才会尝试自己加载。
原理
SPI 是 “约定好的配置方式”,让核心库能找到第三方实现的类名。
线程上下文类加载器 是 “工具”,让核心库(由父加载器加载)能突破双亲委派,用子加载器(应用程序类加载器)去加载第三方库的类。
SPI 机制
SPI 机制通过在 jar 包META-INF/services
目录下放置接口名文件(如java.sql.Driver
),文件中写入实现类全限定名(如com.mysql.cj.jdbc.Driver
),从而找到接口实现类。
JDBC 加载驱动流程
启动类加载器加载
DriverManager
。DriverManager
初始化时,调用LoadInitialDrivers
方法,通过 SPI 机制加载META-INF/services/java.sql.Driver
中的实现类。SPI 机制使用线程上下文类加载器(应用程序类加载器)加载 MySQL 驱动类(
com.mysql.cj.jdbc.Driver
)。驱动类初始化时,调用
DriverManager.registerDriver(new Driver())
,完成注册。
JDBC案例中真的打破了双亲委派机制吗?
最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。
但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。
所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。
Osgi 框架的类加载器
Osgi 是模块化框架,实现了同级类加载器委托加载,还支持热部署(服务不停止时动态更新字节码)。但目前使用较少,此处不展开。
热部署案例:Arthas 不停机修复线上问题
注意事项
程序重启后,字节码恢复,需将新 class 文件放入 jar 包更新。
retransform
不能添加方法 / 字段,不能更新正在执行的方法。