Java 虚拟机 (四) - 类加载器

这是我们 java 虚拟机系列的第四篇文章, 类加载器

1.类加载器

jvm_1.png

Java 虚拟机的主要任务是装载 class 文件并且执行其中的字节码。类加载器的作用是加载程序或 Java API 的 class 文件,并将字节码加载到执行引擎。

在加载 class 文件时, 为了防止加载进来恶意的代码,需要在类的加载器体系中去实现一些规则,保证在 Java 沙箱的安全模型。

类加载其在 Java 沙箱中主要是三方面

  • 守护了被信任的类库的边界 - 通过双亲委托机制实现
  • 防止恶意代码去干涉善意代码 - 通过不同的命名空间去实现
  • 将代码归入某类(称为保护域,该类确定了代码可以进行哪些操作。

这三方面我们后续会一个一个说

首先我们先看看在 Java 虚拟机中的整个类加载器体系

2. 双亲委托机制

类加载器体系守护了被信任的类库的边界,这是通过分别使用不同的类加载器加载可靠包和不可靠包来实现的。

这些不同的类加载器之间的依赖关系,构成了 Java 虚拟机中的双亲委托机制。所谓的双亲委托机制,是指类加载器请求另一个类加载器来加载类的过程。

jvm_2.png

上图是类加载器双亲委托模型,我们可以看到,除了启动类加载器以外的每一个类加载器,都有一个 ”双亲“ 类加载器,在某个特定的类加载器试图以常用的方式加载类以前,它会默认将这个任务 ”委托“ 给它的双亲 -- 请求它的双亲来加载这个类。这个双亲再依次请求它自己的双亲来加载这个类。这个委托的过程一直向上继续,直到达到启动类加载器。如果一个类加载器的双亲类加器有能力来加载这个类,则这个类加载器返回这个类。否则,这个类加载器试图自己来加载这个类。

它们有着不同的启动路径

类加载器 路径
Bootstrap ClassLoader 启动类加载器 Load JRE\lib\rt.jar 或者 -Xbootclasspath 选项指定的 Jar 包
Extension ClassLoader 扩展类加载器 Load JRE\lib\ext*.jar 或 -Djava.ext.dirs 指定目录下的 Jar 包
Application ClassLoader 应用程序类加载器 Load CLASSPATH 或 -Djava.class.path 所指定的目录下的类和 Jar 包
User ClassLoader 自定义类加载器 通过 Java.lang.ClassLoader 的子类自定义加载 class

ClassLoader 的 loadClass 方法和 findClass 方法,如果是我们自定义 ClassLoader 的话,只需要重写 findClass 方法即可

下面我们用一个例子来说明来加载器的双亲委托机制。我们自定义一个 ClassLoader 并复写它的 findClass() 方法

  @Override
    protected Class findClass(String className) throws ClassNotFoundException {
        System.out.println("findClass className: " + className);
        byte[] classData;

        classData = getTypeFromBasePath(className);
        if (classData == null){
            throw new ClassNotFoundException();
        }

        // Parse it
        return defineClass(className, classData, 0, classData.length);
    }


    private byte[] getTypeFromBasePath(String typeName){
        FileInputStream fis;
        String fileName = path  + typeName.replace('.', File.separatorChar) + ".class";
        System.out.println("getTypeFromBasePath fileName :" + fileName);

        try {
            fis = new FileInputStream(fileName);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
            return  null;
        }

        BufferedInputStream bis = new BufferedInputStream(fis);
        ByteArrayOutputStream out = new ByteArrayOutputStream();

        try {
            int c = bis.read();
            while ( c != -1){
                out.write(c);
                c = bis.read();
            }

        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }

        return out.toByteArray();
    }
  
    

然后在我们通过 IDEA 编译,将编译出来的 Class 文件版本放到桌面,并且指定路径进行加载。

jvm_3.png

然后在 main 方法中运行,将路径设置为 我们在上面的桌面 视图加载 Test1 类

public static void main(String[] args) throws Exception {
        //  loadClass
        MyClassLoader loader1 = new MyClassLoader("loader1");

        loader1.setPath("/Users/yxhuang/Desktop/");

        Class clazz = loader1.loadClass("com.yxhuang.jvm.bytecode.Test1");
        System.out.println("class name: " + clazz.getSimpleName() + " \nclass hashcode: " + clazz.hashCode() + " \nloader: " + clazz.getClassLoader().getClass().getSimpleName());
        Object object1 = clazz.newInstance();
        System.out.println(object1);

}

上面的例子,我们将 MyClassLoader 命名为 loader1, 设置路径为我们的电脑桌面。这时候运行,看看输出

class name: Test1 
class hashcode: 1265094477 
loader: AppClassLoader
com.yxhuang.jvm.bytecode.Test1@7ea987ac

上面输出,我们看看 Test1 类文件已经加载进了 类加载器,但是打印出来,我们看到 ClassLoader 是 AppClassLoader 而不是我们自定义的 MyClassLoader。 为什么会这样呢,这就涉及到类的双亲委托机制了。

当我们用 loader1 视图去加载 com.yxhuang.jvm.bytecode.Test1 这个类的时候,根据双亲委托机制,自定义的类加载器 MyClassLoader 会委托它的父加载器 AppClassLoader 去加载, AppClassLoader 应用类加载器又会委托它的父类加载器 Bootstrap ClassLoader 启动类去加载。而 Bootstrap ClassLoader 找不到这个类,然后让 AppClassLoader 去加载,还记得上面提到 AppClassLoader 加载的路径是项目的 ClassPath, 这时候找到了 Test1 类并加载了它,并没有让 MyClassLoader 去加载

jvm_4.png

现在,我们把 out/production/class 路径里面的 Test1 删掉,再次运行查看结果
这时候的输出是

findClass className: com.yxhuang.jvm.bytecode.Test1
getTypeFromBasePath fileName :/Users/yxhuang/Desktop/com/yxhuang/jvm/bytecode/Test1.class
class name: Test1 
class hashcode: 1554874502 
loader: MyClassLoader
com.yxhuang.jvm.bytecode.Test1@6e0be858

看到上面的输出,我们看到我们自定义的 MyClassLoader 被调用了,加载 Test1 的路径是 /Users/yxhuang/Desktop/com/ , 类加载器也是我们自定义的 MyClassLoader

jvm_5.png

MyClassLoader 会委托给它的父类,最后到 启动类加载器,然后 MyClassLoader 之上的类加载器没有一个能加载到,最后只能是 MyClassLoader 来加载。

双亲委托机制可以保证父类先加载 Class 文件,特别是 jdk 里面的类,保证 jdk 类的类优先被启动类加载器加载,防止恶意代码伪装成 jdk 的类去破坏 jvm 的运行。

双亲委托机制还有下面的一些特点:

  • 1.如果没有显示地传递一个双亲类装载器给用户自定义的类装载器的构造方法,系统装载器就默认被指定为双亲。
  • 2. 如果传递到构造方法的是一个已有的用户自定义类型装载器的引用,该用户自定义装载器就被作为双亲。
  • 3.如果传递的方法是一个 null, 启动类装载器就是双亲。
  • 4.在类装载器之间具有了委派关系,首先发起装载要求的类装载器不必是定义该类的类装载器。

当时双亲委托机制,也有它不足的地方,在不需要双亲委托机制的地方,需要上下文类加载器。关于上下文类加载器,后面我们会讲到,这里先跳过。

下面我们先看看命名空间

3. 类加器的命名空间

下面我们通过实例代码,说明命名空间

先定义一个 Person 类

public class Person {
    private Person person;

    public Person() {
    }

    public void setPerson(Object object){
        System.out.println("setPerson " + object.getClass().getSimpleName());
        this.person = (Person) object;
    }
}

用 IDEA 编译成 class 文件,将编译出来的 Class 文件版本放到桌面,并且指定路径进行加载。然后将 Persion 的 class 文件 删除,同时注释 Person。

下面是命名空间的测试类, 设置加载路径,用两个不同的类加载器去加载 com.yxhuang.jvm.classloader.Person 类,然后通过反射,调用 class1 的 setPerson 方法。

public class NameSpaceLoaderTest {

    public static void main(String[] arg) throws Exception {
        MyClassLoader classLoader1 = new MyClassLoader("classloader1");
        MyClassLoader classLoader2 = new MyClassLoader("classloader2");

        classLoader1.setPath("/Users/yxhuang/Desktop/");
        classLoader2.setPath("/Users/yxhuang/Desktop/");

        Class class1 = classLoader1.loadClass("com.yxhuang.jvm.classloader.Person");
        Class class2 = classLoader2.loadClass("com.yxhuang.jvm.classloader.Person");

        System.out.println("class1 : " + class1.getSimpleName() + " " +  class1.getClassLoader().toString());

        System.out.println("class2 : " + class2.getSimpleName() + " " +  class2.getClassLoader().toString());

        System.out.println(class1 == class2);

        Object object1 = class1.newInstance();
        Object object2 = class2.newInstance();

        Method method = class1.getMethod("setPerson", Object.class);
        method.invoke(object1, object2);

    }
}

然后,我们看看输出

findClass className: com.yxhuang.jvm.classloader.Person
getTypeFromBasePath fileName :/Users/yxhuang/Desktop/com/yxhuang/jvm/classloader/Person.class

findClass className: com.yxhuang.jvm.classloader.Person
getTypeFromBasePath fileName :/Users/yxhuang/Desktop/com/yxhuang/jvm/classloader/Person.class

class1 : Person com.yxhuang.jvm.classloader.MyClassLoader@42a57993

class2 : Person com.yxhuang.jvm.classloader.MyClassLoader@6bc7c054

false

// 还会抛出异常
Caused by: java.lang.ClassCastException: com.yxhuang.jvm.classloader.Person cannot be cast to com.yxhuang.jvm.classloader.Person

根据上面的打印,我们可以知道, class1 和 class2 都是 Person 类,但是 class1 == class2 是 false 的,说明他们不是同一个类。

将 class1 和 class2 通过 newInstance() 方法生成对应的 object1 和 object2 对象,这也都是 Person 类的对象。
在调用反射将 object1 的 setPerson 方法会抛出异常

public void setPerson(Object object){
        System.out.println("setPerson " + object.getClass().getSimpleName());
        this.person = (Person) object;
}

抛出的异常是说 Person 对象不能强转成 Person 对象。这个异常就很奇怪了,那为什么会出现这个异常,那就要说到 java 虚拟机里面的命名空间了。
因为这两个对象加载的虚拟机不一样,导致命名空间不一样导致的。

命名空间是表示当前类的加载器的命名空间,是由当前类转加载器是自己的初始类加载器的类型名称组成的。

命名空间的作用是通过不同的命名空间,防止恶意代码去干涉其他代码。在 Java 虚拟机中,在同一个命名空间内的类可以之间进行交互,而不同的命名空间中的类察觉不到彼此的存在。

每个类装载器都有自己的命名空间,其中维护者由它装载的类型。所以一个 Java 程序可以多次装载具有一个全限定名的多个类型。这样一个类的全限定名就不足以确定在一个 Java 虚拟机中的唯一性。因此,当多个类装载器都装载了同名的类型时,为了唯一地标识该类型,还要在类型名称前加上装载器该类(指出了它所位于的命名空间)的类装载器的标识。

上面 Person 的这个例子就说明,一个类的全限定名 com.yxhuang.jvm.classloader.Person 不能确定它的唯一性,我们可以用另外一个类加载器去再次加载这个类。

综上所述,如果想要确定一个类是否是唯一的或者说判断两个类是否相等,就需要他们的类加载器为同一个累加器,并且命名空间是一致的。

关于命名空间的一些论述

    1. 每个类装载器都有自己的命名空间,命名空间由该装载器及其父装载器所装载的类组成;
    1. 在同一个命名空间中,不会出现类的完整姓名(包括类的包名)相同的两个类;
    1. 在不同的命名空间中,有可能会出现类的完整名字(包含类的包名)相同的两个类。

类装载器和这个类本身一起共同确立在 Java 虚拟机中的唯一性,每一个类装载器,都有一个独立的命名空间。
也就是说,比较两个类是否”相等“,只有这两个类是由同一个类装载器的前提下,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类装载器不同,那这两个类就必定不相等。

不同的加载器实例加载的类被认为是不同的类

在 JVM 的实现中有一条隐含的规则,默认情况下,如果一个类由类加载器 A 加载,那么这个类的依赖类也是由相同的类加载器加载

上面的几条论述在例子中也有体现。

4 自定义类加载器

4.1 自定义类加载器

如果想要自定义类加载器,只需要继承 ClassLoader 并且重写它的 findClass() 方法。

在 findClass() 方法里面根据路径去加载相应的 Class 文件流,然后将数据传递给 ClassLoader 自带的 defineClass() 方法,defineClass() 会将Class 流文件转成 Class 类的实例。

@Override
protected Class findClass(String className) throws ClassNotFoundException {
    System.out.println("findClass className: " + className);
    byte[] classData;
    
    // 指定路径加载 Class 流文件
    classData = getTypeFromBasePath(className);
    if (classData == null){
        throw new ClassNotFoundException();
    }

    // Parse it 将流文件转成一个 Class 类实例
    return defineClass(className, classData, 0, classData.length);
}

除此之外,必须要了解 ClassLoader 里面的 loadClass() 方法

4.2 loadClass() 方法

在我们自定义了 ClassLoader 之后,会调用 loadClass() 方法去加载想要加载的类。

  • loadClass() 的基本工作方式:
    给定需要查找的类型的全限定名, loadClass()方法会用某种方式找到或生成字节数组到,里面的数据采用 Java Class 文件格式(用该格式定义类型)。如果 loadClass() 无法找到或生成这些字节,就会抛出 ClassNotFoundException 异常。否则,loadClass() 会传递这个自己数组到 ClassLoader 声明的某一个 defineClass() 方法。通过把这些字节数组传递给
    defineClass(),loadClass() 会要求虚拟机把传入的字节数组导入这个用户自定义的类装载器的命名中间中去。

  • loadClass 的步骤:

    • 1.查看是否请求的类型已经被这个类装载器装载进命名空间(提供 findLoadedClass())方法的工作方式
    • 2.否则,委派到这个类装载器的双亲装载器。如果双亲返回了一个 Class 实例,就把这个 Class 实例返回。
      1. 否则,调用 findClass(), findClass() 会试图寻找或者生成一个字节数组,内容采用 Java Class 文件格式(它定义了所需要的类型)。如果成功,findClass() 把这个字节传递给 defineClass() ,后者试图导入这个类型,返回一个 Class 实例。 如果 findClass() 返回一个 Class 实例,loadClass() 就会把这个实例返回。
      1. 否则, findClass() 抛出某些异常来中止处理,而且 loadClass() 也会抛出异常中止。
 
public abstract class ClassLoader {

    //每个类加载器都有个父加载器
    private final ClassLoader parent;
    
    public Class loadClass(String name) {
  
        //查找一下这个类是不是已经加载过了
        Class c = findLoadedClass(name);
        
        //如果没有加载过
        if( c == null ){
          //先委托给父加载器去加载,注意这是个递归调用
          if (parent != null) {
              c = parent.loadClass(name);
          }else {
              // 如果父加载器为空,查找Bootstrap加载器是不是加载过了
              c = findBootstrapClassOrNull(name);
          }
        }
        // 如果父加载器没加载成功,调用自己的findClass去加载
        if (c == null) {
            c = findClass(name);
        }
        
        return c;
    }
    
    protected Class findClass(String name){
       //1. 根据传入的类名name,到在特定目录下去寻找类文件,把.class文件读入内存
          ...
          
       //2. 调用defineClass将字节数组转成Class对象
       return defineClass(buf, off, len);
    }
    
    // 将字节码数组解析成一个Class对象,用native方法实现
    protected final Class defineClass(byte[] b, int off, int len){
       ...
    }
}

5 线程上下文类加载器

双亲委托机制不适用的场景下,需要使用到 上下文类加载器(Thread Context ClassLoader)

场景是有基础类要调用用户代码(Service Provider Interface, SPI)

线程上下文加载器通过 Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程还未设置,就会从父线程中继承一个,如果在应用程序的全局范围都没有设置过的话,那这个类装载器默认是应用类加载器。

6.获取 ClassLoader 的途径

获取当前类的 ClassLoader: clazz.getClassLoader()

获取当前线程上下文的 ClassLoader: Thread.currentThread().getContextClassLoader()

获取系统的 ClassLoader : ClassLoader.getSystemClassLoader()

获取调用者的 ClassLoader: DriverManager.getCallerClassLoader()

7.参考

  • 《深入 Java 虚拟机》
  • 《深入理解 Java 虚拟机》
  • 圣思园张龙

你可能感兴趣的:(Java 虚拟机 (四) - 类加载器)