Java类加载机制

1,707 阅读9分钟

Java编译器把 “.java” 代码文件编译成 “.class” 字节码文件,然后类加载器又负责在需要的时候把字节码文件的类加载到JVM中,加载过程中经历了什么?类加载器又有哪几种。不同的类加载器又如何确保不会重复加载相同的类。下面将一一解答这些问题

字节码

在聊类加载机制之前,首先需要了解一下Java字节码,因为它和类加载的过程相关。

Java在诞生的时候的口号:“Write Once, Run AnyWhere", 为了这个目的,Sun公司发布了许多可以在不同平台上运行的JVM——负责载入和执行Java编译后的字节码。

先来看一下,字节码长什么样子:

cafe babe 0000 0034 014c 0a00 3e00 950a
0096 0097 0900 3d00 980b 0099 009a 0900
3d00 9b0a 009c 009d 0a00 9e00 9f0a 00a0
00a1 0b00 9900 a207 00a3 0a00 0a00 a409
003d 00a5 0a00 a600 a70a 00a8 00a9 0b00
9900 aa08 00ab 0b00 ac00 ad07 00ae 0a00
9c00 af08 00b0 0a00 b100 b20a 00b3 00b4
0a00 1200 b50a 0012 00b6 0a00 b100 b70a
00b8 00b9 0a00 3d00 ba0a 00b1 00a9 0a00
b100 bb09 003d 00bc 0a00 1200 bd0a 0012

这段字节码中的 cafe babe 被称为“魔数”,是 JVM 识别 .class 文件的标志。

JVM 就是负责把这样的文件载入,并且解释成计算机能听懂的语言。

img

JVM在什么情况下会加载一个类?

我们首先要搞明白一个问题,一般在什么情况下会去加载一个类呢?

其实,答案非常简单就是代码中用到这个类的时候。

那总得有个头呀,也就是那一个是一开始就加载的类?这就要说到启动类的

public static void main(String[] args){
    Manager manager = new Manager();
}

就是一切的起源,也是Java的规定吧。

简单概括一下:首先你的代码中包含“main()”方法的主类一定会在JVM进程启动之后被加载到内存,开始执行你的“main()”方法中的代码。

接着遇到你使用了别的类,比如“Manager”,此时就会从对应的“.class”字节码文件加载对应的类到内存里来。

类加载的过程

一个类从加载到使用,一般会经历下面的过程:

载入 ->  验证  ->  准备  ->  解析  ->  初始化 -> 使用  -> 卸载

1.载入(Loading)

JVM 在该阶段的主要目的是将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class 对象。

2.验证(Verification)

JVM 会在该阶段对二进制字节流进行校验,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障,下面是一些主要的检查。

  • 确保二进制字节流格式符合预期(比如说是否以 cafe bene 开头)。
  • 是否所有方法都遵守访问控制关键字的限定。
  • 方法调用的参数个数和类型是否正确。
  • 确保变量在使用之前被正确初始化了。
  • 检查变量是否被赋予恰当类型的值。

3.准备(Preparation)

JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。

举个例子:

public String chenmo = "沉默";
public static String wanger = "王二";
public static final String cmower = "沉默王二";

chenmo不会被分配内存,而 wanger 会;但 wanger 的初始值不是“王二”而是 null

需要注意的是,static final 修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 cmower 在准备阶段的值为“沉默王二”而不是 null

4.解析 (Resolution)

该阶段将常量池中的符号引用转化为直接引用。

首先需要解释一下符合引用,在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 com.Wanger 类引用了 com.Chenmo 类,编译时 Wanger 类并不知道 Chenmo 类的实际内存地址,因此只能使用符号 com.Chenmo直接引用通过对符号引用进行解析,找到引用的实际内存地址。

更加详细的解释可以看这篇文章

5.初始化(Initialization)

上面提到在准备阶段仅仅是给变量,开辟一个内存空间,然后给个初始值罢了,那么赋值阶段的执行就是在初始化阶段,另外静态代码块也会在这个初始化阶段被执行。

public class ClassLoaderDemo {
	public static int i = 5;
	static {
		test();
	}
	public static void test() {
		System.out.println("正在执行初始化")
	}
}

也就是说,上面的代码中,i被赋值为5,和静态代码块将会在这个阶段执行。

此外,这里还有一个非常重要的规则,就是如果初始化一个类的时候,发现他的父类还没初始化,那么必须先初始化他的父类。

举例

给定下面代码:求counter1和counter2的值

public class Classloader {
	private static Classloader classloader = new Classloader(); // 1
	public static int counter1; // 2
	public static int counter2 = 0; // 3
	public Classloader() {
		counter1++; 
		counter2++;
	}
	public static Classloader getClassloader() {
		return classloader;
	}
}

// 运行主类
public class TestClassloader {
	public static void main(String[] args) {
		Classloader classloader = Classloader.getClassloader();
		System.out.println(classloader.counter1);
		System.out.println(classloader.counter2);
	}
}

答案是

counter1 == 1
counter2 == 0

下面来一步步分析呀,在准备阶段的时候,给静态变量赋予默认的初始值,也就是

classloader = null;

counter1 = 0;

counter2 = 0;

接下来的初始化阶段,初始化静态变量是从上往下依次执行,所以最先开始执行new CLassloader();然后执行构造方法,所以接下来的 counter1 = 1, counter2 = 1;

继续执行初始化,counter1没有在初始化阶段进行赋值操作,跳过,counter2 被赋值 0,所以最终的结果是 counter1 = 1, counter2 = 0;

你也可以试试,将语句1放到语句3后面,输出结果是counter1 = 1, counter2 = 1;

类加载器

Java的类加载器有下面这几种:

  • 启动类加载器(Bootstrap ClassLoader)

主要负责加载Java目录下的核心类,具体是负责加载<$JAVA_HOME>/ jre/lib/rt.jar 里所有的class或Xbootclassoath选项指定的jar包。由**C++**实现,不是ClassLoader子类。

  • 扩展类加载器(Extension ClassLoader)

负责加载java平台中扩展功能的一些jar包,包括<$JAVA_HOME>/jre/lib/ext/*.jar 或 -Djava.ext.dirs指定目录下的jar包。

  • 应用程序类加载器 (App ClassLoader)

负责加载classpath中指定的jar包及 Djava.class.path 所指定目录下的类和jar包。它是java应用程序默认的类加载器。

  • 用户自定义类加载器(Custom ClassLoader)

通过java.lang.ClassLoader的子类自定义加载class,属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。


在虚拟机启动的时候会初始化BootstrapClassLoader,然后在Launcher类中去加载ExtClassLoader、AppClassLoader,并将AppClassLoader的parent设置为ExtClassLoader,并设置线程上下文类加载器。

Launcher是JRE中用于启动程序入口main()的类,让我们看下Launcher的代码

public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            //加载扩展类类加载器
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            //加载应用程序类加载器,并设置parent为extClassLoader
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }
        //设置默认的线程上下文类加载器为AppClassLoader
        Thread.currentThread().setContextClassLoader(this.loader);
        //此处删除无关代码。。。
        }

看上面的源代码,不知道你发现没有,ExtClassLoader没有设置parent, 主要原因是因为BootstrapClassLoader是由C++实现的,所以并不存在一个Java的类,所以如果你尝试执行

 public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader classLoader = Test.class.getClassLoader();
        System.out.println(classLoader);
        System.out.println(classLoader.getParent());
        System.out.println(classLoader.getParent().getParent());
    }

会输出这样的结果

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@5a61f5df
null

双亲委派机制

img

一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。

目的

首先,虚拟机只有在两个类的类名相同且加载该类的加载器均相同的情况下才判定这是一个类。为了解决开发者自定义的类名与官方类的可能的加载冲突问题。因此使用了具体优先级的层次加载关系。

例如类java.lang.Object,它存在在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个java.lang.Object的同名类并放在Class-path中,那系统中将会出现多个不同的Object类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar类库中重名的Java类,可以正常编译,但是永远无法被加载运行。


自定义类加载器

自己实现ClassLoader时只需要继承ClassLoader类,然后覆盖findClass(String name)方法即可完成一个带有双亲委派模型的类加载器。

ClassLoader

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

伪代码:

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

这时候,可能有人会有疑问?为什么不继承ExtClassLoader或者AppClassLoader呢?

因为AppClassLoader和ExtClassLoader都是Launcher的静态内部类,都是包访问路径权限的。


下面是实现了自定义ClassLoader的代码

public class MyClassLoader extends ClassLoader {
    /**
     * 重写父类方法,返回一个Class对象
     * ClassLoader中对于这个方法的注释是:
     * This method should be overridden by class loader implementations
     */
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        String classFilename = name + ".class";
        File classFile = new File(classFilename);
        if (classFile.exists()) {
            try (FileChannel fileChannel = new FileInputStream(classFile)
                    .getChannel();) {
                MappedByteBuffer mappedByteBuffer = fileChannel
                        .map(MapMode.READ_ONLY, 0, fileChannel.size());
                byte[] b = mappedByteBuffer.array();
                clazz = defineClass(name, b, 0, b.length);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (clazz == null) {
            throw new ClassNotFoundException(name);
        }
        return clazz;
    }

    public static void main(String[] args) throws Exception{
        MyClassLoader myClassLoader = new MyClassLoader();
        Class clazz = myClassLoader.loadClass(args[0]);
        Method sayHello = clazz.getMethod("sayHello");
        sayHello.invoke(null, null);
    }
}

引用

本文章参考了知乎用户请叫我程序猿大人的好怕怕的类加载器文章