第一部分:理论基础
1. 类加载与类的生命周期
1.1 类加载的概念与背景
什么是类加载
类加载(Class Loading):JVM 或 Android 的 ART 把类的字节码从存储介质(磁盘、网络等)读入内存,并转换成运行时可用的形式的过程。
所谓“可用的形式”,包括:方法区里的类元数据、堆里的 java.lang.Class 对象;只有完成类加载(至少完成到“初始化”),才能 new 该类的实例、访问其静态成员、调用其方法。
简单理解:
- ① Java 源码(.java)编译后得到字节码(.class)
- ② 字节码不能直接运行,需要被加载到内存
- ③ 类加载就是把 .class/DEX“搬”进内存,并转换成可执行的形式,由 ClassLoader 完成
用语说明:狭义的“加载”仅指七阶段里的加载(Loading);广义的“类加载”常指加载~初始化这五个阶段整体。本文中“类加载过程”指五阶段。
可以拆成两条线理解:
- ① 编译:
.java→ javac → 字节码(.class),字节码是平台无关的中间表示,CPU 不能直接执行 - ② 类加载(广义):运行环境把字节码读入内存,做验证、准备、解析、初始化,得到“可用的类”,由 ClassLoader 完成(谁去“找”字节流、谁去“定义”类)。即:类加载 = 字节码 → 可执行逻辑的桥梁。
类加载过程的五阶段:加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)。
方法区:类元数据存放的区域。JVM 中常对应 Metaspace(Java 8+)或永久代;ART 中有对应的运行时结构,不要求与 JVM 一致,但都存类的结构信息、常量池等。
| 阶段 | 说明 |
|---|---|
| 编译 | .java → .class(字节码),与 JVM/ART 无关 |
| 加载(狭义) | 读入字节流 → 方法区元数据 + 堆中 Class 对象 |
| 类加载(广义) | 加载 → 验证 → 准备 → 解析 → 初始化,形成可用的类 |
为什么是“按需加载”
类加载不是启动时一次性做完,而是按需、延迟:
- 按需:类在第一次被使用时(如第一次
new、第一次访问静态成员、第一次反射加载)才触发加载与初始化。 - 延迟:从未被引用的类不会加载,节省内存、加快启动。
- 动态:运行中可通过反射、自定义 ClassLoader 加载新类(插件、热修复等)。
因此即使应用类很多,也只为用到的部分做验证和初始化。
字节码与 DEX:JVM 与 Android
- JVM:每个类一个
.class,通过 classpath(jar/目录)查找。 - Android:多个
.class会合并、优化成 DEX(Dalvik Executable),打进 APK;ART/Dalvik 从 DEX 里加载类。
DEX 更紧凑、跨类信息可合并,体积更小、解析更快。DEX 生成流程:.java → 编译 → .class → dx 等工具打包 → classes.dex → 打入 APK。限制:单 DEX 方法数 ≤ 65536,超出需 MultiDex(主 DEX 放启动必需类,其余在 classes2.dex、classes3.dex 等)。
| 项目 | JVM | Android |
|---|---|---|
| 字节码格式 | .class | DEX |
| 打包 | 多 jar/目录 | 多 .class → 一个/多个 DEX → APK |
| 方法数限制 | 无 | 单 DEX ≤ 65536,需 MultiDex |
| 执行 | 多为 JIT | AOT(.oat)+ JIT |
1.2 类的完整生命周期:七阶段总览
一个类从被加载到被卸载,经历七个阶段。前五个合起来是“类加载过程”,之后是“使用”,最后在条件满足时可能“卸载”。
七阶段生命周期流程图:
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization) → 使用(Using) → 卸载(Unloading)
↑________________________ 前五阶段 = “类加载过程” ________________________↑
- 加载~初始化:把字节码变成“可用类”,顺序固定(解析允许在初始化之后延迟进行,但使用前必须完成)。
- 使用:创建实例、访问静态/实例成员、调用方法。
- 卸载:条件苛刻,Android 中几乎不发生。
1.3 阶段一:加载(Loading)
定义:通过类的全限定名拿到其二进制字节流(DEX 或 class 文件),把这份字节流所表示的静态结构,转化为方法区里的运行时数据结构,并在堆中生成一个 java.lang.Class 对象,作为该类在方法区的访问入口。
具体做什么:
- 获取字节流:由 ClassLoader 根据全限定名查找并读取 DEX/class(Android 上多为从 APK 内或指定路径的 DEX 读取)。
- 解析格式:解析 DEX/class 的格式,在方法区建立类的元数据(类名、父类、接口、字段、方法、常量池等)。
- 创建 Class 对象:在堆中创建
Class实例,并把它与方法区中该类数据关联起来;之后反射、getClass()等都通过这个对象访问类信息。
产物:Class 对象 + 方法区中的类元数据(类的全限定名、父类、接口、字段、方法、常量池等)。此时类尚未初始化,不能正常 new 或执行静态代码。加载完成后可通过 Class 对象访问类信息,例如:
Class<?> clazz = MyClass.class;
String name = clazz.getName(); // 全限定名
Class<?> superClass = clazz.getSuperclass(); // 父类
数组类:数组类不由 ClassLoader 从字节码加载,而是由虚拟机在运行时按“元素类型”动态创建。若元素是引用类型,会先加载该元素类型,再创建数组类(如 String[] 会先加载 String)。
Android 中类首次使用时的加载流程:
需要某类 C
→ PathClassLoader.loadClass(C)
→ 双亲委派:先委派父加载器(BootClassLoader),父找不到再自己 findClass
→ BaseDexClassLoader.findClass:DexPathList 按 Element 顺序在 DEX 中查找 C
→ 找到:读取字节码 → 方法区建元数据、堆中创建 Class 对象 → 返回 Class
→ 未找到:抛 ClassNotFoundException
- 多 DEX 时按 classes.dex、classes2.dex… 顺序在 DexPathList 的 Element 中查找。
- 前置:APK 安装时提取并验证 DEX,必要时做 AOT 生成 .odex/.oat;应用启动时系统创建 PathClassLoader 并关联 APK 路径。
1.4 阶段二:验证(Verification)
目的:确保字节码合法、安全,防止被篡改或恶意代码破坏虚拟机。编译后的字节码可能被篡改,网络或外部存储中的类可能被修改,验证阶段可在执行前发现这些问题。
验证通常分为四类(符号引用验证也可放到解析阶段做):
| 类型 | 验证内容 | 失败时典型异常 |
|---|---|---|
| 文件格式 | DEX/class 魔数、版本、整体结构、常量池项类型等 | ClassFormatError |
| 元数据 | 是否有父类(除 Object)、继承关系是否合法(如不能继承 final 类)、是否实现接口/抽象方法、字段/方法访问符是否合法 | IllegalAccessError、AbstractMethodError |
| 字节码 | 指令是否合法、类型转换是否安全、跳转目标是否在方法内、方法调用参数类型与数量是否匹配 | VerifyError |
| 符号引用 | 常量池里引用的类、字段、方法是否存在、当前类是否有权访问 | NoClassDefFoundError、NoSuchFieldError、NoSuchMethodError |
验证失败的常见原因:DEX/class 被篡改、由不兼容版本的编译器生成、或运行环境版本低于字节码要求,会抛出上表所列的相应异常。
Android 在安装时会对 DEX 做大量验证与优化,运行时验证负担更小;动态加载的 DEX(如插件)则会在加载时做验证。
1.5 阶段三:准备(Preparation)
定义:为类的静态变量(类变量,static 字段)在方法区分配内存,并赋予数据类型默认值(零值)。此阶段不执行代码中的显式赋值(如 static int a = 100 里的 100 是在初始化阶段赋的)。
各类型零值:
- 基本类型:
int=0、long=0L、boolean=false、float=0.0f、double=0.0d、char='\u0000' - 引用类型:
null
例外:编译期常量
若静态变量是 static final 且值在编译期就能确定(如字面量、其他编译期常量表达式),准备阶段就会直接赋这个常量值,而不用等到初始化阶段,既符合“常量只赋一次”的语义,也便于编译器做常量折叠和内联。
public class Example {
static int a = 100; // 准备:a=0;初始化:a=100
static final int b = 200; // 准备阶段即 200(编译期常量)
static final String s = "hello"; // 准备阶段即 "hello"
static final int c = new Random().nextInt(); // 准备:c=0;初始化:c=随机值(运行期常量)
}
注意:实例变量(非 static 的成员变量)不参与准备阶段,它们在对象创建时随对象一起在堆上分配。
小结:准备阶段只做“分配 + 零值(或编译期常量)”;所有“在代码里写的”赋值都在初始化阶段执行。
1.6 阶段四:解析(Resolution)
定义:把常量池中的符号引用替换成直接引用。
- 符号引用:用字符串描述目标(如类名
com.example.Foo、方法签名void bar(int))。编译时无法知道真实内存地址,所以先以符号形式存在常量池。 - 直接引用:能够直接定位到目标在内存中的位置(指针、偏移或句柄),运行时使用。
解析时机:规范允许在“初始化之前”或“初始化之后”进行,也可延迟到某符号引用第一次被使用时再解析(延迟解析)。常见实现采用延迟解析,以分摊开销。例如执行到 invokevirtual、invokespecial 等指令时,才会去解析对应方法。
解析对象:类/接口、字段、方法、接口方法的符号引用。
- 类/接口解析:若符号引用是数组类型则先解析元素类型;否则用当前类加载器加载目标类并校验访问权限。
- 字段/方法解析:先解析所属类,再沿当前类 → 父类 → 父接口查找字段或方法(方法按名称与参数类型匹配),找到第一个匹配即解析成功。
- 接口方法解析:先解析接口,再在接口及父接口中查找方法;接口不能继承类,故不能查 Object 的方法。
解析结果会被缓存,同一符号引用不会重复解析。
可能异常:
ClassNotFoundException、NoSuchFieldError、NoSuchMethodError、IllegalAccessError、AbstractMethodError、IncompatibleClassChangeError等。
1.7 阶段五:初始化(Initialization)
一句话理解:让“已经加载好的类”真正可以使用——把静态变量从零值变成你在代码里写的值,同时执行静态代码块,并保证父类先于子类,而且这一整套过程只执行一次。
初始化具体做什么(开发者视角):
- 如果存在父类,先初始化父类:先把父类变成“可用状态”,再初始化当前类。
- 给静态变量赋值:为类变量(static 字段)执行源码中的赋值语句(准备阶段只赋零值,真正赋值在此阶段完成)。
- 执行静态代码块
static {}:按在源码中出现的顺序依次执行所有静态代码块。 - 完成以上步骤后,类就处于“已初始化”状态,可以安全地
new对象、访问静态字段/方法、通过反射使用。
这些操作在字节码层面会被编译器合成为一个特殊方法 <clinit>,由虚拟机在需要初始化该类时自动调用。
什么是 <clinit>(虚拟机视角):
- 名字:
<clinit>= class initialization,JVM 规定的类初始化方法名,不会在源码里直接出现,只能在字节码/反编译中看到。 - 里面有什么:编译器把类里所有静态变量赋值语句和所有静态块
static { },按照在源码中出现的顺序,拼成一个方法,这个方法就是<clinit>;如果类里既没有静态赋值也没有静态块,就不会生成<clinit>,该类在准备阶段结束后就算“已经初始化”。 - 谁来调用、何时调用:当类第一次被主动使用(如第一次
new、第一次访问其非常量静态字段或静态方法、Class.forName等)时,JVM/ART 在初始化阶段自动调用<clinit>,且每个类的<clinit>只会被调用一次,执行完类才真正“可用”。 - 和
<init>的区别:构造器在字节码里叫<init>,对应“对象初始化”,每new一个对象就会调用一次;<clinit>对应“类初始化”,是“全类一份、只执行一次”的初始化流程。
// 你写的代码:
public class Foo {
static int a = 1; // ①
static {
System.out.println("static block"); // ②
}
static int b = 2; // ③
}
// 编译器会生成一个 <clinit>,逻辑等价于:先执行 ①,再执行 ②,再执行 ③。
// 当 Foo 第一次被使用时,JVM 自动调用这个 <clinit>,只调一次。
<clinit>(类初始化) | <init>(构造器) | |
|---|---|---|
| 针对谁 | 类(全类一份) | 每个对象 |
| 执行几次 | 每个类只执行 1 次 | 每 new 一次执行 1 次 |
| 里面是啥 | 所有 static 赋值 + 所有 static 块,按源码顺序 | 实例块 + 你写的构造器体 |
| 谁调用 | JVM 在类初始化阶段自动调,你调不了 | 每次 new 时 JVM 自动调 |
顺序规则:父类 <clinit> 先于子类;同一类内按源码顺序(静态变量赋值与静态块交错时按书写顺序);静态先于实例——实例块、构造器在“创建对象”时执行,晚于类初始化。
线程安全:虚拟机保证每个类的 <clinit> 只会执行一次;多线程同时触发初始化时,只有一个线程执行,其余阻塞等待。
初始化失败:若 <clinit> 执行过程中抛出异常且未被捕获,该类会处于“初始化失败”状态,之后任何对该类的使用都会导致 NoClassDefFoundError(原因多为之前的 ExceptionInInitializerError)。
public class Parent {
static { System.out.println("Parent 静态块"); }
static int p = 1;
}
public class Child extends Parent {
static { System.out.println("Child 静态块"); }
static int c = 2;
}
// new Child() 时输出顺序:Parent 静态块 → Child 静态块
1.8 阶段六:使用(Using)与阶段七:卸载(Unloading)
使用:类已完成初始化,可正常创建实例、访问静态/实例成员、调用方法。创建对象时,会先为实例变量分配内存并赋零值(类似“准备”),再按顺序执行实例块和构造器(类似“初始化”的 <clinit>,但针对对象)。日常业务逻辑都在这一阶段。
卸载:当同时满足以下条件时,类才有可能被 GC 卸载:
- 该类的所有实例都已被回收;
- 加载该类的 ClassLoader 已被回收;
- 该类的
Class对象没有被任何地方引用。
在 Android 上,应用主 ClassLoader(PathClassLoader)不会被回收,且大量框架会缓存 Class、静态引用等,因此由 PathClassLoader 加载的类几乎不会卸载。相比之下,自定义 ClassLoader(如插件、热修用的 DexClassLoader)若不再被引用,其本身可被 GC 回收,则其加载的类在满足上述三条件后有可能被卸载。不要依赖“类卸载”来管理内存;真正“清空”往往依赖进程结束或重启。
1.9 类加载与初始化的时机
规范只严格规定了何时必须对类进行初始化,没有规定“必须在哪一时刻完成加载”,只要求在使用前完成即可。实现上一般是按需加载:在需要初始化(或需要解析到该类)之前完成加载。
主动引用(会触发初始化)
以下情况视为对类的“主动使用”,会触发该类初始化(未加载则先完成加载再初始化):
new该类:new MyClass()。- 访问该类的非常量静态字段或静态方法:如
MyClass.staticField、MyClass.staticMethod()。若该成员是编译期常量(见下),则不触发初始化。 - 反射:
Class.forName(className)默认会执行初始化;classLoader.loadClass(className)默认只加载不解析、不初始化(resolve=false),只有后续第一次 new 或访问静态成员时才会触发初始化和解析。 - 子类初始化:初始化子类前会先初始化父类。
- 启动主类:虚拟机启动时指定的主类(如含
main的类)会先被初始化。
接口的初始化:接口也有 <clinit>。但接口的初始化不要求父接口先初始化,只有在真正用到父接口中定义的常量时才会初始化该父接口。
被动引用(不触发该类初始化)
以下情况不会触发类的初始化(可能触发加载,但不执行 <clinit>):
- 通过子类访问父类静态字段:如
int x = Child.parentStaticField;只初始化父类,不初始化Child。 - 通过数组引用类:
MyClass[] arr = new MyClass[10];只创建数组类型,不初始化MyClass。 - 访问编译期常量:若字段是
static final且为基本类型或字符串字面量,编译期就会把值写进调用方,不触发定义该常量的类的初始化。
若“常量”在运行期才确定(如 static final int x = new Random().nextInt();),访问时会触发初始化。
class Parent { static int value = 100; }
class Child extends Parent { }
int v = Child.value; // 只初始化 Parent
class ConstHolder {
static final int X = 1; // 编译期常量
static final int Y = new Random().nextInt(); // 运行期常量
}
int a = ConstHolder.X; // 不触发 ConstHolder 初始化
int b = ConstHolder.Y; // 会触发 ConstHolder 初始化
加载与初始化的关系
- 规范:规定“何时必须初始化”,不规定“何时必须加载”。
- 实现:在“需要初始化”或“需要解析到该类”之前完成加载;即先完成加载(含验证、准备),在首次主动使用时完成解析与初始化。
- Android:首次使用类、反射加载、通过 DexClassLoader 等动态加载 DEX 并使用时,会触发对应类的加载;在主动引用时触发初始化。
一次 new 背后触发的阶段
以 new MyClass() 为例,根据类当前状态分三种情况:
情况一:MyClass 尚未被加载
→ 加载 → 验证 → 准备(静态变量零值)→ 解析(可能延迟)→ 初始化(执行 <clinit>)→ 实例化(分配对象、赋实例变量零值、执行实例块与构造器)
情况二:MyClass 已加载但未初始化
→ 初始化(执行 <clinit>)→ 实例化
情况三:MyClass 已初始化
→ 直接实例化(分配对象、赋实例变量零值、执行实例块与构造器)
实例化:为对象分配堆内存、为实例变量赋零值、按顺序执行实例块与构造器(<init>)。
1.10 Android 中类加载与生命周期的特点
- AOT:安装或后台运行时,ART 可能把 DEX 编译成 .oat(或旧版的 .odex)机器码,加载时直接使用已编译代码,启动与执行更快。
- 验证前移:安装时对 DEX 做验证与优化,运行时验证更少。
- 主 DEX 与 MultiDex:主 DEX(classes.dex)通常放 Application、入口 Activity 等启动必需的类,避免冷启动时因类在 classes2.dex 而尚未加载导致找不到类;其余类可放在 classes2.dex、classes3.dex 等,按需加载。
- 类卸载极少:PathClassLoader 常驻,其加载的类基本不会卸载;需注意静态引用和缓存对内存的影响。
第二部分:类加载器
2. 类加载器(ClassLoader)
2.1 概念与作用
定义:ClassLoader 是负责“通过类的全限定名获取字节流并定义类”的模块。对应到类加载过程里,它干的是加载阶段里“获取字节流”和“把字节流变成 Class 对象”这两件事;验证、准备、解析、初始化仍由虚拟机在后续阶段完成。
作用:
- 加载:根据全限定名找到 DEX/class 字节流并读入。
- 定义类:调用虚拟机接口,把字节流转化为
Class对象(即“定义”这个类)。 - 查找:在类路径或 DEX 路径中查找类文件。
- 隔离:不同 ClassLoader 加载的同类名类被视为不同类,可实现插件隔离、多版本库共存。
常用 API:
loadClass(name):加载类(内部先委派父加载器,再findClass);返回Class<?>。findClass(name):子类实现“从哪拿字节流、如何定义类”;默认抛ClassNotFoundException。getParent():获取父加载器;Bootstrap 在 Java 里为 null。- 获取当前类的加载器:
MyClass.class.getClassLoader()或 Android 里context.getClassLoader(),得到加载该类 / 应用的 ClassLoader(应用内多为 PathClassLoader)。
简单理解:类加载器是“搬运工”——把类从 DEX/class 文件“搬”到内存;不同加载器负责不同来源(系统、应用、插件);加载器还决定了类的查找顺序和隔离(双亲委派、命名空间)。
层次结构(双亲委派):
- JVM:Bootstrap → Extension → Application → 自定义。
- Android:BootClassLoader → PathClassLoader → DexClassLoader(无 Extension)。
获取 ClassLoader 的常见方式:
| 方式 | 说明 |
|---|---|
context.getClassLoader() | Activity/Application 等 Context 中获取,得到 PathClassLoader |
MyClass.class.getClassLoader() | 某类由谁加载,就得到对应的 ClassLoader |
ClassLoader.getSystemClassLoader() | 得到“系统”类加载器(Android 上即 PathClassLoader) |
Thread.currentThread().getContextClassLoader() | 当前线程的上下文类加载器,SPI 等场景常用 |
2.2 类加载器分类
JVM 中的三类加载器
| 类型 | 职责 | 说明 |
|---|---|---|
| Bootstrap | 核心库(如 java.lang.*、java.util.*) | 由 C/C++ 实现,是 JVM 的一部分;加载路径通常为 JRE/lib 下的 rt.jar 等。在 Java 代码中无法直接引用,因此 String.class.getClassLoader() 返回 null,表示“由根加载器加载”。 |
| Extension | 扩展类库 | 加载 JRE/lib/ext 目录下的 jar,或 java.ext.dirs 指定路径。便于在不改核心库的前提下扩展 JRE。Android 中没有 Extension 层,因为不使用标准 JRE。 |
| Application | 应用 classpath 下的类 | 即“系统类加载器”(System ClassLoader),加载 -classpath 或 CLASSPATH 环境变量指定的 jar/目录。通过 ClassLoader.getSystemClassLoader() 获取,是用户代码默认的加载器;其 parent 为 Extension。 |
层次与委派:Bootstrap 无 parent;Extension 的 parent 是 Bootstrap;Application 的 parent 是 Extension。加载类时从 Application 开始,逐级向上委派,再从上往下尝试加载。
Android 中的类加载器
| 类型 | 职责 | 说明 |
|---|---|---|
| BootClassLoader | 系统框架类(如 android.*、java.*、部分 javax.*) | 由 C++ 实现,加载系统 ROM 中的 framework、core 等。Java 层无法直接拿到实例;PathClassLoader.getParent() 通常为 null,表示父链顶端是 Boot,由虚拟机内部处理。 |
| PathClassLoader | 已安装 APK 中的类 | 应用进程默认的 ClassLoader,由系统在进程启动时创建,传入 APK 路径(如 /data/app/.../base.apk),负责加载该 APK 内所有 DEX(含 MultiDex)。通过 context.getClassLoader()、Activity.getClassLoader() 获取。 |
| DexClassLoader | 动态 DEX(任意路径) | 由开发者 new 创建,可指定 DEX 文件路径(如 SD 卡、私有目录、下载目录),用于插件、热修、动态下发等。父加载器一般传 context.getClassLoader(),这样插件可复用主应用的类,同时插件内类由自己的 DexClassLoader 加载,实现隔离。 |
| InMemoryDexClassLoader (API 26+) | 从内存加载 DEX | 构造时传入 ByteBuffer(如从网络解密后的 DEX 字节),不落盘,适合对安全或临时性要求高的场景。 |
| BaseDexClassLoader | Path/Dex 的基类 | 持有一个 DexPathList,实现从 DEX 里查找并定义类的逻辑。一般不直接使用,而是用其子类 PathClassLoader 或 DexClassLoader。 |
获取方式小结(与上表对应):通过 context.getClassLoader() 或 getSystemClassLoader() 得到的是 PathClassLoader;系统类(如 String)由 BootClassLoader 加载,应用层拿不到 Boot 实例,PathClassLoader 的 parent 为 null 表示“再往上由虚拟机处理”;动态加载需自己 new DexClassLoader(...) 或 InMemoryDexClassLoader(...),用该 loader 去 loadClass()。
2.3 双亲委派模型
思想:收到加载请求时,先不自行加载,而是委托给父加载器;父加载器再向上委托;只有父加载器无法加载时,当前加载器才尝试加载。
Android 委派链示意(请求自下而上委派,加载自上而下尝试):
BootClassLoader(系统框架类,parent 为 null,Java 层不可见)
↑ 委派
PathClassLoader(应用 APK 内 DEX,getParent() 常为 null,由虚拟机当作 Boot 处理)
↑ 委派
DexClassLoader / 自定义(插件、热修等动态 DEX)
流程:
请求 loadClass(C)
→ findLoadedClass(C) 已加载?是 → 直接返回
→ 否 → parent 存在?是 → 调用 parent.loadClass(C)
→ 父加载器找到则返回;找不到则抛 ClassNotFoundException,由当前加载器接住
→ 当前加载器 findClass(C),找到则定义并返回,否则抛 ClassNotFoundException
优点:
- 安全:核心类(如
java.lang.String)由 Bootstrap 加载,应用层无法用同名类替换。 - 唯一性:同一全限定名在同一父链下只会被一个加载器加载一次。
- 避免重复:已由父加载器加载的类,子加载器直接复用,不重复定义。
实现要点:
ClassLoader.loadClass()里先findLoadedClass,再parent.loadClass(),最后findClass()。子类只重写findClass()即可实现“从自己的路径找类”,无需动loadClass(),自然满足双亲委派。- Android 的 BaseDexClassLoader 只重写
findClass()(从 DexPathList 里找 DEX 并定义类),仍用父类loadClass(),因此默认遵循双亲委派。
实际例子:应用里执行 loadClass("java.lang.String") 时,请求先到 PathClassLoader,再委派给父加载器(Android 上 PathClassLoader.getParent() 为 null,由虚拟机内部当作 BootClassLoader 处理);根加载器在系统路径中找到并加载 String,返回给调用者,因此最终是根加载器加载了核心类,应用无法用自定义类替换。
简单伪代码:
// ClassLoader.loadClass 简化逻辑
protected Class<?> loadClass(String name, boolean resolve) {
Class<?> c = findLoadedClass(name); // 1. 已加载则直接返回
if (c == null) {
if (parent != null) {
c = parent.loadClass(name, false); // 2. 委派给父加载器
}
if (c == null) {
c = findClass(name); // 3. 父找不到,自己 findClass
}
}
if (resolve) resolveClass(c);
return c;
}
2.4 类加载器与命名空间
什么是命名空间:在类加载体系里,命名空间(Namespace) 指的是“由某个 ClassLoader 所加载的类构成的集合”所对应的那个范围;也可以简单理解为:每个 ClassLoader 对应一个命名空间。同一个全限定名(如 com.example.Foo)在不同命名空间里可以对应不同的 Class 对象——只有在同一个 ClassLoader 下,同名才代表同一个类。
类的唯一性:因此,类的唯一性 = 全限定名 + 定义它的 ClassLoader(即其所在命名空间)。两个条件都相同,才是“同一个类”。
- 同一类名由不同 ClassLoader 加载 → 属于不同命名空间 → 得到两个不同的
Class,class1 == class2为 false,不能互相赋值,instanceof也按“类 + 加载器”判断。 - 类隔离:不同命名空间之间互不干扰。插件用独立 ClassLoader(独立命名空间),与主应用同名类互不干扰;也可让不同模块使用不同版本的同一库。
示例:
ClassLoader loader1 = new DexClassLoader(path1, ...);
ClassLoader loader2 = new DexClassLoader(path2, ...);
Class<?> c1 = loader1.loadClass("com.example.Foo");
Class<?> c2 = loader2.loadClass("com.example.Foo");
// c1 != c2,二者是“不同类”,不能把 c1 的实例赋给 c2 的引用
3. Android 类加载器详解
3.1 PathClassLoader 与 DexClassLoader
二者都继承自 BaseDexClassLoader,区别主要在于谁创建、加载来源是什么:PathClassLoader 由系统创建、只加载已安装 APK;DexClassLoader 由开发者创建、可加载任意路径的 DEX。
| 项目 | PathClassLoader | DexClassLoader |
|---|---|---|
| 用途 | 加载已安装 APK 中的类 | 从指定路径动态加载 DEX |
| DEX 来源 | 应用安装目录中的 APK(如 /data/app/.../base.apk) | 任意路径(SD 卡、私有目录、下载目录等) |
| 创建 | 系统在进程启动时创建,传入 APK 路径 | 开发者 new 创建,传入 DEX 路径 |
| 典型场景 | 应用日常类加载 | 插件化、热修复、动态加载 |
PathClassLoader:系统为每个应用进程创建一个,负责加载该 APK 内 classes.dex(及 MultiDex 的 classes2.dex 等)。其公开构造函数为 PathClassLoader(String dexPath, ClassLoader parent),内部调用 super(dexPath, null, null, parent)。应用内通过 context.getClassLoader() 获取,无需也不能自己 new。
DexClassLoader 构造:
DexClassLoader(String dexPath, // DEX 路径,多路径用 File.pathSeparator 或 ":"
String optimizedDirectory, // 优化产物目录,必须为应用私有目录
String librarySearchPath, // native 库路径,无则 null
ClassLoader parent) // 通常传 context.getClassLoader()
注意:optimizedDirectory 必须为应用私有目录(如 getCacheDir().getAbsolutePath()、getDir("dex", MODE_PRIVATE)),不能是外部存储公共目录,否则可能报错或存在安全/兼容问题。
使用示例:
// 动态加载 DEX
File dexFile = new File(getCacheDir(), "plugin.dex"); // 假设 DEX 已下载或解压到此
String optimizedDir = getCacheDir().getAbsolutePath();
DexClassLoader loader = new DexClassLoader(
dexFile.getAbsolutePath(),
optimizedDir,
null,
getClassLoader()
);
Class<?> clazz = loader.loadClass("com.plugin.PluginEntry");
InMemoryDexClassLoader(API 26+):InMemoryDexClassLoader(ByteBuffer dexBuffer, ClassLoader parent),从内存 ByteBuffer 加载 DEX,不写文件,适合从网络解密后直接加载或对安全要求高的场景。
3.2 BaseDexClassLoader 与 DexPathList
BaseDexClassLoader:PathClassLoader 和 DexClassLoader 的共同父类,持有一个 DexPathList,负责“从 DEX 里找类并定义”的具体实现。其 findClass(name) 内部只做一件事:调用 pathList.findClass(name);不重写 loadClass(),因此默认仍按双亲委派工作。
DexPathList:内部维护一个 Element 数组,每个 Element 对应一个 DEX 文件(或 jar/zip,其中含 DEX)。findClass(name) 时按数组顺序遍历,在每个 Element 对应的 DEX 里查找类名;找到则由该 Element 读取字节流并交给虚拟机 defineClass,得到 Class 并返回。多 DEX 时,类在先被遍历到的 DEX 里找到即返回,因此 DEX 顺序会影响“同名类取哪一份”(主 DEX 通常在前)。
加载链路小结(流程图):
loadClass(name) [委派:先 parent,再 findClass]
→ findLoadedClass(name) 已加载?是 → 直接返回 Class
→ 否 → findClass(name)(BaseDexClassLoader 实现)
→ pathList.findClass(name)
→ 按 Element 数组顺序遍历,在每个 Element 的 DexFile 中查找 name
→ 找到 → defineClass(交给 ART)→ 返回 Class
→ 未找到 → 抛 ClassNotFoundException
3.3 多 DEX 与 AOT
多 DEX:单 DEX 方法数上限 65536,超出需拆成多个 DEX(classes.dex、classes2.dex、…)。
- 启用方式:在
build.gradle中multiDexEnabled true,并依赖androidx.multidex:multidex;在 Application 的attachBaseContext里调用MultiDex.install(this),让 PathClassLoader 在启动时能找到主 DEX 以外的 DEX。 - 主 DEX 列表:主 DEX 中需包含 Application、入口 Activity 等启动阶段会用到的类,通过主 DEX 列表或 build 配置控制哪些类打进主 DEX,避免启动时类在 classes2.dex 尚未加载而报错。
// build.gradle
android {
defaultConfig { multiDexEnabled true }
}
dependencies { implementation 'androidx.multidex:multidex:2.0.1' }
// Application
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(base);
MultiDex.install(this);
}
AOT(Ahead-of-Time):ART 在安装时或后台将 DEX 编译成 .oat 机器码。类加载时若该 DEX 已有对应 .oat,会直接使用编译后的本地代码,加快启动和运行;否则走解释或 JIT。对“类加载”而言,加载的仍是类元数据和代码,只是执行方式可能是 AOT 编译后的本地码。
3.4 Android 与 JVM 类加载对比
| 特性 | JVM | Android |
|---|---|---|
| 字节码 | .class | .dex |
| 加载器层次 | Bootstrap/Ext/App | Boot/Path/Dex |
| 扩展加载器 | 有 | 无 |
| 多文件 | 多 jar | 多 dex(MultiDex) |
| 编译 | 以 JIT 为主 | AOT + JIT |
| 获取应用加载器 | getSystemClassLoader() | context.getClassLoader()(PathClassLoader) |
理解上可把 PathClassLoader 类比为 JVM 的 Application ClassLoader,BootClassLoader 类比 Bootstrap;Android 没有 Extension 层,且字节码与多 DEX 机制与 JVM 不同。
第三部分:自定义与双亲委派破坏
4. 自定义类加载器(Android)
4.1 使用场景
- 动态加载 APK/DEX(插件化):主应用体积大时按需加载功能模块,或功能模块独立开发与更新,不重新发版即可加能力;用 DexClassLoader 加载插件 DEX,每个插件独立 ClassLoader 实现隔离。
- 热修复:修复线上 Bug 不发版;加载补丁 DEX,替换有问题的类,使后续逻辑走修复后的类。
- 从非标准位置加载:从网络下载 DEX、从加密文件解密后加载、或使用 InMemoryDexClassLoader 从内存加载,不落盘。
- 类隔离:不同模块使用不同版本的同一类库、或插件之间互不干扰,通过不同 ClassLoader 加载视为不同类。
4.2 实现方式
- 继承 BaseDexClassLoader 或 DexClassLoader,传入 DEX 路径、优化目录(应用私有目录)、父加载器(通常
context.getClassLoader())。 - 重写 findClass():在
super.findClass()前后加日志、耗时统计、缓存等;类不存在时仍抛 ClassNotFoundException。自定义时还可在 findClass 前增加缓存(如 Map 缓存已加载类)、统计加载次数等,便于监控与避免重复查找。 - 重写 loadClass()(破坏双亲委派时):用 Set 维护“优先由当前加载器加载”的包名,对这类类先
findLoadedClass,未加载则先findClass(),失败再parent.loadClass();其它类直接super.loadClass()。
重写 findClass 示例(仅加日志与耗时):
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Log.d("MyLoader", "findClass: " + name);
long start = System.currentTimeMillis();
Class<?> c = super.findClass(name);
Log.d("MyLoader", "loaded in " + (System.currentTimeMillis() - start) + "ms");
return c;
}
重写 loadClass 破坏委派示例(插件包优先从当前加载器加载;低版本 Android 可将包名判断改为 for 循环 name.startsWith(pkg)):
private Set<String> preferredPackages = new HashSet<>(Arrays.asList("com.plugin"));
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);
if (c != null) return c;
boolean preferred = false;
for (String pkg : preferredPackages) { if (name.startsWith(pkg)) { preferred = true; break; } }
if (preferred) {
try {
c = findClass(name);
if (resolve) resolveClass(c);
return c;
} catch (ClassNotFoundException e) { /* 当前 loader 找不到,再委派给 parent */ }
}
return super.loadClass(name, resolve);
}
5. 双亲委派的破坏(Android 场景)
5.1 为何要破坏
- 插件化:希望插件中的类由“插件 ClassLoader”加载,与主应用隔离,并能使用插件内不同版本库;若严格委派,插件类可能被父加载器先加载,无法隔离。
- 热修复:需要替换已有类;若先委派给父加载器,会一直用旧类,必须让补丁 ClassLoader 优先加载“已修复”的类。
- 动态加载:需要从当前加载器优先加载指定路径/来源的类,控制加载顺序与优先级。
5.2 如何破坏
- 重写 loadClass():对指定包名/类名先
findLoadedClass,若未加载则先findClass()(本加载器),失败再parent.loadClass();其它类直接super.loadClass()。 - 反射修改 parent:极少数方案通过反射修改 ClassLoader 的
parent字段,改变委派链(不推荐,兼容性与稳定性差)。
5.3 SPI 与线程上下文类加载器
- SPI(Service Provider Interface):一种服务发现机制——定义接口,实现类在配置(如
META-INF/services/接口全限定名)中声明,运行时通过ServiceLoader加载。接口常在核心库(Bootstrap 加载),实现类在应用层(Application/Path 加载),若严格双亲委派,根加载器无法加载应用层实现。 - 解决:使用线程上下文类加载器。
ServiceLoader.load(接口)内部会使用Thread.currentThread().getContextClassLoader()去加载实现类,从而打破“父加载器加载的接口看不到子加载器加载的实现”的限制。 - Android 使用:建议显式传入 ClassLoader,避免上下文类加载器为 null 或不符合预期:
ServiceLoader.load(PluginInterface.class, getClassLoader())。
SPI 简化示例:
// 1. 定义接口
public interface PaymentService { void pay(double amount); }
// 2. 实现类(可多个)并在 META-INF/services/...PaymentService 中声明实现类全限定名
// 3. 使用 ServiceLoader 加载(Android 建议显式传 ClassLoader)
ServiceLoader<PaymentService> loader = ServiceLoader.load(PaymentService.class, getClassLoader());
for (PaymentService service : loader) {
service.pay(100.0);
}
第四部分:类的卸载与常见问题
6. 类的卸载(Android)
6.1 类卸载的条件(须同时满足)
类要被 GC 卸载,必须同时满足以下三个条件,缺一不可(逻辑关系:① 且 ② 且 ③)。
| 条件 | 说明 |
|---|---|
| ① 该类所有实例都已被回收 | 只要还有该类的对象存活,类元数据就不能卸。 |
| ② 加载该类的 ClassLoader 已被回收 | ClassLoader 是类的“命名空间”,loader 在则类在。系统 PathClassLoader 不会被回收,故由它加载的类不会被卸载;只有自定义 ClassLoader(如 DexClassLoader)被回收后,其加载的类才可能卸载。 |
| ③ 该类对应的 Class 对象没有被引用 | 静态变量、反射缓存、序列化等持有 Class 都会阻止卸载。 |
简要示例:
// 条件①:实例都要回收
MyClass obj1 = new MyClass(), obj2 = new MyClass();
obj1 = null; obj2 = null; // GC 后若还满足②③,才可能卸载
// 条件②:ClassLoader 要回收(PathClassLoader 不会回收,只有自定义 loader 可能)
DexClassLoader loader = new DexClassLoader(...);
Class<?> c = loader.loadClass("com.example.Foo");
loader = null; // GC 后若还满足①③,才可能卸载
// 条件③:Class 对象不能被引用
Class<?> clazz = MyClass.class; // 被引用则不会卸载
clazz = null; // 释放后,且满足①②,才可能卸载
Android 中的现实:PathClassLoader 常驻、Class 常被框架缓存、静态变量持有引用,因此由应用主加载器加载的类几乎不会卸载,应用重启才是真正的“清空”。
6.2 类卸载的过程与验证
时机:类卸载发生在 GC 时——GC 会检查类是否满足上述三条件,满足则执行卸载;Android 中很少“主动”触发。
步骤(流程图):
GC 运行
→ 检查类是否同时满足:① 无实例 ② ClassLoader 已回收 ③ Class 对象无引用
→ 三条件均满足 → 清理该类在方法区的元数据、移除 Class 相关信息 → 卸载完成
→ 任一条件不满足 → 该类保留,不卸载
验证:用 WeakReference 持有 Class,清除其他引用并触发 GC 后,若 ref.get() == null,说明 Class 已被回收(类可能已卸载)。
WeakReference<Class<?>> ref = new WeakReference<>(loader.loadClass("com.example.Foo"));
loader = null; // 并清除对该 Class 的其他引用
System.gc(); System.runFinalization(); System.gc();
if (ref.get() == null) { /* Class 已被回收 */ }
6.3 注意事项与促进卸载
结论:不要依赖类卸载来管理内存;合理设计生命周期、减少不必要的静态引用;需要彻底清理时考虑进程重启。
若希望提高卸载概率(如插件、临时模块):
- 用可回收的 ClassLoader:插件用独立 DexClassLoader 加载,用完后解除对 loader 的引用,便于 GC 回收 loader 及其加载的类。
- 避免静态强引用:用 WeakReference 代替静态强引用缓存对象,避免阻止类卸载。例如
static Object cache = new Object();会阻止类卸载,可改为static WeakReference<Object> cache = new WeakReference<>(...);。 - 及时释放 Class 引用:
loadClass使用完后将 Class 引用置 null,不长期缓存。 - 监控(可选):JVM 可用
-XX:+TraceClassUnloading;Android 可用 WeakReference 记录关键类,通过ref.get() == null推断是否被回收。
7. 常见异常与排查
7.1 ClassNotFoundException
含义:在指定 ClassLoader 及其父链上找不到该类的定义(即没有对应的 DEX/class 或 loader 无法访问)。
典型触发场景:在主动加载类时抛出,例如 Class.forName(...)、classLoader.loadClass(...)、反射 getDeclaredMethod 等。异常 message 中会包含找不到的类名,可根据堆栈定位到触发加载的那一行代码。
常见原因与解决方式:
| 类型 | 原因说明 | 解决方式 |
|---|---|---|
| 类名/包名错误 | 写错、大小写不一致、混淆后名称变化 | 核对全限定名与大小写;混淆场景下用 -keep 或映射表 |
| DEX 缺失或路径错误 | 文件不存在、路径无权限、optimizedDirectory 用了非法目录 | 确认文件存在且路径可读;optimizedDirectory 使用应用私有目录 |
| 类未打进 DEX | 未参与编译、被 ProGuard 移除且未 keep | 确保类参与编译;在 ProGuard 规则中 -keep / -keepclassmembers |
| 用了错误的 ClassLoader | 类在插件 DEX 里却用 PathClassLoader 去 load | 使用加载该 DEX 的 ClassLoader(如 DexClassLoader)调用 loadClass |
| 多 DEX 问题 | 类在 classes2.dex 等,主 DEX 未配置或未调用 MultiDex.install | 配置 mainDexList / multiDexEnabled;在 Application 中调用 MultiDex.install() |
排查步骤:
- 看异常 message 中的类名和调用栈,确定是哪一行触发的加载。
- 核对该类的全限定名与 DEX 路径(含大小写;混淆后类名会变)。
- 用反编译或工具确认 DEX 中是否包含该类。
- 检查 ProGuard 规则是否误删或未 keep。
- 确认使用的 ClassLoader 及 MultiDex 配置是否正确。
Android 特有原因:多 DEX 未配置或未调用 MultiDex.install()、ProGuard 混淆导致类名变化、Instant Run 导致类与 DEX 不一致(可关闭后完整编译再试)。
7.2 NoClassDefFoundError
含义:编译时类存在,运行时在链接或首次使用该类时失败——或因依赖类找不到(如 B 引用了 A,A 缺失或未加载),或因类初始化失败(<clinit> 中抛异常),导致该类处于“不可用”状态,后续任何使用都会抛出 NoClassDefFoundError。
与 ClassNotFoundException 的区别:
| 项目 | ClassNotFoundException | NoClassDefFoundError |
|---|---|---|
| 典型触发时机 | 主动调用 loadClass / Class.forName 时 | 首次使用类时(如 new、访问静态成员) |
| 直接原因 | 在 ClassLoader 链上找不到类定义 | 类曾加载过但初始化失败,或依赖类缺失 |
| 常见根因 | 类不在 DEX、路径/混淆/loader 错误 | 依赖缺失、ProGuard 删依赖、ExceptionInInitializerError |
常见原因与解决方式:
| 类型 | 原因说明 | 解决方式 |
|---|---|---|
| 依赖类缺失 | B 引用 A,A 不在 DEX 或未被当前 ClassLoader 加载 | 确保依赖类参与编译并打进 DEX;用正确 ClassLoader 加载;MultiDex 时保证依赖在主 DEX 或 install 正确 |
| 类初始化失败 | <clinit> 执行时抛异常,该类被标记为不可用 | 查看异常 cause(常为 ExceptionInInitializerError);修复静态块、静态变量赋值中的异常 |
| ProGuard 删依赖 | 依赖类被误删或未 keep | 在 ProGuard 规则中 -keep 被引用的类及其依赖 |
| 多 DEX 未配置 | 依赖类在 classes2.dex 等,主 DEX 或 MultiDex.install 未包含 | 配置 mainDexList / multiDexEnabled;Application 中调用 MultiDex.install() |
| AAR 依赖未传递 | 依赖在 AAR 中未正确打进 DEX 或未声明依赖 | 检查 implementation/api 依赖;确认 AAR 中类被打进主/分包 DEX |
排查步骤:
- 看异常 cause(常有 ExceptionInInitializerError 或 ClassNotFoundException),先解决根因。
- 根据 cause 中的类名,确认该依赖类是否在 DEX 中、是否被 ProGuard 去掉。
- 检查出问题类的静态初始化代码(静态块、静态变量赋值)是否有异常或依赖缺失。
- 确认 MultiDex、ClassLoader 及 AAR 依赖配置是否正确。
Android 上:上述原因常表现为多 DEX 未配置、ProGuard 未 keep 依赖类、AAR 依赖未正确传递,按上表逐项排查即可。
7.3 LinkageError 及子类
含义:LinkageError 表示类在链接阶段(验证、解析等)出错,常见子类包括 ClassFormatError、VerifyError、UnsupportedClassVersionError 等,多在与 DEX/class 格式、字节码合法性、版本兼容相关时抛出。
常见原因与解决方式:
| 类型 | 原因说明 | 解决方式 |
|---|---|---|
| ClassFormatError | DEX/class 文件格式错误或损坏(如文件不完整、被篡改、拷贝中断) | 重新编译或重新获取 DEX;检查文件完整性、未篡改;校验来源 |
| VerifyError | 字节码验证失败(指令、类型、引用等不合法,如继承 final 类、类型不匹配) | 检查编译版本与运行环境一致;检查 ProGuard 规则与依赖版本;修复不合法的字节码或依赖 |
| UnsupportedClassVersionError | 字节码版本高于当前运行环境(如高版本 Java 编译在低版本 ART 上运行) | 降低编译的 Java/字节码版本,或升级设备系统(Android 中较少见) |
排查步骤:
- 根据异常子类(ClassFormatError / VerifyError / UnsupportedClassVersionError)确定是格式、验证还是版本问题。
- ClassFormatError:检查 DEX 来源、拷贝/下载是否完整,必要时重新编译或重新获取。
- VerifyError:核对编译与运行环境、ProGuard 规则、依赖版本;查看详细堆栈定位到具体类/方法。
- UnsupportedClassVersionError:核对编译 SDK 与设备 minSdk/targetSdk,降低编译版本或升级运行环境。
Android 上:常见为 DEX 损坏(重新获取并校验)、.odex/.oat 优化失败(清除数据或重装)、版本不兼容(检查 minSdk/targetSdk 与设备系统);确保 DEX 来源可靠,勿长期放宽验证。
7.4 类加载性能与优化
性能问题与优化:类加载会带来 I/O(读 DEX)、验证与解析、<clinit> 初始化等开销,在启动或首次进入某功能时容易造成卡顿。下表合并列出常见性能问题与对应优化手段。
| 性能问题 | 原因说明 | 优化手段 |
|---|---|---|
| I/O、验证、解析、初始化集中在首次使用 | 读 DEX、字节码验证、符号解析、<clinit> 等集中在首次使用某类时执行,首屏或首次进功能时易卡顿 | 预加载:空闲时(Application、Splash 等)提前 Class.forName / loadClass 关键类(如 MainActivity、CommonUtils、网络库入口) |
| 启动时主 DEX 类过多、多 DEX 查找慢 | 主 DEX 或启动路径上类过多,或类在 classes2.dex 等需按 DexPathList 顺序查找,拖慢启动 | 多 DEX / 主 DEX 优化:用 ProGuard 与 mainDexList 只保留启动必需类;其余放 classes2.dex 等按需加载 |
| 不必要的类被加载、整包加载 | 启动时通过反射或静态引用拉取整包,或未按需加载模块,增加 DEX 体积与类数量 | 减少不必要加载:按需加载模块;避免启动时拉整包;ProGuard 剪枝减体积与类数 |
| 重复加载、缓存缺失 | 自定义 ClassLoader 未缓存或多次 loadClass 同一类,导致重复 I/O 与验证 | 缓存机制:系统 ClassLoader 已缓存;自定义 ClassLoader 在 findClass 前查缓存;插件/热修注意类隔离与缓存键(类名 + ClassLoader) |
| 难以定位哪类加载慢 | 无耗时统计,无法发现 DEX 过大、主 DEX 过多、I/O 慢等瓶颈 | 监控慢加载:对 loadClass / findClass 做耗时统计,超过阈值(如 100ms)打日志或上报 |
简单耗时监控示例:
long start = System.currentTimeMillis();
Class<?> clazz = loader.loadClass(className);
if (System.currentTimeMillis() - start > 100) {
Log.w("ClassLoad", "Slow: " + className);
}
小结:先明确“I/O、验证、解析、初始化、主 DEX 过大、首次使用才加载”等性能问题,再通过预加载、多 DEX/主 DEX 优化、减少不必要加载、缓存、监控等手段做针对性优化。Android 上还需结合 AOT(.oat 优化)对执行速度的影响,类加载阶段的耗时仍可从上述几方面优化。
第五部分:实践场景简述
8. 插件化
- 思路:下载/获取插件 APK → 提取或使用其中的 DEX → 用 DexClassLoader 加载 → 通过反射加载插件类、创建实例、调用方法。资源需通过 AssetManager 单独添加插件路径并生成 Resource;Activity 等组件往往通过代理 Activity 或 Hook 系统服务方式启动,插件中的 Activity 由宿主代理并委托插件执行。
- 类加载相关步骤(流程图):
获取插件 APK
→ ① 提取 DEX(解压 APK 或通过 DexFile 等读取)
→ ② 创建 DexClassLoader(dexPath, optimizedDir, null, getClassLoader())
→ ③ loader.loadClass(插件类名) 得到 Class
→ ④ 反射 newInstance()、getMethod()、invoke() 调用插件逻辑
→ ⑤ 资源:AssetManager 添加插件路径;Activity:代理或 Hook 方式启动
- 类隔离:每个插件使用独立 ClassLoader(父加载器一般为宿主
getClassLoader()),或自定义 ClassLoader 并重写 loadClass,使插件包名下的类优先由插件 ClassLoader 加载。 - 常见框架:VirtualAPK、RePlugin、Shadow 等。
9. 热修复
- 思路:下发补丁 DEX,用 DexClassLoader 加载;通过替换 ClassLoader 内的类定义或修改 DexPathList 等,使后续使用到“已修复类”时从补丁中加载。
- 类加载相关流程(流程图):
① 下载/获取补丁 DEX
→ ② 创建 DexClassLoader(补丁路径, 优化目录, null, getClassLoader())
→ ③ 从补丁中 loadClass 得到修复后的类(可选:验证补丁是否生效)
→ ④ 反射修改 PathClassLoader/BaseDexClassLoader 的 DexPathList
→ 将补丁 DEX 对应的 Element 插入到原 DEX 列表前面
→ 后续 findClass 时先查补丁,优先命中修复后的类,实现“替换”
- 注意:实际方案需考虑类的替换机制、Native 方法替换、资源与 so 的修复、版本兼容性等,建议参考 Tinker、AndFix、Robust 等成熟方案。
10. 动态加载
- 思路:使用 DexClassLoader(文件)或 InMemoryDexClassLoader(内存)加载 DEX,再
loadClass、反射调用。
类加载相关流程(流程图):
获取 DEX(文件路径 / 内存 ByteBuffer)
→ 创建 DexClassLoader(dexPath, optimizedDir, null, parent) 或 InMemoryDexClassLoader(buffer, parent)
→ loader.loadClass(目标类名) 得到 Class
→ 反射 newInstance()、getMethod()、invoke() 调用
- 权限:读取 DEX 所在路径需存储权限,Android 10+ 注意 Scoped Storage。
- 安全:校验 DEX 来源与完整性,避免加载不可信代码。
第六部分:调试与面试要点
11. 排查与监控
11.1 常见问题排查思路
| 现象 | 排查方向 |
|---|---|
| ClassNotFoundException | 类名/包名/大小写、混淆后名称;DEX 路径与文件是否存在;ProGuard 是否误删或未 keep;使用的 ClassLoader 是否正确(插件类需用加载该 DEX 的 ClassLoader);MultiDex 是否配置并调用 MultiDex.install() |
| NoClassDefFoundError | 先看异常 cause(常为 ExceptionInInitializerError 或 ClassNotFoundException);依赖类是否在 DEX 中、是否被 ProGuard 去掉;该类的 <clinit> 是否抛异常;MultiDex、AAR 依赖是否正确 |
| VerifyError / ClassFormatError | DEX 是否完整、未损坏;编译与运行环境版本是否一致;ProGuard 规则与依赖版本是否冲突 |
| 启动或首次进入卡顿 | 主 DEX 类是否过多;是否可预加载关键类;对 loadClass/findClass 打点看哪类加载慢 |
11.2 调试手段
- 日志:用
adb logcat过滤类加载相关日志,例如:或按 tag 过滤:adb logcat | grep -i "classloader\|ClassNotFound\|NoClassDefFound\|VerifyError"adb logcat -s ClassLoad:*(需在代码里用对应 tag 打日志)。 - 确认 ClassLoader 链:在代码中打印
obj.getClass().getClassLoader()、getParent(),确认当前类由谁加载、父加载器是谁。示例:ClassLoader loader = getClassLoader(); Log.d("Debug", "ClassLoader: " + loader + ", parent: " + loader.getParent()); - 耗时打点:在
loadClass/findClass前后打时间戳,找出加载慢的类。 - 断点与单步:在
ClassLoader.loadClass、findClass处下断点,单步跟踪加载流程与委派顺序。 - DEX 与 AOT:需查看设备上 DEX、oat 信息时,可用
adb shell执行dex2oat、cmd package compile等(需设备路径与权限)。例如:adb shell cmd package compile -f -m speed 包名。
11.3 监控与工具
- 应用内监控:在关键路径对
loadClass做耗时统计,超过阈值(如 100ms)打日志或上报,便于发现主 DEX 过大、I/O 慢等问题。 - Android Studio Profiler:Memory 视图中可查看已加载的类、ClassLoader 数量等,辅助分析内存与类加载情况。
- Release 包对比:开启 ProGuard 后对比混淆映射表与崩溃堆栈中的类名,确认是否因混淆导致类找不到或依赖缺失。
12. 面试题与参考答案
12.1 基础概念
Q1:什么是类加载?类加载包含哪几个阶段?
- 类加载(广义):把类的字节码从存储介质读入内存,经验证、准备、解析、初始化,形成可用的类(堆中有
Class对象,方法区有元数据),供创建实例、访问静态成员、反射等使用。 - 五个阶段:加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization)。加载是“找字节流、生成 Class”;验证是检查字节码合法性;准备是给静态变量赋零值(常量可在此阶段赋真值);解析是把符号引用转为直接引用;初始化是执行
<clinit>(静态块与静态变量赋值)。能按顺序说出各阶段做什么即可。
Q2:Android 和 JVM 在类加载上有什么主要区别?
- 字节码:JVM 用
.class,Android 用 DEX(多 class 合并、方法数 65536 限制、MultiDex)。 - 类加载器:JVM 有 Bootstrap、Extension、Application;Android 有 BootClassLoader、PathClassLoader、DexClassLoader(无 Extension),Path 加载已安装 APK 内 DEX,Dex 用于动态 DEX。
- 运行与优化:Android 有 AOT(安装或运行时生成 .oat)、AOT+JIT,与纯 JIT 的典型 JVM 不同;类加载阶段都包含加载~初始化,但 DEX 格式与查找方式不同。
12.2 类加载器
Q3:Android 中有哪些类加载器?PathClassLoader 和 DexClassLoader 有什么区别?
- 常见类加载器:BootClassLoader(系统框架类)、PathClassLoader(应用 APK 内 DEX)、DexClassLoader(可从指定路径加载 DEX)、InMemoryDexClassLoader(API 26+,从内存 Buffer 加载)。
- Path 与 Dex 区别:Path 由系统创建,加载已安装 APK 的 DEX(如
/data/app/.../base.apk);Dex 由开发者new,可从任意可读路径加载 DEX(插件、热修、动态下发)。Android 8.0 起二者底层实现趋同(都继承 BaseDexClassLoader),主要区别在 API 与使用场景:Path 用于应用默认类,Dex 用于动态 DEX。
Q4:什么是双亲委派?有什么好处?
- 流程:加载类时先交给 parent ClassLoader,parent 再委派其 parent,直到顶层;若父链都找不到,才由当前 ClassLoader 调用
findClass自己加载。 - 好处:安全(避免自定义类冒充系统类,如自定义
java.lang.String不会被加载);唯一性(同名的类由同一加载器加载,避免多份);避免重复(父已加载则直接返回)。
Q5:什么场景下会破坏双亲委派?怎么实现?
- 场景:插件化(希望插件 DEX 里的类优先于宿主)、热修复(补丁类要替换原类)、SPI(如 JDK 的 ServiceLoader 需要线程上下文类加载器加载实现类)。
- 实现:重写 loadClass,对指定包名或类先判断是否“归自己管”,若是则先调用
findClass加载,不再委派给 parent;否则再调用super.loadClass走委派。也可通过反射修改 parent(不推荐)。
12.3 自定义与底层
Q6:如何实现自定义 ClassLoader?要注意什么?
- 实现:继承 BaseDexClassLoader 或 DexClassLoader,重写 findClass(在传入的 DEX 路径中查找并 defineClass);若需破坏委派,则重写 loadClass,对特定包/类先 findClass 再委派。
- 注意:optimizedDirectory 需为应用私有目录(如
getDir("dex", 0)、getCacheDir()),Android 8.0+ 可为 null;插件/热修场景注意类隔离(不同插件用不同 ClassLoader)和缓存键(类名 + ClassLoader)。 - 代码要点:构造传
(dexPath, optimizedDir, libraryPath, context.getClassLoader());只做“从自己的 DEX 找类”就只重写findClass;要做“插件包优先”则重写loadClass,对指定包先findClass再super.loadClass。
Q7:类在 JVM/ART 中的唯一性由什么决定?
- 全限定名 + ClassLoader。同一 ClassLoader 下同名类只加载一次;不同 ClassLoader 加载的同名类是不同类(不能互相 cast、instanceof),这就是命名空间隔离,插件化、热修依赖这一点。
Q8:类加载器底层是怎么找到并加载类的?(Android)
- BaseDexClassLoader 内部持有一个 DexPathList,维护多个 Element(每个对应一个 DEX 路径或文件)。
- findClass 时遍历 Element 数组,在对应 DexFile 中查找类名,找到则通过 defineClass 等完成类定义,最终在 ART 中完成验证、准备、解析、初始化。可简答:BaseDexClassLoader → DexPathList → Element → DexFile → ART。
12.4 异常与优化
Q9:ClassNotFoundException 和 NoClassDefFoundError 有什么区别?
- ClassNotFoundException:主动调用
loadClass/Class.forName时,在 ClassLoader 链上找不到类定义(类不在 DEX、路径错、混淆、用错 ClassLoader、MultiDex 未装等)。 - NoClassDefFoundError:编译时类存在,运行时首次使用该类时失败——常因依赖类缺失或类初始化失败(
<clinit>抛异常),该类被标记为不可用,后续任何使用都报此错。可记:前者“找不到”,后者“找到了但用不了”。
Q10:类卸载需要满足什么条件?Android 里能依赖类卸载做内存优化吗?
- 三条件同时满足:
- ① 该类无实例
- ② 加载该类的 ClassLoader 已被回收
- ③ 该类的 Class 对象无其他引用
- Android:应用主逻辑由 PathClassLoader 加载,它常驻进程,因此其加载的类几乎不会卸载。不能依赖类卸载做内存管理;插件/热修用独立 ClassLoader 时,可在插件卸载后期待其类被 GC,但不要对主 APK 类抱期望。
Q11:类加载性能如何优化?
- 预加载:在 Application 或空闲时提前加载启动与关键路径上的类,把 I/O、验证、初始化的成本前移。
- 多 DEX / 主 DEX:用
mainDexList、ProGuard 控制主 DEX 只保留启动必需类,其余分包按需加载,减少启动时集中加载的类数。 - 减少不必要加载:按需加载模块,避免启动时拉整包;ProGuard 剪枝。
- 缓存:自定义 ClassLoader 时在 findClass 前查缓存,避免同一类重复读 DEX、重复验证。
- 监控:对 loadClass/findClass 做耗时统计,发现慢类与瓶颈。
12.5 实践场景
Q12:插件化中类加载是怎么做的?
- 获取插件 APK 中的 DEX(解压或直接读);用 DexClassLoader(或自定义子类)加载该 DEX;通过 loadClass 加载插件类,反射创建实例、调用方法。
- 类隔离:每个插件使用独立 ClassLoader(父一般为宿主 getClassLoader()),或重写 loadClass 使插件包名下的类优先由插件 ClassLoader 加载,避免与宿主类冲突。
- 资源:用 AssetManager 添加插件路径,生成独立 Resource;Activity 多通过代理 Activity 或 Hook 系统方式启动插件界面。
可答代码要点:
// 1. 创建 DexClassLoader(DEX 路径、优化目录、父加载器)
DexClassLoader pluginLoader = new DexClassLoader(
dexPath, getDir("plugin_opt", 0).getAbsolutePath(), null, getClassLoader());
// 2. 加载插件类并反射调用
Class<?> clazz = pluginLoader.loadClass("com.plugin.PluginEntry");
Object instance = clazz.newInstance();
Method method = clazz.getMethod("run");
method.invoke(instance);
附录:速记版(背诵用)
A. 生命周期与初始化
- 七阶段:加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载(前五 = 类加载过程)。
- 每阶段一句话:
- 加载:找字节流,建元数据 +
Class。 - 验证:保安全,查格式/元数据/字节码/引用。
- 准备:给 static 分配内存 + 零值(编译期常量可直接真值)。
- 解析:符号引用 → 直接引用。
- 初始化:执行
<clinit>,给 static 赋值 + 跑静态块,父类先于子类,只执行一次。
- 加载:找字节流,建元数据 +
- 主动 vs 被动初始化:
- 主动:
new/ 访问非常量静态字段 / 静态方法 /Class.forName/ 子类初始化 / 主类。 - 被动:子类引用父类静态字段、数组引用类、访问编译期常量。
- 主动:
- 一次
new三种情况:- 未加载:加载 → 验证 → 准备 → 解析 → 初始化 → 实例化。
- 已加载未初始化:初始化 → 实例化。
- 已初始化:直接实例化。
B. 类加载器与双亲委派
- JVM 三层:Bootstrap / Extension / Application。
- Android 四个关键词:BootClassLoader / PathClassLoader / DexClassLoader / InMemoryDexClassLoader。
- 谁干什么:
- Boot:系统框架类(
java.*、android.*)。 - Path:已安装 APK 内 DEX。
- Dex:任意路径 DEX(插件 / 热修 / 动态加载)。
- InMemoryDex:内存里的 DEX(ByteBuffer,API 26+)。
- Boot:系统框架类(
- 底层链路口诀:BaseDexClassLoader → DexPathList → Element → DexFile → ART。
- 双亲委派一句话:先问“爸”(parent),爸不会再自己
findClass。 - 双亲委派的好处:安全(系统类不被替换)+ 唯一(同一类一份)+ 不重复加载。
- 破坏双亲委派的典型场景:插件化、热修复、SPI(线程上下文类加载器)。
C. Android 特有点
- 字节码:
.class→ 合并成.dex,单 DEX ≤ 65536 方法,需要 MultiDex。 - 多 DEX 要点:
multiDexEnabled true+MultiDex.install()+ 控制主 DEX(启动必需类)。 - AOT + JIT:安装 / 后台把 DEX 编成
.oat,运行时再配合 JIT。 - 命名空间:类唯一性 = 全限定名 + ClassLoader,同名类不同 Loader 视为不同类(插件隔离、多版本共存)。
D. 卸载与常见异常
- 类卸载三条件(且关系):① 无实例 ② Loader 被回收 ③
Class对象无引用。
Android 中 PathClassLoader 常驻 → 主 APK 类几乎不卸载。 - 两大异常对比:
ClassNotFoundException:主动loadClass/Class.forName找不到类 → 类本身不在 DEX / 路径 / Loader 错。NoClassDefFoundError:编译时有、运行时用不了 → 依赖类缺失 /<clinit>抛异常 → “找到了但用不了”。
- LinkageError 家族速记:
ClassFormatError:class/DEX 格式坏了(不完整 / 被篡改)。VerifyError:验证不过(继承 final、类型不匹配等)。UnsupportedClassVersionError:版本太高,环境太低。
E. 实战场景与调试
- 插件化类加载 4 步:拿插件 APK DEX →
new DexClassLoader→loadClass→ 反射调用(资源 / 组件用代理或 Hook)。 - 热修复 4 步:下载补丁 DEX →
DexClassLoader加载 → 取新类 → 修改 DexPathList,把补丁 DEX 插到前面。 - 动态加载 3 步:获取 DEX(文件 / 内存)→
DexClassLoader/InMemoryDexClassLoader→loadClass+ 反射。 - 性能优化 5 词:预加载 / 主 DEX 控制 / 剪枝减包 / 缓存 / 打点监控。
- 调试排查 4 步通用法:
- 看异常 message / cause,先分清
ClassNotFoundException还是NoClassDefFoundError/VerifyError等。 - 确认类是否在 DEX 里(反编译 / 工具)、有没有被 ProGuard 删。
- 检查使用的 ClassLoader / MultiDex / AAR 依赖是否正确。
- 结合 logcat + 打点(
loadClass耗时、ClassLoader 链)定位具体问题点。
- 看异常 message / cause,先分清