理解 Java 中的类加载器

一、什么是类加载器

1. 类加载器

JVM 虚拟机团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己去决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

2. 命名空间

每个类加载器都有自己的命名空间,命名空间由该加载器及所有的父加载器所加载的类组成。

在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类。而在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类。

子加载器所加载的类能够访问到父加载器所加载的类;而父加载器无法访问到子加载器所加载的类。

二、类加载器

1. 启动类加载器

启动类加载器主要会去加载 JVM 自身需要的类,这个类由 C++ 代码实现,是虚拟机本身的一部分,它负责将/lib路径下的核心库或者-Xbootclasspath参数指定的路径下的 jar 包加载到内存中。注意,由于虚拟机是按照文件名识别加载 jar 包的,如 rt.jar,所以如果文件名不被虚拟机识别,即使把 jar 包丢到 lib 目录下也是没有作用的(出于安全考虑,启动类加载器只加载包名为java、javax、sun等开头的包)。

2. 扩展类加载器

扩展类加载器是指 Sun 公司(已被 Oracle 收购)实现的sun.misc.Launcher$ExtClassLoader类,由 Java 语言实现的,是 Launcher 的静态内部类,它负责加载/lib/ext目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。

// ExtClassLoader类中获取路径的代码
private static File[] getExtDirs() {
    //加载/lib/ext目录中的类库
    String s = System.getProperty("java.ext.dirs");
    File[] dirs;
    if (s != null) {
        StringTokenizer st =
            new StringTokenizer(s, File.pathSeparator);
        int count = st.countTokens();
        dirs = new File[count];
        for (int i = 0; i < count; i++) {
            dirs[i] = new File(st.nextToken());
        }
    } else {
        dirs = new File[0];
    }
    return dirs;
}

3. 应用类加载器

也会被称为系统类加载器,是指由 Sun 公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath-Djava.class.path指定路径下的类库,也就是我们常用到的 classpath 路径,开发者可以直接使用系统类加载器,一般情况下该类加载器是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。

public class Demo {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("java.lang.String");
        System.out.println(clazz.getClassLoader());

        Class<?> clazz2 = Class.forName("com.jojo.jvm.classloader.C");
        System.out.println(clazz2.getClassLoader());
    }
}

class C {}

以上代码输出的分别是null,以及 sun.misc.Launcher$AppClassLoader@1d16e93,其中null指代了系根类加载器,说明了 String 类是被根类加载器加载进来的。而后者是一个匿名内部类,从名字可以看出是系统类加载器,所以在这里我们自定义的类是由系统类加载器加载进来的。

在Java的日常应用程序开发中,类的加载几乎是由上述 3 种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,下面我们进一步了解它。

三、双亲委托机制

双亲委派模型要求除了最顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器(注意:这里的父类加载器并不是通常所说的继承关系,而是采用组合关系来复用父类加载器的相关代码),如下所示:

理解 Java 中的类加载器_第1张图片

双亲委派模式是在 Java 1.2 后引入的,其工作原理是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载器任务,就成功加载,否则就返回让子类加载器自己去尝试加载,这就是双亲委派模型。

1. 双亲委派的实现

protected synchronized Class<?> loadClass(String name, boolean resolve) throws 
    ClassNotFoundException {
    // 首先,检查请求的类是否已经被加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出 ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的 findClass 方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    
    return c;
}

这段代码逻辑:首先检查要加载的类是否已经被加载过,若没有则调用父类加载器的loadClass()方法,若父类加载器为空则默认使用启动类加载器作为父类加载器。加入父类加载器

2. 双亲委派机制的优点

JVM通过这种双亲委派模型来组织类加载器之间的关系,使得 Java 中的类随着它的类加载器一起具备了一种有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型顶端的启动类加载器来完成,因此能确保 Object 类在程序的各种类加载器环境中都能够保证是同一个类。

总结:一方面可以避免类的重复加载,另外也避免了 Java 和核心 API 被篡改。

3. 类与类加载器之间的关系

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一类加载器的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示不同的类加载器对instanceof关键字运算的结果的影响。

/*
  类加载器与 instanceof 关键字演示
*/
public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] b = new byte[is.available()];
                    is.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Object obj = myLoader.loadClass("com.jojo.jvm.classloader.MyTest01").newInstance();

        System.out.println(obj.getClass());
        System.out.println(obj instanceof com.jojo.jvm.classloader.MyTest01);
    }
}

运行结果:

class com.jojo.jvm.classloader.MyTest01
false

上面代码中构造了一个简单的类加载器,它可以加载与自己在同一路径下的Class文件。我们使用这个类加载器去加载一个名为com.jojo.jvm.classloader.MyTest01的类,并实例化了这个类的对象。两行输出结果中,从第一句看出,这个对象确实是com.jojo.jvm.classloader.MyTest01实例化出来的对象,但从第二句可以发现,这个对象与类com.jojo.jvm.classloader.MyTest01做所属类型检查的时候却返回了false,这时因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个Class文件,但依然是两个独立的类,做对象所属类型检查时结果自然为false。(所示代码通过覆盖 loadClass 方法从而破坏了父亲委托机制,这也是一个前提。)

4. 破坏双亲委派模型

双亲委派模型本身有一定的缺陷。双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类由越上层的加载器进行加载),基础类之所以被称为“基础”,是因为它们总是作为被用户代码调用的API,但世事无绝对,如果基础类又要调用回用户的代码,那该怎么办?

这并非是不可能存在的情况,常见的例子就是JDBC,其主要的接口类等是由启动类加载器去加载(文件存放与rt.jar)。而对于其组件的具体实现,则是由MySQL或者Oracle等数据库厂商去实现的。这时它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的代码,但是启动类加载器不可能“认识”这些代码。

为了解决这个问题,Java设计团队引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用类加载器。

除去上面所提及的线程上下文类加载器外,双亲委派模型第一次“被破坏”发生在双亲委派模型出现之前(即 JDK 1.2面世之前)。那时的用户自定义类加载器主要靠重写loadClass()方法来完成,这样对双亲委派模型是一种破坏,1.2 之后为了兼容前面的代码,只能在java.lang.ClassLoader中再添加一个findClass()方法,并引导用户编写时尽可能去重写这个方法。

此外,为了实现代码热部署,对双亲委派模型也产生了破坏,这个不展开讨论。

四、自定义类加载器

虽然 JVM 中为我们提供了成熟的类加载器以及类加载器模型,但是我们在一些场景下也是需要去自定义类加载器的,比如以下几种情况:

  • 当 class 文件不在 ClassPath 路径下,默认系统类加载器无法找到该 class 文件,在这种情况下我们需要实现一个自定义的 ClassLoader 并编写加载逻辑,来加载特定路径下的 class 文件从而生成 class 对象;
  • 当一个 class 文件通过网络传输并且可能进行相应的加密操作时,需要先对 class 文件进行相应的解密后再加载到 JVM 内存中,此时也应该自定义 ClassLoader 并实现相应的逻辑;
  • 当需要实现热部署功能时(一个class文件通过不同的类加载器产生不同class对象从而实现热部署功能),需要实现自定义ClassLoader的逻辑。

下面简单介绍一下如何针对第一种情况自定义一个类加载器,实现自定义类加载器可以去继承 ClassLoader 或 URLClassLoader,继承 ClassLoader 需要自己重写 findClass() 方法并编写加载逻辑,而继承 URLClassLoader 则可以省去编写findClass()方法以及 class 文件加载后转换成字节码流的代码。以下简单举例代码:

public class Test {
    @Override
    public String toString() {
        return "load success!!!";
    }
}

将上面 java 文件编译后生成的 class 文件放置在其他位置,以便等等验证加载过程,同时记得删除 classPath 路径下已经生成的 class 文件,避免先加载了 classPaht 下的文件。接下来是类加载器的代码:

public class FileClassLoader extends ClassLoader {
    
    private String rootDir;
    
    public FileClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }
    
    /**
     * 编写 findClass 方法的逻辑
     */
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 获取类的 class 文件字节数组
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 直接生成 class 对象
            return defineClass(name, classData, 0, classData.length);
        }
    }
    
    /**
     * 编写获取class文件并转换为字节码流的逻辑
     */
    private byte[] getClassData(String className) {
        // 读取类文件的字节
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            // 读取类文件的字节码
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
    
    /**
     * 生成类文件的完全路径
     */
    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
    
    public static void main(String[] args) throws ClassNotFoundException {
        String rootDir="your/file/dir";
        // 创建自定义文件类加载器
        FileClassLoader loader = new FileClassLoader(rootDir);
        try {
            //加载指定的class文件
            Class<?> object1=loader.loadClass("com.jojo.classloader.Test");
            System.out.println(object1.newInstance().toString());
            //输出结果:I am DemoObj
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
}

这里说明一下,上面代码定义的findClass方法最终是会被loadClass方法去调用的,所以如果想要打破双亲委派机制,还得去重写loadClass方法。

参考:

  • 《深入理解Java虚拟机》

  • 深入理解Java类加载器(ClassLoader)

你可能感兴趣的:(java,jvm)