1. 类加载的过程

JVM 类加载机制 - 图1
JVM 类加载机制 - 图2

1.1 加载(Loading)

这一步是读取到类文件产生的二进制流(findClass()),并将字节流所代表的静态存储结构转换为方法区的运行时数据结构(defineClass())。初步校验cafe babe魔法数(二进制中前四个字节为0xCAFEBABE用来标识该文件是 Java 文件,这是很多软件的做法,比如zip压缩文件)、常量池、文件长度、是否有父类等,然后在 Java堆中创建对应类的java.lang.Class实例,类中存储的各部分信息也需要对应放入运行时数据区中(例如静态变量、类信息等放入方法区)。

这里我们可能会有一个疑问,为什么 JVM 允许还没有进行验证、准备和解析的类信息放入方法区呢? 答案是加载阶段和链接阶段的部分动作(比如一部分字节码文件格式验证动作)是交叉进行的,也就是说加载阶段还没完成,链接阶段可能已经开始了。但这些夹杂在加载阶段的动作(验证文件格式等)仍然属于链接操作。

1.2 链接(Linking)

1.2.1 验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

  • 文件格式验证:验证字节流是否符合 Class 文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。
  • 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。
  • 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  • 符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

1.2.2 准备:为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下两点需要注意:

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如00Lnullfalse等),而不是被在 Java 代码中被显式地赋予的值。

注:如果类字段的字段属性表中存在 ConstantValue 属性,即同时被和修饰,那么在准备阶段变量value就会被初始化为 ConstValue 属性所指定的值。 例如,假设这里有一个类变量public static int value = 666;,在准备阶段时初始值是0而不是666,在初始化阶段才会被真正赋值为666。假设是一个静态类变量public static final int value = 666;,则在准备阶段 JVM 就已经赋值为666了。

1.2.3 解析:把类中的符号引用转换为直接引用(重要)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

-『符号引用』的作用是在编译的过程中,JVM 并不知道引用的具体地址,所以用符号引用进行代替,而在解析阶段将会将这个符号引用转换为真正的内存地址。
-『直接引用』可以理解为指向类、变量、方法的指针,指向实例的指针和一个间接定位到对象的对象句柄。

1.3 初始化(Initialzation)

初始化,为类的静态变量赋予正确的初始值,JVM 负责对类进行初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值;
  2. 使用静态代码块为类变量指定初始值;

JVM 初始化步骤:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类。
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句。

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化。

2. 在 JVM 中如何判断两个 Class 对象是否为同一个类?

两个 Class 对象属于同一个类需要满足以下三个条件:

  1. 同一个 .class 文件;
  2. 被同一个虚拟机加载;
  3. 被同一个类加载器加载;

比较两个类是否 相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

3. ClassLoader 类结构分析

ClassLoader 的主要责任就是加载类。ClassLoader 通过一个类的名字定位并且把这个 class 文件加载进 JVM 的内存里,生成可以表示这个类的结构。ClassLoader 是JDK 为我们提供的一个基础的类加载器,它本身是一个抽象类。

以下就是 ClassLoader 的主要方法了:
JVM 类加载机制 - 图3

defineClass()

defineClass()用于将byte字节流解析成 JVM 能够识别的 Class 对象。有了这个方法意味着我们不仅可以通过.class文件实例化对象,还可以通过其他方式实例化对象,例如通过网络接收到一个类的字节码。

注意,如果直接调用这个方法生成类的 Class 对象,这个类的 Class 对象还没有resolve,JVM 会在这个对象真正实例化时才调用resolveClass()进行链接。

findClass()

findClass()通常和defineClass()一起使用,我们需要直接覆盖 ClassLoader 父类的findClass()方法来实现类的加载规则,从而取得要加载类的字节码。

  1. protected Class findClass(String name) throws ClassNotFoundException {
  2. throw new ClassNotFoundException(name);
  3. }

如果你不想重新定义加载类的规则,也没有复杂的处理逻辑,只想在运行时能够加载自己制定的一个类,那么你可以用this.getClass().getClassLoader().loadClass("class")调用 ClassLoader 的loadClass()方法来获取这个类的 Class 对象,这个loadClass()还有重载方法,你同样可以决定在什么时候解析这个类。

loadClass()

loadClass()用于接受一个全类名,然后返回一个 Class 类型的对象。(该方法的源码蕴含了著名的双亲委派模型)

resolveClass()

resolveClass()用于对 Class 进行链接,也就是把单一的 Class 加入到有继承关系的类树中。如果你想在类被加载到 JVM 中时就被链接(Link),那么可以在调用defineClass()之后紧接着调用一个resolveClass()方法,当然你也可以选择让 JVM 来解决什么时候才链接这个类(通常是真正被实例化的时候)。

ClassLoader 是个抽象类,它还有很多子类,如果我们要实现自己的 ClassLoader,一般都会继承URLClassLoader这个子类,因为这个类已经帮我们实现了大部分工作。

例如,我们来看一下java.net.URLClassLoader.findClass()方法的实现:

// 入参为 Class 的 binary name,如 java.lang.String  
protected Class findClass(final String name) throws ClassNotFoundException {  
    // 以上代码省略  
    // 通过 binary name 生成包路径,如 java.lang.String -> java/lang/String.class  
    String path = name.replace('.', '/').concat(".class");  
    // 根据包路径,找到该 Class 的文件资源  
    Resource res = ucp.getResource(path, false);  
    if (res != null) {  
        try {  
            // 调用 defineClass 生成 java.lang.Class 对象  
            return defineClass(name, res);  
        } catch (IOException e) {  
            throw new ClassNotFoundException(name, e);  
        }  
    } else {  
        return null;  
    }  
    // 以下代码省略  
}

4. 深入理解双亲委派模型

4.1 JVM 的三个类加载器

一个 Java 类是依靠类加载器被加载进 JVM 的,在 JVM 中有自带的三个类加载器:

  • Bootstrap ClassLoader(启动类加载器):最顶层的加载类,主要加载 核心类库%JRE_HOME%\lib下的rt.jarresources.jarcharsets.jarclass等。
  • Extention ClassLoader(扩展类加载器):用于加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。
  • Appclass Loader(应用程序加载器):加载当前应用的classpath的所有类。

    4.2 双亲委派模型的工作过程

  1. 当前 ClassLoader 首先从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。(每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。)
  2. 当前 ClassLoader 的缓存中没有找到被加载的类的时候,委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到 Bootstrap ClassLoader。(当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。)

JVM 类加载机制 - 图4
JVM 类加载机制 - 图5

4.3 双亲委派模型带来了什么好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题。比如我们编写一个称为java.lang.Object类的话,那么程序运行的时候,系统就会出现多个不同的Object类。

4.4 loadClass() 方法解析

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 首先检查该类是否已经被加载过了
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                // 父加载器不为空则调用父加载器的 loadClass() 方法
                    c = parent.loadClass(name, false);
                } else {
                // 父加载器为空则调用 Bootstrap ClassLoader
                    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.
                long t1 = System.nanoTime();
                // 父加载器都没有在缓存中找到该类,则调用 findClass() 方法
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            // 调用 resolveClass() 方法对类进行链接
            resolveClass(c);
        }
        return c;
    }
}

类加载的入口是从 loadClass() 方法开始的,而类加载器的双亲委派模型也是在这里实现的。在加载类期间,调用线程会一直持有一把锁。接着的逻辑就是类的双亲委派模型机制的实现,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回。否则把类加载的任务交给父类加载器执行,直到父类加载器为空,此时会返回通过 JDK 提供的系统启动类加载器加载的类。如果最后发现该类仍然没有被加载过,则由当前类加载器调用 findClass() 方法继续加载该类。

ClassLoader 不提供 findClass() 方法的具体实现,要求我们根据自己的需要来重写该方法。我们可以看看 URLClassLoader 对 findClass() 方法的实现,发现类加载的工作又被代理给了 defineClass() 方法。

defineClass() 方法主要是把字节数组转化成类的实例。同时 defineClass() 方法是 final 和 native 的,具体是由虚拟机实现。

4.5 实现自己的类加载器

4.5.1 什么情况下需要自定义类加载器

  1. 隔离加载类。在某些框架内进行中间件与应用的模块隔离,把类加载器到不同的环境。比如,阿里内某容器框架通过自定义类加载器确保应用中依赖的jar包不会影响到中间件运行时使用的jar包。
  2. 修改类加载方式。类的加载模型并非强制,除了 Bootstrap 外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需的动态加载。
  3. 扩展加载源。比如从数据库、网络,甚至是电视机顶盒进行加载。(下面👇我们会编写一个从网络加载类的例子)
  4. 防止源码泄露。Java 代码容易被编译和篡改,可以进行编译加密。那么类加载器也需要自定义,还原加密的字节码。

    4.5.2 怎么自定义 ClassLoader

    实现一个自定义的类加载器比较简单:继承 ClassLoader,重写findClass()方法,调用defineClass()方法,就差不多行了。(在线 IDE 项目的实现方法,见02-字节码的加载和运行)。

最好不要直接重写 loadClass() 方法,因为 loadClass() 这个方法是实现双亲委托模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。当然,如果是刻意要破坏双亲委托模型就另说。

4.6 JVM 能否对同一个类加载两次?(怎么破坏双亲委派模型?)

由于双亲委派模型机制,通常情况下可以保证一个类只被加载一次。

但其实在某些情况下,我们可能需要加载两个不同的类,但是不巧的是这两个类的名字完全一样,这时候双亲委托模型就无法满足我们的要求了,我们就要重写 loadClass 方法破坏双亲委托模型,让同一个类被不同的 ClassLoader 加载多次。

参考

  1. VM类加载器-源码分析
  2. 一文带你深扒ClassLoader内核,揭开它的神秘面纱!