类加载器看这篇就够了

4,827 阅读11分钟

面试问类加载器?看这篇就够了

思考

我们平时写的代码或程序到底是如何运行起来的呢? 比如我开发用的是 java 语言,源码是是 .java 的文件,但他们是没有办法运行的。通常我们会打成 jar 包,然后部署到服务器上,其实我们所说的打包就是编译,即把 java 文件编译成 .class 字节码文件,那如何执行这些 .class 字节码文件呢? 通过 java -jar 命令来执行这些 .class 文件。其实 java -jar 命令启动了一个 jvm 进程,由 jvm 进程来运行这些字节码文件

概述

jvm 如何加载这些 class 文件呢?

上面我们说 jvm 会运行这些 .class 字节码文件,但他们是怎么加载进来的 呢?

当然是通过类加载器了,类加载器加载 .class 文件的流程为

加载->验证->准备->解析->初始化

类加载过程

下面我们就分析下加载的整体流程,但在分析整个流程前,先介绍下类加载的条件

类加载条件

一般我们的一个程序中会有很多 class 文件,那 jvm 会无条件加载这些文件吗?

肯定不是的,其实 jvm 只有在**“使用”该 class 文件时才会加载,这里的“使用”主动使用**,主动使用只有下列几种情况:

1.当创建一个类的实例时,比如使用 new 关键字或者反射、克隆、反序列化

2.当调用类的静态方法时,即使用字节码 invodestatic 指令

3.当使用类或接口的静态字段时(final 常量除外),比如使用 getstatic 或者 putstatic 指令

4.当使用 java.lang.reflect 包中的方法反射类的方法时

5.当初始化子类时,要求先初始化父类

6.作为启动虚拟机,含有 main() 方法的那个类

除上面列出的 6 点为主动使用外,其他都是被动使用

主动使用的例子

public class Parent {
    static {
        System.out.println("Parent init");
    }
}

public class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

public class Main {
    public static void main(String[] args) {
        Child child = new Child();
    }
}

如果 Parent 类被初始化,会打印 “Parent init”,如果 Child 类被初始化,会打印"Child init",通过执行 Main 类中的 main 方法,来初始化 Child 类,发现打印如下:

Parent init Child init

通过打印结果,我们可以验证主动使用 class 文件的两个条件,1 和 5 是成立的

其他主动使用的情况就不举例子了,下面我们来看下被动使用的例子

被动使用的例子

public class Parent {
    public static int v = 60;
    static {
        System.out.println("Parent init");
    }
}

public class Child extends Parent {
    static {
        System.out.println("Child init");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Child.v);
    }
}

这次是在 Parent 类中增加了一个静态变量 v,但 Child 类中没有增加,然后在 Main 类中访问 Child.v,这种情况会加载 Parent 类吗?会加载 Child 类吗?

输出结果如下:

Parent init 60

可见,只加载了 Parent 类,并没有加载 Child 类,值得注意,这里的“加载”指完成整个加载的过程,其实此时 Child 类也被加载了(这里的加载指整个加载过程的第一步加载,可以通过加上 -XX:TraceClassLoading 参数来验证),但没有进行初始化。

加上 -XX:TraceClassLoading 后的输出结果

[Loaded jvm.loadclass.Parent from file:/D:/workspace/study/study_demo/target/classes/] [Loaded jvm.loadclass.Child from file:/D:/workspace/study/study_demo/target/classes/] Parent init 60

所以在使用一个字段时,只有直接定义该字段的类才会被初始化

在主动使用的第 3 点,很明确的指出,使用类的 final 常量不属于主动使用,也就不会加载对应的类,我们通过代码验证下

public class ConstantClass {
    public static final String CONSTANT = "constant";
    static {
        System.out.println("ConstantClass init");
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(ConstantClass.CONSTANT);
    }
}

输出结果如下:

[Loaded jvm.loadclass.Main from file:/D:/workspace/study/study_demo/target/classes/]

constant

通过结果,确实验证了 final 常量不会引起类的初始化,因为在编译阶段对常量做了优化(学名是“常量传播优化”),把常量值 "constant"直接存放到了 Main 类的常量池中,所以不会加载 ConstantClass 类

加载

加载是类加载过程的第一个阶段,在加载阶段,jvm 需要完成如下工作:

1.通过类的全限定类名获取类的二进制数据流

2.解析类的二进制数据流为方法区内的数据结构

3.创建 java.lang.Class 类的实例,表示该类型

获取类的二进制数据流的方式有很多,比如直接读入 .class 文件,或者从 jar 、zip、war等归档数据包中提取 .class 文件,然后 jvm 处理这些二进制数据流并生成一个 java.lang.Class 的实例,该实例是访问类型元数据的接口,也是实现反射的关键数据

验证

验证阶段是为了保证加载的字节码是符合jvm规范的,大体分为格式检查、语义检查、字节码检验证、符号引用验证,如下所示:

验证

准备

准备阶段主要就是为类分配相应的内存空间,并设置初始值,常用的初始值如下表所示:

数据类型 默认初始值
int 0
long 0L
short (short)0
char '\u0000'
boolean fasle
float 0.0f
double 0.0d
reference null

如果类中定义了常量,如:

public static final String CONSTANT = "constant";

这种常量(查看字节码文件,含有 ConstantValue 属性)会在准备阶段直接存到常量池中

 public static final java.lang.String CONSTANT;
    descriptor: Ljava/lang/String;
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: String constant

解析

解析阶段主要把类、接口、字段和方法的符号引用转为直接引用

符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义地定位到目标即可

直接引用:直接引用是可以直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄

解析阶段主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符

下面我们通过一个例子来简单解释下

public class Demo {
    public static void main(String[] args) {
        System.out.println();
    }
}

查看 main 方法中 System.out.println() 方法对应的字节码

3: invokevirtual #3                  // Method java/io/PrintStream.println:()V

常量池第 3 项被使用,那我们去看常量池中第 3 项的内容,如下:

#3 = Methodref          #17.#18        // java/io/PrintStream.println:()V

看来还要继续查找引用关系,第 17 项和第 18 项,如下:

#17 = Class              #24            // java/io/PrintStream
#18 = NameAndType        #25:#7         // println:()V

其中第 17 项又引用到了第 24 项,第 18 项又引用了 第 25 和 7 项,分别如下:

#24 = Utf8               java/io/PrintStream
#25 = Utf8               println
#7 = Utf8               ()V

我们在一张图中表示上面的引用关系关系,如下所示:

符号引用

其实上面的引用关系就是符号引用

但在程序运行时,光有符号引用是不够的,系统需要明确知道该方法的位置,所以 jvm 为每个类准备了一张方法表,将其所有的方法都列入到了方法表中,当需要调用一个类的方法时,只要知道这个方法在方法表中的偏移量就可以直接调用了。通过解析操作,符号引用可以转变为目标方法在类方法表中的位置,使得方法被成功调用。

初始化

初始化是类加载的最后一个阶段,只要前面的阶段都没有问题,就会进入到初始化阶段。那初始化阶段做什么工作呢?

主要就是执行类的初始化方法(该初始化方法由编译器自动生成),它是由类静态成员变量的赋值语句及 static 语句块共同产生的。这个阶段才是执行真正的赋值操作。准备阶段只是分配了相应的内存空间,并设置了初始值。

下面我们通过一个小例子来验证下

public class StaticParent {
    public static int id = 1;
    public static int num ;
    static {
        num = 4;
    }
}

对应的部分字节码文件如下所示:

#13 = Utf8               <clinit>
static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: iconst_1
         1: putstatic     #2                  // Field id:I
         4: iconst_4
         5: putstatic     #3                  // Field num:I
         8: return

可以看到在 方法中,对类中的 static 变量 id 和 static语句块中的 num 进行了赋值操作

那编译器会为所有的类都生成方法吗?答案是否定的,如果一个类既没有赋值语句,又没有 static 语句块,这样即使生成了 方法,也是无事可做,所以编译器就不插入了。我们通过一个例子看下对应的字节码

public class StaticFinalParent {
    public static final int a = 1;
    public static final int b = 2;
}
public jvm.loadclass.StaticFinalParent();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

从字节码中没有发现 方法,因为我们前面说过,final 类型的常量是在准备阶段完成的初始化,所以在初始化阶段就不用再初始化了。

注意点

这里指的注意的一点是,jvm 会保证方法 的安全性,因为可能存在多个线程同时去初始化类,这样要保证只有一个线程执行 方法,而其他线程要等待,只要有线程初始化类成功,其他线程就不用再次进行初始化了

小总结

通过上面的介绍,我想大家应该了解了我们平时写的代码,最后到底是如何运行起来的了吧,总之一句话就是我们编写的 java 文件,会被编译成 class 字节码文件,然后由 jvm 把主动使用的类加载到内存中,然后开始执行这些程序。很重要的阶段就是加载类即从外部系统获得 class 文件的二进制流,而在该阶段起着决定性作用的就是下面要介绍的 类加载器

类加载器

ClassLoader 代表类加载器,是 java 的核心组件,可以说所有的 class 文件都是由类加载器从外部读入系统,然后交由 jvm 进行后续的连接、初始化等操作。

分类

jvm 会创建三种类加载器,分别为启动类加载器、扩展类加载器和应用类加载器,下面我们分别简单介绍下各个类加载器

启动类加载器

Bootstrap ClassLoader 主要负责加载系统的核心类,如 rt.jar 中的 java 类,我们在 Linux 系统或 Windows 系统使用 java,都会安装 jdk,lib 目录里其实里面就有这些核心类

扩展类加载器

Extension ClassLoader 主要用于加载 lib\ext 中的 java 类,这些类会支持系统的运行

应用类加载器

Application ClassLoader 主要加载用户类,即加载用户类路径(ClassPath)上指定的类库,一般都是我们自己写的代码

双亲委派模型

在类加载时,系统会判断当前类是否已经加载,如果已经加载了,就直接返回可用的类,否则就会尝试去加载这个类。在尝试加载类时,会先委派给其父加载器加载,最终传到顶层的加载器加载。如果父类加载器在自己的负责的范围内没有找到这个类,就会下推给子类加载器加载。加载情况如下所示:

双亲委派模型

可见检查类是否加载的委派过程是单向的,底层的类加载器询问了半天,到最后还是自己加载类,那不白费力气了吗?这样做当然有它的好的,这样在结构上比较清晰,最重要的是可以避免多层级的加载器重复加载某些类

双亲委派模型的弊端

双亲委派模型检查类加载是单向的,但这样也有个弊端就是上层的类加载器无法访问由下层类加载器所加载的类。那如果启动类加载器加载的系统类中提供了一个接口,接口需要在应用中实现,还绑定了一个工厂方法,用于创建该接口的实例。而接口和工厂方法都在启动类加载器中。这时就会出现该工厂无法创建由应用类加载器加载的应用实例的问题。比如 JDBC、XML Parser 等

jvm 这么厉害,肯定会有办法解决这种问题的,没错,java 中通过 SPI(Service Provider Interface)机制解来解决这类问题

总结

本文主要介绍了 jvm 的类加载机制,包括类加载的全过程和每个阶段做的一些事情。然后介绍了类加载器的工作机制和双亲委派模型。更输入的知识点,希望你自己去继续研究,比如 OSGI 机制,热替换和热部署如何实现等

参考资料

1.《实战 Java 虚拟机》

2.《深入理解Java虚拟机》

3.《从0开始带你成为JVM实战高手》,公众号回复“jvm”可查看资料

欢迎关注公众号 【每天晒白牙】,获取最新文章,我们一起交流,共同进步!