类加载器子系统

135 阅读14分钟

简介

本文整理类加载器子系统的相关知识,主要包括了一下内容:

  • 类加载的过程
  • 类加载器
  • 双亲委派机制
  • 其他相关内容拓展

首先,我们先来了解一下类加载器子系统在Java虚拟机内部扮演了一个什么样的角色,也就是说这玩意到底有啥用:

类加载器子系统负责从文件系统或者网络中加载Class文件, 加载的类信息存放于一块称为方法区的内存空间

结合下图先有一个大概的理解,接下来会详细介绍:

一、类加载过程

系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

1、加载阶段

类加载过程的第一步,主要完成下面 3 件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

加载class文件的方式有很多种:从本地系统中直接加载;通过网络获取;从zip压缩包中读取;从加密文件中获取等

2、连接阶段

链接分为三个子阶段:验证 -> 准备 -> 解析

2.1、验证(Verify)

加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。

是为了确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

文件格式验证:验证字节流是否符合Class文件格式的规范,例如:是否以 0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池的常量是否有不被支持的类型。

元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。例如:这个类是否继承了不允许继承的类。

字节码验证:最复杂的一个阶段。通过数据流和控制流的分析,确定程序语义是合法的,符合逻辑的。比如保证任意时刻操作数栈和指令代码序列都能配合工作。

符号引用验证:确保解析动作能正常执行

2.2、准备(Prepare)

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

  1. 这时候进行内存分配的仅包括类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  2. 从概念上讲,类变量所使用的内存都应当在 方法区 中进行分配。不过有一点需要注意的是:JDK 7 之前,HotSpot 使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的。 而在 JDK 7 及之后,HotSpot 已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着 Class 对象一起存放在 Java 堆中。
  3. 这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等)。

private static int a = 1;//prepare:a = 0 ---> initial : a = 1

比如我们定义了public static int value=1 ,那么 value 变量在准备阶段的初始值就是 0 而不是 1(初始化阶段才会赋值)。

被 final 关键字修饰的情况:比如给 value 变量加上了 final 关键字public static final int value=1 ,编译的时候就会分配好了默认值,准备阶段会显式初始化被赋值为 1。

2.3、解析(Resolve)

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

符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。在程序实际运行时,只有符号引用是不够的,在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量

3、初始化阶段

初始化阶段是执行初始化方法 <clinit> ()方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。

<clinit> ()方法是编译之后自动生成的;是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。也就是说,当我们代码中包含static变量的时候,就会有 <clinit> ()方法。

为了内容的连续性,把对应的示例放到文末拓展部份:(1、示例: <clinit> ()方法什么时候会生成?)

  1. ()方法中的指令按语句在源文件中出现的顺序执行
  2. 若该类具有父类,JVM会保证子类的()执行前,父类的()已经执行完毕
  3. 对于<clinit> () 方法的调用,虚拟机会自己确保其在多线程环境中的安全性。

相关的示例见文末拓展部份:(2、示例:虚拟机必须保证一个类的 ()方法在多线程下被同步加锁)

对于初始化阶段,虚拟机严格规范了有且只有 几 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

  1. 当遇到 newgetstaticputstaticinvokestatic 这 4 条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。
    • 当 jvm 执行 new 指令时会初始化类。即当程序创建一个类的实例对象。
    • 当 jvm 执行 getstatic 指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。
    • 当 jvm 执行 putstatic 指令时会初始化类。即程序给类的静态变量赋值。
    • 当 jvm 执行 invokestatic 指令时会初始化类。即程序调用类的静态方法。
  1. 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("..."), newInstance() 等等。如果类没初始化,需要触发其初始化。
  2. 初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
  3. 当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。
  4. MethodHandleVarHandle 可以看作是轻量级的反射调用机制,而要想使用这 2 个调用, 就必须先使用 findStaticVarHandle 来初始化要调用的类。
  5. 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

到这里类的加载过程已经说完了,下面拓展以下类的卸载。

卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 GC

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

只要想通一点就好了,jdk 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 jdk 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

二、类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader:

  • BootstrapClassLoader(启动类加载器,又:引导类加载器) :

最顶层的加载类,由 C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类;加载扩展类和应用程序类加载器,并作为他们的父类加载器。

  • ExtensionClassLoader(扩展类加载器) :

主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。

  • AppClassLoader(应用程序类加载器) :

面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

该类加载是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载。

双亲委派机制

每一个类都有一个对应它的类加载器。系统中的 ClassLoader 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

我们可以通过下面的代码验证:

public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println("ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader());
        System.out.println("The Parent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent());
        System.out.println("The GrandParent of ClassLodarDemo's ClassLoader is " + ClassLoaderDemo.class.getClassLoader().getParent().getParent());
    }
}
ClassLodarDemo's ClassLoader is sun.misc.Launcher$AppClassLoader@18b4aac2
The Parent of ClassLodarDemo's ClassLoader is sun.misc.Launcher$ExtClassLoader@1b6d3586
The GrandParent of ClassLodarDemo's ClassLoader is null

AppClassLoader的父类加载器为ExtClassLoaderExtClassLoader的父类加载器为 null,null 并不代表ExtClassLoader没有父类加载器,而是 BootstrapClassLoader

双亲委派机制好处

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题。

比如我们编写一个称为 java.lang.String 类的话,那么程序运行的时候,系统就会出现多个不同的 String 类。

如果我们不想用双亲委派模型怎么办?

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法。

沙箱安全机制

假如我们在自己的项目中自定义了一个String类,这个类正好位于我们自己创建的 java.lang包下。像这样:

那么在加载String类的时候会率先使用引导类加载器( BootstrapClassLoader )加载,而引导类加载器( BootstrapClassLoader )在加载的过程中会先加载jdk自带的文件(rt.jar包中java.lang.String.class)。

这样可以保证对java核心源代码的保护,这就是沙箱安全机制。

x、拓展

1、示例: <clinit> ()方法什么时候会生成?

当我们的代码中有这样一行private static int b = 2;,会生成 <clinit> ()方法。

把对应的代码注释,不会生成 <clinit> ()方法。

2、示例:虚拟机必须保证一个类的 <clinit> ()方法在多线程下被同步加锁

public class DeadThreadTest {
    public static void main(String[] args) {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() + "开始");
            DeadThread dead = new DeadThread();
            System.out.println(Thread.currentThread().getName() + "结束");
        };

        Thread t1 = new Thread(r,"线程1");
        Thread t2 = new Thread(r,"线程2");

        t1.start();
        t2.start();
    }
}

class DeadThread{
    static{
        if(true){
            System.out.println(Thread.currentThread().getName() + "初始化当前类");
            while(true){

            }
        }
    }
}

输出结果:

线程2开始
线程1开始
线程2初始化当前类

//然后程序卡死了

3、以下代码会报错吗?

public class ClassInitTest {
   private static int num = 1;

   static{
       num = 2;
       number = 20;
       System.out.println(num);
       System.out.println(number);
   }

   private static int number = 10;

    public static void main(String[] args) {
        System.out.println(ClassInitTest.num);
        System.out.println(ClassInitTest.number);
    }
}

会报错!

报错的代码位于第8行,就是这行代码 System.out.println(number);如果把第8行代码去掉,程序是可以正常执行的,main方法中的两个值可以正常打印出来。

public class ClassInitTest {
   private static int num = 1;

   static{
       num = 2;
       number = 20;
       System.out.println(num);
       //System.out.println(number);//报错:非法的前向引用。
   }

   /**
    * 为什么 number 在上面静态代码块中可以赋值却不能使用:
      类加载准备阶段为类变量分配内存并设置类变量初始值,也就是0,也就是说这一阶段赋值动作
      是没有影响的。
      正真的赋值实在初始化阶段,<clinit>()方法中的指令按语句在源文件中出现的顺序执行,
      先赋值20,再赋值10。这也是为什么main方法中 System.out.println(ClassInitTest.number);
      会打印10的原因。
      
    *
    */
   private static int number = 10;

    public static void main(String[] args) {
    
        System.out.println(ClassInitTest.num);//2
        
        System.out.println(ClassInitTest.number);//10
    }
}

4、如何判断两个class对象是否相同?

在JVM中表示两个class对象是否为同一个类存在两个必要条件:

  1. 类的完整类名必须一致,包括包名
  2. 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同

换句话说,在JVM中,即使这两个类对象(class对象)来源同一个Class文件,被同一个虚拟机所加载,但只要加载它们的ClassLoader实例对象不同,那么这两个类对象也是不相等的

总结

类加载过程:加载 -> 连接(验证->准备->解析) -> 初始化

  • 加载:获取类的二进制字节流、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构、生成类的Class对象
  • 验证:确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性。
  • 准备:正式为类变量分配内存并设置类变量初始值的阶段。(类变量( Class Variables ,即静态变量,被 static 关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中)
  • 解析:虚拟机将常量池内的符号引用替换为直接引用的过程。
  • 初始化:是执行初始化方法 <clinit> ()方法的过程。

类加载器:

  • BootstrapClassLoader(启动类加载器)  :最顶层的加载类,由 C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类。
  • ExtensionClassLoader(扩展类加载器)  :主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
  • AppClassLoader(应用程序类加载器)  :面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

双亲委派模型:

类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。