ClassLoader机制:PathClassLoader与DexClassLoader、ClassTable与查找链、类的懒加载
说到Android的ClassLoader,我脑子里第一个蹦出来的画面,是几年前排查一个插件化框架的bug。那时候App启动后总报ClassNotFoundException,查了两天才发现是ClassLoader的parent设错了。嗯,从那以后,我对这块就格外上心。
今天咱们就来聊聊Android Runtime里的类加载机制。说白了,就是系统怎么找到你写的那个类,把它拉进内存里。
PathClassLoader vs DexClassLoader
Android里有两个核心的ClassLoader实现:PathClassLoader和DexClassLoader。很多人以为它们功能完全不同,其实不然。
| 特性 | PathClassLoader | DexClassLoader |
|---|---|---|
| 加载路径 | 只支持已安装APK的dex/jar | 支持任意路径的dex/jar/apk |
| 优化目录 | 系统自动管理 | 需要开发者指定 |
| 典型用途 | 系统默认加载器 | 插件化、热修复 |
我个人习惯把PathClassLoader看作是"正统军",它只加载系统认可的路径。而DexClassLoader更像是"特种兵",你可以让它去任何地方找dex文件。
核心区别:PathClassLoader的dexPath只能包含已安装APK的路径,而DexClassLoader没有这个限制。但底层实现上,它们最终都调用了同一个native方法——OpenDexFile。
你想想看,为什么要有这种区分?其实是为了安全。系统不希望随便一个第三方代码就能从任意位置加载dex,那不乱套了?
ClassTable与查找链
类加载的核心,是ClassTable。每个ClassLoader内部都维护着一张表,记录着"这个类我有没有加载过"。
查找链,说白了就是双亲委派模型。我画个图你一看就明白:
这个流程,我称之为"向上问,向下答"。子ClassLoader收到加载请求后,先问parent:"这个类你加载过吗?"parent再往上问。直到最顶层的Bootstrap ClassLoader。
如果顶层也没加载过,那就一层层往下传,谁有能力谁加载。加载成功后,结果会记录在各自的ClassTable里。
小技巧:调试类加载问题时,可以用ClassLoader.getResources()来查看某个资源会被哪个ClassLoader命中。我曾经靠这招定位过一个资源冲突的bug。
ClassTable的内部结构
ClassTable在ART里是个什么结构?说白了就是一个std::unordered_map,key是类名(比如"java/lang/String"),value是ObjPtr<mirror::Class>。
但这里有个细节:ClassTable是分代的。ART把ClassTable分成两部分:
- 共享表:存放从boot classpath加载的类,所有进程共享
- 本地表:存放当前ClassLoader加载的类,每个ClassLoader独有
为什么要分代?我举个例子你就懂了。假设你的App有1000个类,系统框架有5000个类。如果不分代,每次查找都要遍历6000个条目。分代后,本地表只查1000个,共享表查5000个,但共享表是只读的,可以无锁访问。
// ART源码中ClassTable的简化结构
class ClassTable {
// 共享表 - 只读,无锁访问
std::vector<ClassTableEntry> shared_table_;
// 本地表 - 读写,需要加锁
std::vector<ClassTableEntry> local_table_;
// 查找流程
ObjPtr<mirror::Class> Lookup(const char* descriptor) {
// 先查本地表
auto result = LookupInLocalTable(descriptor);
if (result != nullptr) return result;
// 再查共享表
return LookupInSharedTable(descriptor);
}
};
注意:ClassTable的查找是线性扫描。虽然ART做了优化(比如按hash分组),但如果你的ClassLoader加载了上万个类,查找性能还是会下降。这就是为什么插件化框架要控制每个插件的类数量。
类的懒加载
懒加载,说白了就是"用到的时候才加载"。Android里类的加载时机是这样的:
- 首次主动使用:new对象、调用静态方法、访问静态字段
- 反射调用:Class.forName()、ClassLoader.loadClass()
- 子类初始化:如果父类还没加载,会先触发父类加载
但注意,不是所有引用都会触发加载。比如你定义一个成员变量List<String> names,如果只是声明,List并不会被加载。只有真正用到的时候才加载。
我记得有一次,团队里有人写了个工具类,里面引了十几个第三方库的类。结果App启动时卡了好几百毫秒。一查才发现,他在静态初始化块里调了那些库的方法,导致启动时全部加载了。
避坑指南:我曾经把一个重量级SDK的初始化放在Application的静态块里,结果启动时间从200ms飙到800ms。后来改成懒加载,只在用户点击相关功能时才初始化,体验好多了。
ART对懒加载做了个优化叫"预解析"(Pre-resolution)。系统会在空闲时,提前解析一些常用的类引用。这样当真正用到时,省去了查找和解析的时间。
但预解析也不是万能的。它只针对dex文件里明确引用的类。如果你用反射或者动态生成字节码,那ART就帮不上忙了,只能运行时现场加载。
实战:自定义ClassLoader
说了这么多理论,咱们来点实际的。假设你要实现一个简单的插件加载器:
public class PluginClassLoader extends DexClassLoader {
public PluginClassLoader(String dexPath, String optimizedDir,
ClassLoader parent) {
super(dexPath, optimizedDir, null, parent);
}
@Override
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 先查自己有没有加载过
Class<?> clazz = findLoadedClass(name);
if (clazz != null) return clazz;
// 尝试自己加载
try {
clazz = findClass(name);
return clazz;
} catch (ClassNotFoundException e) {
// 自己加载不了,交给parent
return super.loadClass(name, resolve);
}
}
}
这段代码看起来简单,但有个坑:我故意打破了双亲委派模型。标准的双亲委派是先问parent,parent不行才自己加载。但这里我先自己加载,自己加载不了才问parent。
为什么要这么干?因为插件里的类可能和宿主类同名。如果先问parent,parent加载了宿主的版本,插件自己的版本就用不了了。
警告:打破双亲委派模型要非常小心。搞不好就会出现ClassCastException——同一个类,两个ClassLoader各加载了一份,它们互相不认。
嗯,关于ClassLoader的内容就先聊到这儿。记住三个核心点:PathClassLoader和DexClassLoader的路径区别、ClassTable的分代查找、以及懒加载的触发时机。这些搞明白了,Android的类加载机制你就掌握了七八成。
公众号:蓝海资料掘金营,微信deep3321