JVM 派:类加载器与那个经典异常
ClassCastException: Foo cannot be cast to Foo —— 第一次见到这句话的人都会怀疑自己的眼睛。它不是 JVM 的 bug,恰恰相反:它是类加载器隔离在正常工作的证据。这一章把它拆开,然后回答一个更有意思的问题:Mihon 和 IntelliJ 明知道有这个坑,为什么还是要往里跳?
一条规则,全部由它推出
上一章末尾给过了,这里再郑重地写一遍,因为这一章所有内容都是它的推论:
一个类的运行时身份 = 全限定名 + 定义它的类加载器。
不是「全限定名」。是「全限定名 加上 谁加载了它」。
这条规则最直接的后果是:
val loaderA = URLClassLoader(arrayOf(sameJar), null)
val loaderB = URLClassLoader(arrayOf(sameJar), null) // 同一个 jar!
val clsA = loaderA.loadClass("com.example.Foo")
val clsB = loaderB.loadClass("com.example.Foo")
clsA == clsB // false —— 两个不同的 Class 对象
clsA.name == clsB.name // true —— 但名字一模一样
val obj = clsA.getDeclaredConstructor().newInstance()
clsB.isInstance(obj) // false
obj as Foo // 💥 ClassCastException: com.example.Foo cannot be cast to com.example.Foo
同一个 jar 文件,加载两次,得到两个互不相认的类。报错信息之所以看起来像句废话,是因为它只打印了类名 —— 而真正的区别在于加载器,那个信息没被打进去。
(比较新的 JDK 会在这条消息后面补上加载器信息,类似 Foo is in unnamed module of loader 'app'; Foo is in unnamed module of loader com.example.PluginLoader @1b6d3586 —— 好很多,但还是得先懂原理才看得明白。)
为什么 JVM 要这么设计
这看起来像是自找麻烦。为什么不干脆规定「同名即同类」?
因为那样的话,任何人都能顶替你的类。
假设「同名即同类」,我写一个 java.lang.String,让它在你调用 equals 时把内容发到我服务器上,然后想办法让我的版本先被加载 —— 你整个程序里所有的字符串比较都被劫持了。
所以 JVM 需要一种机制,保证核心类库永远由可信的加载器加载。这就是双亲委派。
双亲委派:先问父亲
标准的 ClassLoader.loadClass 逻辑,去掉细节就是这样:
// java.lang.ClassLoader 的默认行为(简化)
protected fun loadClass(name: String): Class<*> {
// ① 我加载过了吗
findLoadedClass(name)?.let { return it }
// ② 没有的话,先问父亲(一路问到最顶上的 Bootstrap)
try {
return parent.loadClass(name) // ← 「委派」
} catch (e: ClassNotFoundException) {
// 父亲也没有,那才轮到我
}
// ③ 自己去找
return findClass(name)
}
这套机制保证了两件事:
① 安全:java.lang.String 永远由 Bootstrap 加载器提供,你在自己 jar 里放一个同名类是没用的 —— 请求会先到达父亲那里,父亲有,就不会轮到你。
② 唯一性:整个应用里 java.lang.String 只有一份,所有代码拿到的是同一个类,类型互通。
但插件系统需要反过来
现在设想一个具体场景:
- 宿主自己用了 okhttp 3.14;
- 某个插件用了 okhttp 4.12 才有的 API,所以它把 4.12 打包进了自己的 jar。
在双亲委派下会发生什么?
插件请求 okhttp3.OkHttpClient → 先问父亲 → 父亲(宿主加载器)说「我有啊,3.14 版的」→ 插件拿到 3.14。
然后插件调用一个 4.x 才有的方法 → NoSuchMethodError,运行时崩。
插件精心打包的 4.12 完全没被用上。它被自己的父亲「好心」地覆盖了。
这就是为什么插件系统几乎都要打破双亲委派,改成子优先(child-first / parent-last):
class ChildFirstClassLoader(
urls: Array<URL>,
parent: ClassLoader,
) : URLClassLoader(urls, parent) {
override fun loadClass(name: String, resolve: Boolean): Class<*> {
synchronized(getClassLoadingLock(name)) {
findLoadedClass(name)?.let { return it }
// ★ 反过来:先翻自己的
try {
return findClass(name).also { if (resolve) resolveClass(it) }
} catch (e: ClassNotFoundException) {
// 自己没有,再问父亲
}
return super.loadClass(name, resolve)
}
}
}
Mihon 用的就是这个 —— 它那个类叫 ChildFirstPathClassLoader,构造时把父加载器传成宿主的 classLoader,但优先从扩展自己的 DEX 里找。IntelliJ 的插件加载器也是同样的策略:先找插件自己的类,找不到再问 IDE 主加载器和它声明依赖的那些插件。
亲手切换两种策略
下面这个模拟器实现了完整的解析逻辑。场景就是上面说的那个:宿主带 okhttp 3.14,插件 A 带 4.12。切换策略,看解析过程和最终后果:
把两种策略的结果并排放:
| 双亲委派 | 子优先 | |
|---|---|---|
| 插件拿到的 okhttp | 宿主的 3.14(自己带的被忽略) | 自己的 4.12 ✓ |
| 插件用 4.x API | 💥 NoSuchMethodError | 正常 |
| 跨边界传 OkHttpClient 对象 | 正常(同一个类) | 💥 ClassCastException |
| 内存里有几份 okhttp | 一份 | 两份(每个插件一份) |
没有免费午餐:你要么牺牲插件的依赖自由,要么牺牲跨边界的类型互通。
那怎么办:边界上只传宿主的类型
所有采用子优先的系统,都靠同一条纪律活着:
宿主和插件之间的接口签名上,只允许出现「由宿主加载器定义的类型」和 JDK 内置类型。插件自带的库类型,绝不能出现在边界上。
为什么这条纪律能解决问题?因为子优先的加载器在自己找不到时会去问父亲。宿主定义的那些接口(NotePlugin、Source、HostContext)只存在于宿主的 jar 里,插件的加载器找不到,就一定会向上委派到宿主加载器 —— 于是双方拿到的是同一个类。
具体到写法:
// ✓ 好:签名上全是宿主定义的类型和内置类型
interface Source {
val name: String // 内置
fun fetchPopular(page: Int): MangasPage // MangasPage 由宿主定义
}
// ✗ 坏:签名上出现了第三方库的类型
interface Source {
fun fetchPopular(page: Int): okhttp3.Response // 💥
// ↑ 插件的 okhttp 和宿主的 okhttp 不是同一个类
}
// ✗ 也坏:泛型参数里藏着第三方类型
fun configure(client: OkHttpClient.Builder) // 💥 一样的问题
Mihon 的接口设计严格遵守这条:SManga、SChapter、Page、MangasPage 全都是宿主定义的简单数据类。虽然扩展内部大量使用 okhttp 和 jsoup,但那些类型一个都不出现在 Source 接口的签名上。
① 异常类型。插件抛出一个它自带的库定义的异常,宿主 catch (e: SomeLibException) 时抓不到(不是同一个类),最后变成一个没人处理的异常。解法:宿主定义自己的异常类型,或者只 catch Throwable 再看 message。
② 序列化 / 反射框架。Gson、Jackson、kotlinx.serialization 这类库会缓存「类 → 序列化器」的映射。如果宿主和插件各有一份,缓存里的 Class 对象就对不上,症状是「明明注册了适配器却没生效」。解法:把序列化库放在宿主侧,作为共享依赖提供给插件,不让插件自己打包。
③ 回调里的 lambda。看起来只是个函数,但它的类型在 JVM 上是有身份的。跨边界传高阶函数时要确保函数类型的参数和返回值也都是共享类型。
「共享」哪些依赖,是一个正经的设计决定
由此引出一个真实系统必须回答的问题:哪些库应该由宿主提供(共享),哪些应该让插件自己带(隔离)?
| 宿主提供(共享) | 插件自带(隔离) | |
|---|---|---|
| 好处 | 类型互通、内存只有一份、版本统一 | 插件可用任意版本、不受宿主升级影响 |
| 坏处 | 宿主升级这个库 = 可能弄死插件 | 内存多份、不能出现在边界上 |
| 适合 | 出现在 API 签名上的类型、序列化框架、日志门面 | 纯内部使用的工具库、解析库、算法库 |
一条实用的判断标准:这个库的类型会不会出现在宿主和插件之间传递的数据里?会 → 必须共享;不会 → 让插件自带,你也少一份维护负担。
顺带一提,这也解释了为什么很多插件 API 的数据类都设计得极其朴素(只有 String、Int、List)—— 不是设计者偷懒,是在刻意缩小「必须共享」的表面积。
OSGi:把这件事做到极致的那个
值得一提的是,Java 世界有一个把类加载器隔离推到极限的框架:OSGi。它让每个模块(bundle)显式声明「我导出哪些包、我导入哪些包、各要什么版本」,然后由框架构建一张精确的类可见性图 —— 你甚至可以让同一个库的两个版本在同一个 JVM 里各服务各的模块。
Eclipse 就建立在 OSGi 上,这也是它插件生态极其强大的原因之一。
但它也是个警示故事:OSGi 的复杂度极高,配置、调试、诊断都很痛苦(「为什么我的类找不到」在 OSGi 里是一个能耗掉一整天的问题)。后来的系统(IntelliJ、Android、VS Code)都刻意选择了更简单、更粗糙的模型:每个插件一个加载器、子优先、边界上只传共享类型 —— 大约 80% 的收益,20% 的复杂度。
这是个反复出现的教训:插件系统的复杂度会被生态里的每一个人分摊承受。你多加一层机制,就是让几百个插件作者每人多学一个概念、多调一类 bug。
装到自己身上
下次遇到 Foo cannot be cast to Foo,别猜,直接把加载器打出来:
fun diagnose(obj: Any, expected: Class<*>) {
val actual = obj.javaClass
println("对象的类: ${actual.name}")
println(" 加载器: ${actual.classLoader}")
println("期望的类: ${expected.name}")
println(" 加载器: ${expected.classLoader}")
println("是同一个类吗: ${actual == expected}")
println("名字一样吗: ${actual.name == expected.name}")
}
// 典型输出:名字一样 = true,是同一个类 = false → 就是本章这个问题
「名字一样但不是同一个类」,这两行一打出来,问题的性质就确定了,剩下的就是去找为什么那个类被加载了两次 —— 通常是某个本该共享的库被插件也打包了一份。
① 用子优先,但把边界类型放进宿主。这是 Mihon / IntelliJ 的方案,也是绝大多数情况下的正确答案。
② 把「共享依赖清单」写进文档,并在构建期强制。Gradle 里让插件用 compileOnly 依赖那些共享库 —— 编译时能用,但不打包进产物。这是把纪律变成机器检查的最简单办法:
dependencies {
compileOnly(project(":host-api")) // 宿主提供,不打包
compileOnly("com.squareup.okhttp3:okhttp:3.14.9")
implementation("some.internal:parser:2.0") // 自己用的,打包进去
}
③ 每个插件一个加载器,别图省事共用一个。共用的话,两个插件带了不同版本的同一个库时,谁先加载谁赢 —— 又变成了那种「换台机器就不一样」的玄学 bug。
④ Android 上记得 DexClassLoader 需要一个可写的优化目录,而且这个目录必须是应用私有的。如果放到外部存储上,别的应用就能替换里面的优化产物 —— 那是一个真实存在过的攻击面。用 context.codeCacheDir。
这一章的一句话
在 JVM 上类的身份是「名字 + 加载器」,所以插件系统必须在「插件能自带库版本」和「跨边界类型互通」之间选一个;主流答案是子优先加载器 + 一条纪律:边界上只出现宿主定义的类型。
下一章把这套东西放到真实世界里 —— Mihon 是怎么把一整个 APK 装进自己进程的,从 PackageManager 一路到 newInstance(),并且我们会真的跑一个源插件。