Skip to content

类加载器命名空间/类的唯一性

命名空间:每个类加载器都有自己的「宇宙」

你有没有想过:能不能在同一个 JVM 中加载两个 java.lang.String?答案是不一定——取决于类加载器。

这一节来深入理解类加载器的命名空间和类的唯一性。

命名空间的概念

命名空间(Namespace)是 JVM 为每个类加载器维护的一张「类名表」。

命名空间结构:

┌─────────────────────────────────────────┐
│ Bootstrap ClassLoader 的命名空间          │
│   java.lang.String                      │
│   java.lang.Object                       │
│   java.io.File                          │
│   ...                                   │
├─────────────────────────────────────────┤
│ Extension ClassLoader 的命名空间          │
│   com.sun.xxx.internal.X               │
│   ...                                   │
├─────────────────────────────────────────┤
│ Application ClassLoader 的命名空间        │
│   com.example.User                      │
│   com.example.Order                      │
│   ...                                   │
├─────────────────────────────────────────┤
│ MyClassLoader 的命名空间                  │
│   com.example.MyClass                   │
│   ...                                   │
└─────────────────────────────────────────┘

关键点:不同命名空间之间,类是隔离的。同一个全限定名在不同命名空间中可能指向不同的类。

类的唯一性判定

JVM 中类的唯一性由以下两者共同决定:

类的唯一性 = 类加载器实例 + 类的全限定名
同一个类 ≠ 类名相同
同一个类  = 类加载器实例  +  类名完全相同
java
// 示例:两个类加载器加载同一个类
ClassLoader loader1 = new MyClassLoader();
ClassLoader loader2 = new MyClassLoader();

Class<?> c1 = loader1.loadClass("com.example.User");
Class<?> c2 = loader2.loadClass("com.example.User");

// c1 != c2!它们的类加载器不同!

全盘委托机制

当一个类加载器要加载类时,它会先委托父加载器去尝试加载,只有父加载器无法完成时,自己才去加载。

全盘委托(也叫双亲委派):

Application ClassLoader 要加载 User 类


    委托给 Extension ClassLoader


    委托给 Bootstrap ClassLoader


    Bootstrap 无法加载(不在其负责范围内)


    返回给 Extension ClassLoader


    Extension 无法加载(不在其负责范围内)


    返回给 Application ClassLoader


    Application 自己加载 User 类

全盘委托的意义

全盘委托保证了 Java 核心类库的安全性:

尝试用自定义类加载器加载 java.lang.String


    委托到 Bootstrap ClassLoader


    Bootstrap 成功加载 java.lang.String


    自定义类加载器使用 Bootstrap 加载的 String


    ✓ 安全:自定义 String 不会污染核心类库

如果没有全盘委托,自定义类加载器可能加载一个恶意的 java.lang.String,替换掉核心类库中的实现——这是非常危险的。

全盘委托的副作用

全盘委托也会带来一些限制:

java
// 场景:插件系统,两个不同版本的同名类
class PluginA {
    class V1 { void method() { System.out.println("V1"); } }
}

class PluginB {
    class V2 { void method() { System.out.println("V2"); } }
}

// 两个 V1 和 V2 不能在同一个 JVM 中共存
// 因为 Application ClassLoader 只能加载一个 V1.class

这催生了 OSGi 和热部署框架的需求——它们需要打破全盘委托。

命名空间的隔离性

不同命名空间的类之间是严格隔离的:

java
// 命名空间隔离示例
class LoaderA extends ClassLoader {
    public Class loadClass(String name) {
        return findClass(name);  // 不走双亲委派
    }
}

class LoaderB extends ClassLoader {
    public Class loadClass(String name) {
        return findClass(name);  // 不走双亲委派
    }
}

// LoaderA 和 LoaderB 加载的同名类是不同的类

跨命名空间访问的限制

一个命名空间中的类,可以访问另一个命名空间中的类吗?

java
// 规则:
// 1. 子命名空间的类可以看到父命名空间中的类
//    Application ClassLoader 的类能看到 Extension/Bootstrap 中的类
// 2. 父命名空间的类看不到子命名空间中的类
//    Bootstrap ClassLoader 的类看不到 Application 中的类
// 3. 同级命名空间的类通常互相不可见
//    LoaderA 加载的类看不到 LoaderB 加载的类
可见性关系(箭头表示「可见」):

Bootstrap → Extension → Application → 自定义加载器
    ↑           ↑           ↑
    │           │           │
  看不到     看不到       看不到
  子空间     子空间       子空间

Class 对象的关系

不同命名空间中的类,它们的 Class 对象之间是什么关系?

java
Class<?> c1 = loader1.loadClass("com.example.User");
Class<?> c2 = loader2.loadClass("com.example.User");
Class<?> c3 = loader1.loadClass("com.example.User");

// c1 == c3(同一个类加载器实例,加载同一个类,Class 对象相同)
// c1 != c2(不同的类加载器实例,Class 对象不同)
类加载器实例 + 类名 → Class 对象

loader1 + "User" → Class 对象 A
loader2 + "User" → Class 对象 B(A != B)
loader1 + "User" → Class 对象 A(loader1 已加载,直接返回)

类加载器实例与 Class 对象的关系

┌────────────────────────────────────────────┐
│ Class 对象 A                                 │
│   │                                          │
│   ├── 类名:com.example.User                  │
│   ├── 加载器:loader1                         │
│   ├── 类加载器实例 A 的命名空间                 │
│   └── 方法区数据                              │
├────────────────────────────────────────────┤
│ Class 对象 B                                 │
│   │                                          │
│   ├── 类名:com.example.User                  │
│   ├── 加载器:loader2                         │
│   ├── 类加载器实例 B 的命名空间                 │
│   └── 方法区数据                              │
└────────────────────────────────────────────┘

A != B(即使类名相同,加载器不同,Class 对象不同)

打破命名空间隔离的场景

场景一:JDBC 驱动的加载

JDBC 的 DriverManager 使用 Thread Context ClassLoader(TCCL)来加载驱动:

java
// JDBC 的设计哲学
// 核心 API 在 java.sql 中,由 Bootstrap ClassLoader 加载
// 驱动实现由各厂商提供,在 classpath 中,由 Application ClassLoader 加载
// DriverManager 需要能加载不同加载器的驱动
// 解决方案:使用 TCCL

ClassLoader tccl = Thread.currentThread().getContextClassLoader();
Driver driver = (Driver) Class.forName("com.mysql.jdbc.Driver", true, tccl);

场景二:热部署

Tomcat 和 OSGi 通过销毁类加载器来卸载旧版本类:

Tomcat 热部署流程:

1. 新请求到来,检测到有新版本的类
2. 创建新的 WebappClassLoader(新的命名空间)
3. 新的类加载器加载新版本类
4. 旧类加载器没有引用了
5. 旧类加载器可以被 GC 回收
6. 旧类加载器加载的类随之卸载
7. 新旧版本类共存(在不同命名空间)

命名空间与反射

反射操作可能遇到跨命名空间的问题:

java
// 问题:跨命名空间的类型转换
Class<?> aClass = loaderA.loadClass("com.example.User");
Class<?> bClass = loaderB.loadClass("com.example.User");

// aClass != bClass
// 虽然都叫 User,但它们是不同的类型!

Object obj = aClass.newInstance();

// obj instanceof com.example.User(loaderA 的 User) → true
// obj instanceof com.example.User(loaderB 的 User) → ClassCastException

核心问题:同一个类名在不同命名空间中代表不同的 Class 对象,类型检查会失败。

本节小结

命名空间和类的唯一性核心要点:

维度说明
命名空间每个类加载器实例维护一个命名空间
类的唯一性类加载器实例 + 全限定名共同决定
全盘委托加载类时先委托父加载器,只有父无法完成时才自己加载
可见性父命名空间看不到子命名空间,子命名空间能看到父命名空间
打破隔离TCCL、OSGi、热部署等场景需要打破隔离
反射问题跨命名空间的同名类,Class 对象不同,类型转换会失败

理解命名空间,才能理解类加载器的隔离机制,以及为什么需要打破双亲委派。

下一节,我们来看 双亲委派机制破坏/热替换

基于 VitePress 构建