类加载器知识点吐血整理

思考

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

概述


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

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

固然是经过类加载器了,类加载器加载 .class 文件的流程为redis

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

类加载过程

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


类加载条件

通常咱们的一个程序中会有不少 class 文件,那 jvm 会无条件加载这些文件吗?网络

确定不是的,其实 jvm 只有在“使用”该 class 文件时才会加载,这里的“使用”主动使用,主动使用只有下列几种状况:数据结构

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

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

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

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”可查看资料

历史文章推荐

三面阿里被挂,幸获内推名额,历经 5 面终获口碑 offer

原创|ES广告倒排索引架构演进与优化

广告倒排索引架构与优化

cpu使用率太高和jvm old占用太高排查过程

频繁FGC的真凶原来是它

老年代又占用100%了,顺便发现了vertx-redis-client 的bug

KafkaProducer源码分析

Kafka服务端之网络层源码分析

Redis 的过时策略是如何实现的?

原创|若是懂了HashMap这两点,面试就没问题了

原创|面试官:Java对象必定分配在堆上吗?

原创|这道面试题,大部分人都答错了