如何在Java中加载两个类全限定名相同的类?

我们知道在Java中类全限定名由两部分组成,包名类名,当然网上也有说法是由三部分组成,包名、子包名以及类名,这里我把包相关的统称为包名。

比如说在某个Java项目中com.knight包下有一个类A,那么这个类A的类全限定名就为:com.knight.A。我们如果在相同包路径有相同的类名,往往编译是通过不了的。

那么是否有可能在同一个Java项目中加载类全限定名完全相同实现上不同的两个类?如果不可以,是否就代表代表类的唯一路径就是类全限定名?如果可以,那类的全限定名在java中真的唯一标识了一个类吗?

以下内容涉及到的知识点:Java的反射机制双亲委派模型。如果不太了解的同学,建议先了解后再来看本篇文章哦。

洪爵准备先进行实操,把最终的结果先落地,然后再根据结果来讨论。

首先我们创建一个Java项目,洪爵命名为Main,然后创建包路径com.knight,在该包路径下创建一个A类,在这个A类里,我们创建一个public方法,返回一个String,方法名为getVersion()。

项目树状图:
在这里插入图片描述

A.java代码:

public class A {
    public String getVersion() {
        return "1.0";
    }
}

我们运行javac编译A.java,会在com.knight包下生成A.class文件。

javac ./src/com/knight/A.java

然后我们把A.class文件移到项目根目录下(连同包名文件夹一起)。
在这里插入图片描述

然后修改A.java的代码,让getVersion的方法返回值为"2.0"。

现在洪爵想创建一个Main.java文件,在这个Java文件里,洪爵会尝试导入这两个类全限定名相同的A类。首先如果洪爵什么都不做,直接去import这两个类,无疑是不行的,大家肯定都尝试过,那这里的解法就需要提到双亲委派模型了。

我们知道除了应用类加载器、拓展类加载器和启动类加载器外,还有一种自定义类加载器,洪爵尝试使用自定义类加载器看是否能把这两个A类都加载进来。

class MyClassLoader extends ClassLoader {
    private final String classPath;

    // 自定义前缀
    private static final String PREFIX = "prefix.";

    public static String getPrefix() {
        return PREFIX;
    }

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    // 读取文件
    private byte[] loadByte(String name) throws Exception {
        name = name.replaceAll("\.", "/");
        FileInputStream fileInputStream = new FileInputStream(classPath + "/" + name + ".class");
        int len = fileInputStream.available();
        byte[] data = new byte[len];
        fileInputStream.read(data);
        fileInputStream.close();
        return data;
    }

    @Override
    protected Class<?> findClass(String name) {
        try {
            name = name.substring(PREFIX.length());

            // 查找指定名称的类文件
            byte[] data = loadByte(name);
            // 将字节数组形式的类文件数据转换为一个Class对象
            return defineClass(name, data, 0, data.length);
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 检查该类是否已经被加载过 如果已加载则返回对应的Class对象
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                // 如果没有加载过 先让父类进行加载
                if (!name.startsWith(PREFIX)) {
                    c = super.loadClass(name, resolve);
                } else {
                    // 父类不加载 则自己加载
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
}

然后我们尝试开始加载这两个A类,本项目中的A.java自然不必多说,正常new一个就行,另外一个A.class我们需要使用反射去生成对象,并调用getVersion方法:

public class Main {
    public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException {
        MyClassLoader myClassLoader = new MyClassLoader("./");

        Class<?> clazz = myClassLoader.loadClass(MyClassLoader.getPrefix() + "com.knight.A");
        System.out.println(clazz);
        Method method = clazz.getMethod("getVersion");
        System.out.println(method.invoke(clazz.newInstance()));

        System.out.println(A.class);
        A a2 = new A();
        System.out.println(a2.getVersion());

    }
}

这是对应的输出:

class com.knight.A
1.0
class com.knight.A
2.0

天!同一个项目中,竟然让洪爵成功加载了两个类全限定名完全相同的A类!

这到底是怎么做到的?让洪爵来稍微解释一下,首先你得知道加载器其中3个比较常用的方法的含义:loadClassfindClassdefineClass

loadClass方法会检查该类是否已经被加载过,如果已加载则直接返回对应的Class对象。如果没有加载过,则调用父ClassLoader的loadClass方法,如果父ClassLoader也无法加载,则调用findClass方法来实际查找和加载类。findClass用于查找指定名称的类文件。defineClass用于将字节数组形式的类文件数据转换为一个Class对象。

如果自定义类加载器不想违背双亲委派模型,一般只需要重写findClass方法即可,如果想违背双亲委派模型,则还需要重写loadClass方法。虽然我们重写了loadClass方法,但是大体上还是按照双亲委派模型的方式,如果找不到会先去让父类加载,那么我在那里设置了MyClassLoader的父类呢?其实因为MyClassLoader是继承了ClassLoader,而ClassLoader的默认protected构造函数,会设置默认的父类为应用类加载器,源码如下图:

// ClassLoader.java
protected ClassLoader() {
	this(checkCreateClassLoader(), null, getSystemClassLoader());
}

洪爵在loadClass有一行比较与众不同的代码,我会判断这个包路径是否是PREFIX开头的,如果是则走自己的加载逻辑,然后在自己的findClass方法中,再把PREFIX去掉,露出了真正的包名,这个时候去做加载。

但是核心问题是,为什么jvm允许两个类全限定名相同的A类被加载进来?我们深扒源码,发现ClassLoader的源码里有一个map,这个map的key是对应的包路径,value是对应的package对象,所以自定义类加载器、应用类加载器等都自己维护了一个包路径到package对象的映射,等同于每个加载器都有自己的命名空间

// ClassLoader.java
// The packages defined in this class loader.  Each package name is
// mapped to its corresponding NamedPackage object.
//
// The value is a Package object if ClassLoader::definePackage,
// Class::getPackage, ClassLoader::getDefinePackage(s) or
// Package::getPackage(s) method is called to define it.
// Otherwise, the value is a NamedPackage object.
private final ConcurrentHashMap<String, NamedPackage> packages = new ConcurrentHashMap<>();

因此在同一个Java项目中可以出现类全限定名相同的类,类全限定名并不能唯一标识一个类。那么在一个Java项目中,怎么唯一标识一个类呢?
除了类全限定名外,还需要加上所使用的类加载器就可以唯一定位、标识一个类了,即类加载器 + 类全限定名

不知道你看完本篇文章,是否有收获?有需要讨论的地方也可以来找洪爵~

愿每个人都能带着怀疑的态度去阅读文章并探究其中原理。

道阻且长,往事作序,来日为章。

期待我们下一次相遇。

【b站搜Knight洪爵 微信可搜KNIGHT洪爵】