JVM从入门到实战:从字节码组成、类生命周期到双亲委派及打破双亲委派机制

发布于:2025-09-15 ⋅ 阅读:(18) ⋅ 点赞:(0)

摘要:本文围绕 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 字节码文件的组成

字节码文件总共可以分为以下几个部分:

  1. 基础信息:魔数、字节码文件对应的 Java 版本号、访问标识、父类和接口信息

  2. 常量池:保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用

  3. 字段:当前类或接口声明的字段信息

  4. 方法:当前类或接口声明的方法信息,核心内容为方法的字节码指令

  5. 属性:类的属性,比如源码的文件名、内部类的列表等

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 字节码指令:仅调整了iinciload_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 类的生命周期

类的生命周期描述了一个类加载、使用、卸载的整个过程,整体分为:

  1. 加载(Loading)

  2. 连接(Linking):包含验证、准备、解析三个子阶段

  3. 初始化(Initialization)

  4. 使用(Using)

  5. 卸载(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 虚拟机规范》):

  1. 文件格式验证:如文件是否以0xCAFEBABE开头,主次版本号是否满足要求。  

  1. 元信息验证:例如类必须有父类(super 不能为空)。

  2. 语义验证:验证程序执行指令的语义,如方法内指令跳转至不正确的位置。

  3. 符号引用验证:例如是否访问了其他类中 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. returnclinit方法执行结束,最终value=2

触发类初始化的场景

clinit 不执行的情况
  1. 无静态代码块且无静态变量赋值语句。

  2. 有静态变量的声明,但没有赋值语句(如public static int a;)。

  3. 静态变量的定义使用 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实例时,实例初始化的顺序是:

  1. 执行实例初始化块(类中直接用{}包裹的代码块);
  2. 执行构造方法

所以,每次new Test1()时,执行顺序为:

  • 实例初始化块:System.out.println("C"); → 输出:C
  • 构造方法:System.out.println("B"); → 输出:B

两次new Test1()的输出

第一次new Test1()

  • 实例初始化块输出:C
  • 构造方法输出:B

第二次new Test1()

  • 实例初始化块再次输出:C
  • 构造方法再次输出:B

最终输出顺序
D(静态代码块,类加载时执行)→ Amain方法第一行)→ C(第一次实例的初始化块)→ B(第一次实例构造方法)→ C(第二次实例的初始化块)→ B(第二次实例构造方法)

面试题 2

分析步骤

  1. 调用new B02()创建对象,需初始化 B02,优先初始化父类 A02。

  2. 执行 A02 的初始化代码,a赋值为 1。

  3. 执行 B02 的初始化代码,a赋值为 2。

  4. 输出B02.a,结果为 2。

变化:若注释new B02();,仅访问B02.a(父类 A02 的静态变量),则只初始化父类 A02,a=1,输出结果为 1。

1.4 类加载器

1.4.1 什么是类加载器

类加载器(ClassLoader)是 Java 虚拟机提供给应用程序,用于实现获取类和接口字节码数据的技术。类加载器仅参与加载过程中 “字节码获取并加载到内存” 这一部分,具体流程如下:

  1. 类加载器通过二进制流获取字节码文件内容。

  2. 将获取的数据交给 Java 虚拟机。

  3. 虚拟机会在方法区生成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. 启动类加载器未加载过BB不在其加载路径,委派给扩展类加载器。

4. 扩展类加载器加载B成功,返回。

补充问题:

双亲委派机制的作用

  1. 保证类加载安全性:避免恶意代码替换 JDK 核心类库(如java.lang.String),确保核心类库完整性和安全性。

  2. 避免重复加载:同一类不会被多个类加载器重复加载。

如何指定类加载器加载类

在 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 打破双亲委派机制

打破双亲委派机制历史上有三种方式,本质上仅第一种真正打破:

  1. 自定义类加载器并重写loadClass方法(如 Tomcat 实现应用间类隔离)。

  2. 线程上下文类加载器(如 JDBC、JNDI 使用)。

  3. 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方法
    @Override
    public 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构造方法中parentgetSystemClassLoader()(返回AppClassLoader)设置。

线程上下文类加载器

背景

双亲委派机制核心:类加载器在加载类时,优先委托父类加载器去加载。只有当父类加载器无法加载(比如父类加载器的搜索路径里没有该类),当前类加载器才会尝试自己加载。

原理

SPI 是 “约定好的配置方式”,让核心库能找到第三方实现的类名。

线程上下文类加载器 是 “工具”,让核心库(由父加载器加载)能突破双亲委派,用子加载器(应用程序类加载器)去加载第三方库的类。

SPI 机制

SPI 机制通过在 jar 包META-INF/services目录下放置接口名文件(如java.sql.Driver),文件中写入实现类全限定名(如com.mysql.cj.jdbc.Driver),从而找到接口实现类。

JDBC 加载驱动流程

  1. 启动类加载器加载DriverManager

  2. DriverManager初始化时,调用LoadInitialDrivers方法,通过 SPI 机制加载META-INF/services/java.sql.Driver中的实现类。

  3. SPI 机制使用线程上下文类加载器(应用程序类加载器)加载 MySQL 驱动类(com.mysql.cj.jdbc.Driver)。

  4. 驱动类初始化时,调用DriverManager.registerDriver(new Driver()),完成注册。

JDBC案例中真的打破了双亲委派机制吗?

最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。

但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。

所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。

Osgi 框架的类加载器

Osgi 是模块化框架,实现了同级类加载器委托加载,还支持热部署(服务不停止时动态更新字节码)。但目前使用较少,此处不展开。

热部署案例:Arthas 不停机修复线上问题

注意事项

  1. 程序重启后,字节码恢复,需将新 class 文件放入 jar 包更新。

  2. retransform不能添加方法 / 字段,不能更新正在执行的方法。