JVM核心类加载器及类加载的全过程

 2023-01-23
原文作者:summerSunShine 原文地址:https://juejin.cn/post/7013571411474120717

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

本文同时参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金

运行环境:

下面说明一下我的运行环境。我是在mac上操作的. 先找到mac的java地址. 从~/.bash_profile中可以看到

java的home目录是: /Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home

202301011540593361.png


一. 类加载的过程

1.1 类加载器初始化的过程

假如现在有一个java类 com.lxl.jvm.Math类, 里面有一个main方法

    package com.lxl.jvm;
    ​
    public class Math {
        public static int initData = 666;
        public static User user = new User();
    ​
        public int compute() {
            int a = 1;
            int b = 2;
            int c = (a + b) * 10;
            return c;
        }
    ​
        public static void main(String[] args) {
            Math math = new Math();
            math.compute();
        }
    }

这个方法很简单, 通常我们直接执行main方法就ok, 可以运行程序了, 那么点击运行main方法, 整个过程是如何被加载运行的呢? 为什么点击执行main, 就能得到结果呢?

先来看看答题的类加载流程(宏观流程), 如下图:

202301011540599492.png

备注:

1. windows上的java启动程序是java.exe, mac下是java

2. c语言部分,我们做了解, java部门是需要掌握的部分.

第一步: java调用底层的jvm.dll文件创建java虚拟机(这一步由C++实现) . 这里java.exe是c++写的代码, 调用的jvm.dll也是c++底层的一个函数. 通过调用jvm.dll文件(dll文件相当于java的jar包), 会创建java虚拟机. java虚拟机的启动都是c++程序实现的.

第二步: 在启动虚拟机的过程中, 会创建一个引导类加载器的实例. 这个引导类的加载器是C语言实现的. 然后jvm虚拟机就启动起来了.

第三步: 接下来,C++语言会调用java的启动程序.刚刚只是创建了java虚拟机, java虚拟机里面还有很多启动程序. 其中有一个程序叫做Launcher. 类全称是sun.misc.Launcher. 通过启动这个java类, 会由这个类引导加载器加载并创建很多其他的类加载器. 而这些加载器才是真正启动并加载磁盘上的字节码文件.

第四步:真正的去加载本地磁盘的字节码文件,然后启动执行main方法. (这一步后面会详细说,到底是怎么加载本地磁盘的字节码文件的。)

第五步: main方法执行完毕, 引导类加载器会发起一个c++调用, 销毁JVM

以上就是启动一个main方法, 这个类加载的全部过程

下面, 我们重点来看一下, 我们的类com.lxl.Math是怎么被加载到java虚拟机里面去的?  

1.2 类加载的过程

上面的com.lxl.jvm.Math类最终会生成clas字节码文件. 字节码文件是怎么被加载器加载到JVM虚拟机的呢?

类加载有五步:加载, 验证, 准备, 解析, 初始化. 那么这五步都是干什么的呢?我们来看一下

我们的类在哪里呢? 在磁盘里(比如: target文件夹下的class文件), 我们先要将class类加载到内存中. 加载到内存区域以后, 不是简简单单的转换成二进制字节码文件,他会经过一系列的过程. 比如: 验证, 准备, 解析, 初始化等. 把这一些列的信息转变成内元信息, 放到内存里面去. 我们来看看具体的过程

202301011541006813.png

第一步: 加载.

将class类加载到java虚拟机的内存里去, 在加载到内存之前, 会有一系列的操作。第一步是验证字节码。

202301011541012964.png

第二步:验证

验证字节码加载是否正确, 比如:打开一个字节码文件。打眼一看, 感觉像是乱码, 实际上不是的. 其实,这里面每个字符串都有对应的含义. 那么文件里面的内容我们能不能替换呢?当然不能, 一旦替换, 就不能执行成功了. 所以, 第一步:验证, 验证什么呢?

验证字节码加载是否正确: 格式是否正确. 内容是否符合java虚拟机的规范.

第三步:准备

验证完了, 接下来是准备. 准备干什么呢? 比如我们的类Math, 他首先会给Math里的静态变量赋值一个初始值. 比如我们Math里有两个静态变量

    public static int initData = 666;
    public static User user = new User();

在准备的过程中, 就会给这两个变量赋初始值, 这个初始值并不是真实的值, 比如initData的初始值是0. 如果是boolean类型, 就赋值为false. 也就是说, 准备阶段赋的值是jvm固定的, 不是我们定义的值. 如果一个final的常量, 比如public static final int name="zhangsan", 那么他在初始化的时候, 是直接赋初始值"zhangsan"的. 这里只是给静态变量赋初始值

第四步:解析

接下来说说解析的过程. 解析的过程略微复杂, 解析是将"符号引用"转变为直接引用.

什么是符号引用呢?

比如我们的程序中的main方法. 写法是固定的, 我们就可以将main当成一个符号. 比如上面的initData, int, static, 我们都可以将其称之为符号, java虚拟机内部有个专业名词,把他叫做符号. 这些符号被加载到内存里都会对应一个地址. 将"符号引用"转变为直接引用, 指的就是, 将main, initData, int等这些符号转变为对应的内存地址. 这个地址就是代码的直接引用. 根据直接引用的值,我们就可以知道代码在什么位置.然后拿到代码去真正的运行.

将符号引用转变为"内存地址", 这种有一个专业名词, 叫静态链接. 上面的解析过程就相当于静态链接的过程. 类加载期间,完成了符号到内存地址的转换. 有静态链接, 那么与之对应的还有动态链接.

什么是动态链接呢?

    public static void main(String[] args) {
            Math math = new Math();
            math.compute();
        }

比如:上面这段代码, 只有当我运行到math.compute()这句话的时候, 才回去加载compute()这个方法. 也就是说, 在加载的时候, 我不一定会把compute()这个方法解析成内存地址. 只有当运行到这行代买的时候, 才会解析.

我们来看看汇编代码

    javap -v Math.class
    Classfile /Users/luoxiaoli/Downloads/workspace/project-all/target/classes/com/lxl/jvm/Math.class
      Last modified 2020-6-27; size 777 bytes
      MD5 checksum a6834302dc2bf4e93011df4c0b774158
      Compiled from "Math.java"
    public class com.lxl.jvm.Math
      minor version: 0
      major version: 52
      flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
       #1 = Methodref          #9.#35         // java/lang/Object."<init>":()V
       #2 = Class              #36            // com/lxl/jvm/Math
       #3 = Methodref          #2.#35         // com/lxl/jvm/Math."<init>":()V
       #4 = Methodref          #2.#37         // com/lxl/jvm/Math.compute:()I
       #5 = Fieldref           #2.#38         // com/lxl/jvm/Math.initData:I
       #6 = Class              #39            // com/lxl/jvm/User
       #7 = Methodref          #6.#35         // com/lxl/jvm/User."<init>":()V
       #8 = Fieldref           #2.#40         // com/lxl/jvm/Math.user:Lcom/lxl/jvm/User;
       #9 = Class              #41            // java/lang/Object
      #10 = Utf8               initData
      #11 = Utf8               I
      #12 = Utf8               user
      #13 = Utf8               Lcom/lxl/jvm/User;
      #14 = Utf8               <init>
      #15 = Utf8               ()V
      #16 = Utf8               Code
      #17 = Utf8               LineNumberTable
      #18 = Utf8               LocalVariableTable
      #19 = Utf8               this
      #20 = Utf8               Lcom/lxl/jvm/Math;
      #21 = Utf8               compute
      #22 = Utf8               ()I
      #23 = Utf8               a
      #24 = Utf8               b
      #25 = Utf8               c
      #26 = Utf8               main
      #27 = Utf8               ([Ljava/lang/String;)V
      #28 = Utf8               args
      #29 = Utf8               [Ljava/lang/String;
      #30 = Utf8               math
      #31 = Utf8               MethodParameters
      #32 = Utf8               <clinit>
      #33 = Utf8               SourceFile
      #34 = Utf8               Math.java
      #35 = NameAndType        #14:#15        // "<init>":()V
      #36 = Utf8               com/lxl/jvm/Math
      #37 = NameAndType        #21:#22        // compute:()I
      #38 = NameAndType        #10:#11        // initData:I
      #39 = Utf8               com/lxl/jvm/User
      #40 = NameAndType        #12:#13        // user:Lcom/lxl/jvm/User;
      #41 = Utf8               java/lang/Object
    {
      public static int initData;
        descriptor: I
        flags: ACC_PUBLIC, ACC_STATIC
    ​
      public static com.lxl.jvm.User user;
        descriptor: Lcom/lxl/jvm/User;
        flags: ACC_PUBLIC, ACC_STATIC
    ​
      public com.lxl.jvm.Math();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=1, locals=1, args_size=1
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."<init>":()V
             4: return
          LineNumberTable:
            line 3: 0
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0       5     0  this   Lcom/lxl/jvm/Math;
    ​
      public int compute();
        descriptor: ()I
        flags: ACC_PUBLIC
        Code:
          stack=2, locals=4, args_size=1
             0: iconst_1
             1: istore_1
             2: iconst_2
             3: istore_2
             4: iload_1
             5: iload_2
             6: iadd
             7: bipush        10
             9: imul
            10: istore_3
            11: iload_3
            12: ireturn
          LineNumberTable:
            line 8: 0
            line 9: 2
            line 10: 4
            line 11: 11
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      13     0  this   Lcom/lxl/jvm/Math;
                2      11     1     a   I
                4       9     2     b   I
               11       2     3     c   I
    ​
      public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=2, args_size=1
             0: new           #2                  // class com/lxl/jvm/Math
             3: dup
             4: invokespecial #3                  // Method "<init>":()V
             7: astore_1
             8: aload_1
             9: invokevirtual #4                  // Method compute:()I
            12: pop
            13: return
          LineNumberTable:
            line 15: 0
            line 16: 8
            line 17: 13
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      14     0  args   [Ljava/lang/String;
                8       6     1  math   Lcom/lxl/jvm/Math;
        MethodParameters:
          Name                           Flags
          args
    ​
      static {};
        descriptor: ()V
        flags: ACC_STATIC
        Code:
          stack=2, locals=0, args_size=0
             0: sipush        666
             3: putstatic     #5                  // Field initData:I
             6: new           #6                  // class com/lxl/jvm/User
             9: dup
            10: invokespecial #7                  // Method com/lxl/jvm/User."<init>":()V
            13: putstatic     #8                  // Field user:Lcom/lxl/jvm/User;
            16: return
          LineNumberTable:
            line 4: 0
            line 5: 6
    }
    SourceFile: "Math.java"

使用这个指令, 就可以查看Math的二进制文件. 其实这个文件,就是上面那个二进制代码文件.

看看这里面有什么东西?

类的名称, 大小,修改时间, 大版本,小版本, 访问修饰符等等

     Last modified 2020-6-27; size 777 bytes
      MD5 checksum a6834302dc2bf4e93011df4c0b774158
      Compiled from "Math.java"
    public class com.lxl.jvm.Math
      minor version: 0
      major version: 52

还有一个Constant pool 常量池. 这个常量池里面有很多东西. 我们重点看中间哪一行. 第一列表示一个常量的标志符, 这个标识符可能在其他地方会用到. 第二列就表示常量内容.

    Constant pool:
       #1 = Methodref          #9.#35         // java/lang/Object."<init>":()V
       #2 = Class              #36            // com/lxl/jvm/Math
       #3 = Methodref          #2.#35         // com/lxl/jvm/Math."<init>":()V
       #4 = Methodref          #2.#37         // com/lxl/jvm/Math.compute:()I
       #5 = Fieldref           #2.#38         // com/lxl/jvm/Math.initData:I
       #6 = Class              #39            // com/lxl/jvm/User
       #7 = Methodref          #6.#35         // com/lxl/jvm/User."<init>":()V
       #8 = Fieldref           #2.#40         // com/lxl/jvm/Math.user:Lcom/lxl/jvm/User;
       #9 = Class              #41            // java/lang/Object
      #10 = Utf8               initData
      #11 = Utf8               I
      #12 = Utf8               user
      #13 = Utf8               Lcom/lxl/jvm/User;
      #14 = Utf8               <init>
      #15 = Utf8               ()V
      #16 = Utf8               Code
      #17 = Utf8               LineNumberTable
      #18 = Utf8               LocalVariableTable
      #19 = Utf8               this
      #20 = Utf8               Lcom/lxl/jvm/Math;
      #21 = Utf8               compute

这些标识符在后面都会被用到, 比如main方法

     public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=2, args_size=1
             0: new           #2                  // class com/lxl/jvm/Math
             3: dup
             4: invokespecial #3                  // Method "<init>":()V
             7: astore_1
             8: aload_1
             9: invokevirtual #4                  // Method compute:()I
            12: pop
            13: return
          LineNumberTable:
            line 15: 0
            line 16: 8
            line 17: 13
          LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      14     0  args   [Ljava/lang/String;
                8       6     1  math   Lcom/lxl/jvm/Math;
        MethodParameters:
          Name                           Flags
          args

这里面就用到了#2 #3 #4 ,这都是标识符的引用.

第一句: new了一个Math(). 我们看看汇编怎么写的?

             0: new           #2                  // class com/lxl/jvm/Math

new + #2. #2是什么呢? 去常量池里看, #2代表的就是Math类

       #2 = Class              #36            // com/lxl/jvm/Math

这里要说的还是math.compute()这个方法, 不是在类加载的时候就被加载到内存中去了, 而是运行main方法的时候, 执行到这行代码才被加载进去, 这个过程叫做动态链接.

类加载的时候, 我们可以把"解析"理解为静态加载的过程. 一般像静态方法(例如main方法), 获取其他不变的静态方法会被直接加载到内存中, 因为考虑到性能, 他们加载完以后就不会变了, 就直接将其转变为在内存中的代码位置.

而像math.compute()方法, 在加载过程中可能会变的方法(比如compute是个多态,有多个实现), 那么在初始化加载的时候, 我们不会到他会调用谁, 只有到运行时才能知道代码的实现, 所以在运行的时候在动态的去查询他在内存中的位置, 这个过程就是动态加载

第五步: 初始化

对类的静态变量初始化为指定的值. 执行静态代码块. 比如代码

    public static int initData = 666;

在准备阶段将其赋值为0, 而在初始化阶段, 会将其赋值为设定的666  

1.3 类的懒加载

类被加载到方法区中以后,主要包含:运行时常量池, 类型信息, 字段信息, 方法信息, 类加载器的引用, 对应class实例的引用等信息.

什么意思呢? 就是说, 当一个类被加载到内存, 这个类的常量,有常量名, 类型, 域信息等; 方法有方法名, 返回值类型, 参数类型, 方法作用域等符号信息都会被加载放入不同的区域.

注意: 如果主类在运行中用到其他类,会逐步加载这些类, 也就是说懒加载. 用到的时候才加载.

    package com.lxl.jvm;
    public class TestDynamicLoad {
        static {
            System.out.println("********Dynamic load class**************");
        }
    ​
        public static void main(String[] args) {
            new A();
            System.out.println("*********load test*****************");
            B b = null; // 这里的b不会被加载, 除非new B();
        }
    }
    ​
    class A {
        static {
            System.out.println("********load A**************");
        }
    ​
        public A(){
            System.out.println("********initial A**************");
        }
    }
    ​
    class B {
        static {
            System.out.println("********load B**************");
        }
    ​
        public B(){
            System.out.println("********initial B**************");
        }
    }

这里定义了两个类A和B, 当使用到哪一个的时候, 那个类才会被加载, 比如:main方法中, B没有被用到, 所以, 他不会被加载到内存中.

运行结果

    ********Dynamic load class**************
    ********load A**************
    ********initial A**************
    *********load test*****************

我们看到A类被加载了,而B类没有被加载,原因是B类只声明了,没有用到。

总结几点如下:

  1. 静态代码块在构造方法之前执行

  2. 没有被真正使用的类不会被加载

二. 类加载器

2.1 类加载器的类型

类主要通过类加载器来加载, java里面有如下几种类加载器

1. 引导类加载器(Bootstrap ClassLoader)

在上面类加载流程中,说到在 [启动虚拟机的过程中, 会创建一个 引导类加载器 的实例] 这个引导类加载器的目的是什么呢?加载类

引导类加载器主要负责加载最最核心的java类型。 这些类库位于jre目录的lib目录下**. 比如:rt.jar, charset.jar等,

2. 扩展类加载器(Ext ClassLoader)

扩展类加载器主要是用来加载扩展的jar包。 加载jar的目录位于jre目录的lib/ext扩展目录中的jar包

3. 应用程序类加载器(App CloassLoader)

主要是用来加载用户自己写的类的。 负责加载classPath路径下的类包

4. 自定义类加载器

负责加载用户自定义路径下的类包

引导类加载器是由C++帮我们实现的, 然后c++语言会通过一个Launcher类将扩展类加载器(ExtClassLoader)和应用程序类加载器(AppClassLoader)构造出来, 并且把他们之间的关系构建好.

2.2 案例

案例一:测试jdk自带的类加载器

    package com.lxl.jvm;
    import sun.misc.Launcher;
    import java.net.URL;
    public class TestJDKClassLoader {
        public static void main(String[] args) {
            /**
             * 第一个: String 是jdk自身自带的类,位于jre/lib核心目录下, 所以, 他的类加载器是引导类加载器
             * 第二个: 加密类的classloader, 这是jdk扩展包的一个类
             * 第三个: 是我们当前自己定义的类, 会被应用类加载器加载
             */
            System.out.println(String.class.getClassLoader());                     System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());
            System.out.println(TestJDKClassLoader.class.getClassLoader().getClass().getName());
        }
    }

我们来看这个简单的代码, 运行结果:

    null
    sun.misc.Launcher$ExtClassLoader
    sun.misc.Launcher$AppClassLoader
    ​
    解析:
     第一个: String 是jdk自身自带的类, 所以, 他的类加载器是引导类加载器,引导类加载器是c++代码,所以这里返回null
     第二个: 加密类的classloader, 这是jdk扩展包的一个类, jdk扩展包里面使用的是extClassLoader类加载器加载的
     第三个: 是我们当前自己定义的类, 会被AppClassLoader应用程序加载器加载.

我们看到ExtClassLoader和AppClassLoader都是Launcher类的一部分. 那Launcher类是什么东西呢?

上面有提到, Launcher类是jvm启动的时候由C++调用启动的一个类. 这个类引导加载器加载并创建其他的类加载器。

那么,第一个bootstrap引导类加载器, 那引导类加载器返回的为什么是null呢?

因为bootstrap引导类加载器, 他不是java的对象, 他是c++生成的对象, 所以这里是看不到的

案例二: BootstrapClassLoad和ExtClassLoader、AppClassLoader的关系

202301011541020005.png

如上图,左边是C语言程序代码实现, 右边是java代码实现。这里是跨语言调用,JNI实现了有c++向java跨语言调用。c语言调用的第一个java类是Launcher类。

从这个图中我们可以看出,C++调用java创建JVM启动器, 其中一个启动器是Launcher, 他实际是调用了sun.misc.Launcher类的getLauncher()方法. 那我们就从这个方法入手看看到底是如何运行的?

我们看到Lanucher.java类是在核心的rt.jar包里的,Lanucher是非常核心的一个类。

202301011541032106.png

我们看到getLauncher()类直接返回了launcher. 而launcher是一个静态对象变量, 这是一个 单例模式

C++调用了getLauncher()-->直接返回了lanucher对象, 而launcher对象是在构建类的时候就已经初始化好了. 那么,初始化的时候做了哪些操作呢?接下来看看他的构造方法.

202301011541042607.png

在构造方法里, 首先定义了一个ExtClassLoader. 这是一个扩展类加载器, 扩展类加载器调用的是getExtClassLoader(). 接下来看一看getExtClassLoader这个方法做了什么?

202301011541050488.png

这是一个典型的多线程同步的写法。

在这里, 判断当前对象是否初始化过, 如果没有, 那么就创建一个ExtClassLoader()对象, 看看createExtClassLoader()这个方法做了什么事呢?

202301011541059509.png

doPrivileged是一个权限校验的操作, 我们可以先不用管, 直接看最后一句, return new Launcher.ExtClassLoader(var1). 直接new了一个ExtClassLoader, 其中参数是var1, 代表的是ext扩展目录下的文件.

2023010115410687610.png

在ExtClassLoader(File[] var1)这个方法中, 这里第一步就是调用了父类的super构造方法. 而ExtClassLoader继承了谁呢? 我们可以看到他继承了URLClassLoader.

2023010115410755511.png

而URLClassLoader是干什么用的呢? 其实联想一下大概能够猜数来, 这里有一些文件路径, 通过文件路径加载class类.

我们继续看调用的super(parent), 我们继续往下走, 就会看到调用了ClassLoader接口的构造方法:

2023010115410846712.png

这里设置了ExtClassLoader的parent是谁? 注意看,我们发现, ExtClassLoader的parent类是null.

2023010115410945713.png

这就是传递过来的parent类加载器, 那么这里的parent类加载器为什么是null呢? 因为, ExtClassLoader的父类加载器是谁呢? 他是Bootstrap ClassLoader. 而BootStrap ClassLoader是C++的类加载器, 我们不能直接调用它, 所以, 设置为null.

其实, ExtClassLoader在初始化阶段就是调用了ExtClassLoader方法, 初始化了ExtClassLoader类

接下来,我们回到Launcher的构造方法, 看看Launcher接下来又做了什么?

2023010115411033314.png

可以看到, 接下来调了AppClassLoader的getAppClassLoader(var1), 这个方法. 需要注意一下的是var1这个参数. var1是谁呢? 向上看, 可以看到var1是ExtClassLoader.

2023010115411126515.png

这是AppClassLoader, 应用程序类加载器, 这个类是加载我们自己定义的类的类加载器. 他也是继承自URLClassLoader.

我们来看看getAppClassLoader(final ClassLoader var0)方法. 这个方法的参数就是上面传递过来的ExtClassLoader

这里第一句话就是获取当前项目的class 文件路径, 然后将其转换为URL. 并调用了Launcher.AppClassLoader(var1x, var0), 其中var1x是class类所在的路径集合, var0是扩展的类加载器ExtClassLoader, 接下来, 我们进入到这个方法里看一看

2023010115411218216.png

AppClassLoader直接调用了其父类的构造方法, 参数是class类路径集合, 和ExtClassLoader

2023010115411322017.png

2023010115411419418.png

最后, 我们看到, 将ExtClassLoader传递给了parent变量. 这是定义在ClassLoader中的属性, 而ClassLoader类是所有类加载器的父类. 因此, 我们也可以看到AppClassLoader的父类加载器是ExtClassLoader

同时, 我们也看到了, C++在启动JVM的时候, 调用了Launcher启动类, 这个启动类同时加载了ExtClassLoader和AppClassLoader.

    public static void main(String[] args) {
            
      ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
      ClassLoader extClassLoader = appClassLoader.getParent();
      ClassLoader bootstrapClassLoad = extClassLoader.getParent();
    ​
    ​
      System.out.println("bootstrap class loader: " + bootstrapClassLoad);
      System.out.println("ext class loader " + extClassLoader);
      System.out.println("app class loader "+ appClassLoader);
    }

通过这个demo, 我们也可以看出, appClassLoader的父类是extClassLoader, extClassLoader的父类是bootstrapClassLoader

输出结果:

    bootstrap class loader: null
    ext class loader sun.misc.Launcher$ExtClassLoader@2a84aee7
    app class loader sun.misc.Launcher$AppClassLoader@18b4aac2

通过上面的源码分析,我们发现引导类加载器创建并加载了扩展类加载器和应用类加载器。而扩展类加载器的父加载器是引导类加载器。应用类加载器的父加载器是扩展类加载器。这个结构,决定了后面类的加载方式,也就是双亲委派机制。