深入剖析JVM中的类加载机制

一、概述

Java中的数据类型分为两类:基本数据类型和引用数据类型。基本数据类型由JVM预先定义,引用数据类型则需要进行经过类的加载过程加载到JVM的内存中。

在这里插入图片描述

按照JVM规范,从字节码文件到加载到JVM内存中的类,到类卸载出内存为止,其整个生命周期包括如下7个阶段

在这里插入图片描述

其中验证、准备、解析三个阶段统称为连接(Linking)。

  • 加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)
  • 上面所说的顺序是指按部就班地“开始”,而不是按部就班地“进行”或“完成”,因为这些阶段通常互相交叉地混合进行,会在一个阶段执行的过程中调用、激活另一个阶段。

二、加载(Loading)

加载阶段的主要作用是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象。其类似于Java类在JVM内存中的一个快照,存储了从字节码文件中解析出的常量池、类字段、类方法等信息。

反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法实现反射的能力。

2.1 加载阶段完成的工作

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,在加载阶段,JVM需要完成以下三件事情:

  • 通过一个类的全限定名来获取其对应的二进制字节流。
  • 将这个二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入 口。

对于类的二进制数据流,JVM可以通过多种途径获取(只要所读取的字节码符合JVM规范即可):

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流
  • 使用类似于HTTP之类的协议通过网络进行加载

如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。在获取到类的二进制信息后,JVM会处理这些数据,并最终生成一个java.lang.Class的实例。

2.2 类模板与Class实例的存储位置

在这里插入图片描述

  • 加载的类在JVM中创建相应的类模板结构,并存储在方法区中(JDKl.8之前:永久代;J0Kl.8及之后:元空间)。

  • 类将.class文件加载至方法区后,会在堆中创建一个Java.lang.Class对象,每个类都对应有一个Class对象。

2.3 数组类型的加载

数组类本身并不是由类加载器负责加载,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。数组类型(下述简称A)的生成过程:

  • 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载A的元素类型
  • JVM使用指定的元素类型和数组维度来创建新的数组类

如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定,否则数组类的可访问性将被缺省定义为public

三、连接(Linking)

3.1 验证(Verification)

验证环节的目的是保证加载的字节码是合法、合理并符合规范的。验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证以及符号引用验证等。

  • 格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中
  • 格式验证之外的验证操作将会在方法区中进行

链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查

验证的具体内容如下:

  • 格式验证:是否以魔数0XCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,字节码文件中每一个项是否都拥有正确的长度等。

  • 语义检查:JVM会进行字节码的语义检查,如果存在语义上不符合规范的,JVM不会给予验证通过。比如:

    • 是否所有的类都有父类的存在(在Java里,除了Object外,其他类都应该有父类)
    • 是否一些被定义为final的方法或者类被重写或继承了
    • 非抽象类是否实现了所有抽象方法或者接口方法
  • 字节码验证:这是验证过程中最为复杂的一个过程,JVM试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:

    • 在字节码的执行过程中,是否会跳转到一条不存在的指令
    • 函数的调用是否传递了正确类型的参数
    • 变量的赋值是不是给了正确的数据类型等
  • 符号引用的验证:此环节在解析阶段才会执行。Class文件在其常量池会通过字符串记录将要使用的其他类或者方法,验证阶段,JVM会检查这些类或者方法是否存在,以及当前类是否有权限访问这些数据。

    • 如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError
    • 如果一个方法无法被找到,则会抛出NoSuchMethodError

3.2 准备(Preparation)

准备阶段完成的工作是为类的静态变量分配内存,并将其初始化为默认值。JVM为各类型变量默认的初始值:

类型默认初始值
byte0
short0
int0
long0L
float0.0F
double0.0D
char\u0000
booleanfalse
referencenull

Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的boolean的默认值为false。

补充两点细节:

  • 准备阶段进行内存分配的仅包括静态变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中
  • 如果字段的属性表中存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值
    在这里插入图片描述
    • 对于上图中的变量a,在准备阶段JVM就会根据ConstantValue的设置将a赋值为1

3.3 解析(Resolution)

解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。

  • 符号引用(Symbolic References):以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到虚拟机内存当中
  • 直接引用(Direct References):可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,直接引用的目标必定已经在虚拟机的内存中存在

在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。

//System.out.println()对应的字节码:
invokevirtual #2 <java/io/PrintStream.println : ()V>

在这里插入图片描述

以方法为例,JVM为每个类都准备了一张方法表,当需要调用一个类的方法的时候,只要知道其在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。

《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,虚拟机实现可以根据需要自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。

四、初始化(Initialization)

初始化阶段就是执行类构造器方法<clinit>()方法的过程。此方法是编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来。

4.1 final修饰的静态变量

结论:使用final修饰的静态变量,如果直接通过字面量的方式显示赋值,不涉及到方法或构造器调用,则是在连接阶段的准备环节进行赋值,否则是初始化执行<clinit>()方法时进行赋值。

public static final int INT_CONSTANT = 10;                                // 在连接阶段的准备环节赋值
public static final int NUM1 = new Random().nextInt(10);                  // 在初始化阶段clinit>()中赋值
public static int a = 1;                                                  // 在初始化阶段<clinit>()中赋值

public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);     // 在初始化阶段<clinit>()中赋值
public static Integer INTEGER_CONSTANT2 = Integer.valueOf(100);           // 在初始化阶段<clinit>()中概值

public static final String s0 = "helloworld0";                            // 在连接阶段的准备环节赋值
public static final String s1 = new String("helloworld1");                // 在初始化阶段<clinit>()中赋值
public static String s2 = "hellowrold2";                                  // 在初始化阶段<clinit>()中赋值

4.2 <clinit>()方法的线程安全性

  • 对于<clinit>()方法的调用,JVM会在内部确保其在多线程环境中的安全性。(如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。)
  • 如果某个线程成功加载了类,阻塞等待的线程不会再执行<clinit>()方法。需要使用这个类时,JVM会直接返回给已经准备好的信息。

如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。

4.3 类的主动使用与被动使用

Java程序对类的使用分为两种:主动使用和被动使用

4.3.1 主动使用

JVM规定,一个类或接口在初次使用前,必须要进行初始化。这里指的“使用”,是指主动使用,主动使用只有下列几种情况:

  • 创建一个类的实例,比如使用new关键字,或者通过反射、克隆、反序列化。
  • 调用类的静态方法,即使用invokestatic指令。
  • 使用类(接口)的静态字段,比如使用getstatic或者putstatic指令(字段的访问和赋值)。
    • 这里的静态字段指的是需要在初始化阶段进行赋值,才算是主动引用

      public class Test {
      	public static final String a = new String("111"); //在初始化阶段<clinit>()中赋值
      	public static final String b = "111"; //在连接阶段的准备环节赋值
      }
      
      Test.a; //属于对Test类的主动使用
      Test.b; //不属于对Test类的主动使用
      
  • 初始化子类,如果其父类还没有进行过初始化,则需要先触发其父类的初始化。
    • 对于接口而言,这个规则有些特别之处:
      • 在初始化一个类时,并不会先初始化它所实现的接口
      • 在初始化一个接口时,并不会先初始化它的父接口
      • 定义了default方法的接口,如果有这个接口的实现类或子接口发生了初始化,那该接口要在其之前被初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

4.3.2 被动使用

主动使用之外的情况,均属于被动使用,被动使用不会触发类的初始化

并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化。

  • 通过子类引用父类的静态变量,不会触发子类初始化

    public class PassiveUse {
     	@Test
        public void test() {
            System.out.println(Child.num); //会触发Parent的初始化,但不会触发Child的初始化
        }
    }
    
    class Child extends Parent {
    }
    
    class Parent {
        public static int num = 1;
    }
    
  • 通过数组定义类引用,不会触发此类的初始化

    Parent[] parents= new Parent[10]; //不会触发Parent的初始化
    
  • 使用常量不会触发类或接口的初始化(因为常量在准备阶段就已经被显式赋值了)

五、类的卸载(Unloading)

在JVM中,每个类的实例都关联着代表这个类的Class对象

  • Object类中定义了getClass()方法,用于获取对象所属类的Class对象的引用
  • 每个类都有一个静态属性class,代表类所关联的Class对象

一个类所关联的Class实例与类加载器之间为双向关联关系

  • 在类加载器的内部实现中,用一个Java集合来存放所加载类所关联的Class对象
    在这里插入图片描述

  • 通过Class对象的getClassLoader()方法,能获得对应的类加载器
    在这里插入图片描述

一个类何时结束生命周期,取决于其关联的Class对象何时结束生命周期

在这里插入图片描述

  • 如果sampleCl、sampleClass、sample三个变量都置为null,则Sample对象结束生命周期,Sample类的ClassLoader对象结束生命周期,Sample类关联的Class对象也结束生命周期,Sample类在方法区内的模板数据将被卸载。

总结一下,标记方法区中一个类的模板数据“不再使用(允许被回收)”需要满足以下条件:

  • 该类所有的实例都已经被回收,Java堆中不存在该类及其任何派生子类的实例
  • 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类

六、类加载器

类加载器(ClassLoader)是JVM执行类加载机制的前提。类加载器负责将Class信息的二进制数据流读入JVM内部,转换为一个与其对应的java.lang.Class对象实例。

在这里插入图片描述

类加载器的命名空间:

  • 每个类加载器都有自己的命名空间,由该加载器及所有的父加载器所加载的类组成
  • 在同一命名空间中,不会出现全限定名相同的两个类
  • 在不同的命名空间中,有可能会出现全限定名相同的两个类

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在Java虚拟机中的唯一性。即使两个类源自同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,在JVM中也会被认为是不同的类。

JVM在启动的时候会首先创建sun.misc.Launcher类,Launcher类的构造器中设置了程序运行中需要的类加载器:

public Launcher() {
    ExtClassLoader var1;
    try {
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }
    try {
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }
    Thread.currentThread().setContextClassLoader(this.loader);
    ...
    ...
}
  • ExtClassLoader的parent是null
  • AppClassLoader的parent是ExtClassLoader
  • 当前线程的上下文ClassLoader是AppClassLoader

这里的parent并不是Java语言意义上的继承关系,而是一种包含关系!!!

6.1 类加载器的分类

JVM支持两种类型的类加载器 :引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader)

  • Java 虚拟机规范中将所有派生于抽象类java.lang.ClassLoader的类加载器都划分为自定义类加载器

在这里插入图片描述

  • 启动类加载器通过C/C++语言编写,而自定义类加载器都是由Java语言编写的
  • 虽然扩展类加载器和应用程序类加载器是由JDK开发人员编写,但也是由Java语言编写,派生于抽象类java.lang.ClassLoader,所以也被称为自定义类加载器

6.1.1 启动类加载器

  • 使用 C/C++语言实现,嵌套在JVM内部,并不继承自java.lang.ClassLoader,没有父加载器。尝试获取引导类加载器,获取到的值为 null

    ClassLoader classLoader = java.security.Provider.class.getClassLoader();//由启动类加载器加载
    System.out.println(classLoader); //打印为null
    
  • 用于加载扩展类和应用程序类加载器,并指定为他们的父类加载器

  • 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类(JAVA_HOME/jre/lib/rt.jar、sun.boot.class.path等路径下的内容)

public static void main(String[] args) {
    // 获取BootstrapclassLoader能够加载的api的路径
    URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
    for (URL element : urLs) {
        System.out.println(element.toExternalForm());
    }
}

结果如下:
在这里插入图片描述

6.1.2 扩展类加载器

  • Java 语言编写,由sun.misc.Launcher$ExtClassLoader实现,派生于ClassLoader 类
  • 父类加载器为BootstrapClassLoader
  • 从java.ext.dirs系统属性(jre/lib/ext 等)所指定的目录中加载类库,如果用户创建的JAR放在此目录下,也会由扩展类加载器加载
    在这里插入图片描述
public static void main(String[] args) {
        ClassLoader classLoader = sun.security.ec.CurveDB.class.getClassLoader();
        System.out.println(classLoader); //sun.misc. Launcher$ExtCLassLoader@1540e19d
        System.out.println(classLoader.getParent()); //null,说明父类加载器是Bootstrap ClassLoader
}   

6.1.3 应用类加载器

在这里插入图片描述

  • Java 语言编写,由sun.misc.Launcher$AppClassLoader实现,派生于ClassLoader 类
  • 父类加载器为扩展类加载器
  • 负责加载环境变量classpath或系统属性java.class.path指定路径下的类库
  • 是程序中默认的类加载器,一般来说,Java应用的类都是由它来完成加载的,通过 ClassLoader.getSystemclassLoader( )方法可以获取到该类加载器
    public static void main(String[] args) {
            ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
            System.out.println(systemClassLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2
    }
    

6.2 双亲委派机制

  • 类加载器收到类加载请求后,会先委托给父类加载器去执行,依次递归,请求最终将到达顶层的引导类加载器
  • 如果父类加载器可以完成加载,就成功返回;倘若父类加载器无法加载,子类加载器才会尝试自己去加载,这就是双亲委派机制

在这里插入图片描述

6.2.1 ClassLoader源码分析

public Class<?> loadClass(String name) throws ClassNotFoundException
  • ClassLoader的loadClass()方法用于加载名称为name的类,返回结果为java.lang.Class类的实例。该方法中实现了双亲委派机制的逻辑(剔除了部分非核心代码):

    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) {
                        c = parent.loadClass(name, false); //向上委托
                    } else {
                        c = findBootstrapClassOrNull(name); //委托给启动类加载器
                    }
                } catch (ClassNotFoundException e) {
                }
    
                if (c == null) {
                    long t1 = System.nanoTime();
                    c = findClass(name); //父加载器无法加载,尝试自己加载
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    
  • ExtClassLoader并没有重写loadClass(),而AppClassLoader虽然重写了loadClass()方法,但最终调用的还是父类的loadClass()方法,这说明两者的类加载流程均遵守双亲委派机制

protected Class<?> findClass(String name) throws ClassNotFoundException
  • 该方法查找名称为name的类,返回结果为java.lang.Class类的实例。ClassLoader类中并没有具体代码逻辑,不同ClassLoader的实现类中,该方法有不同的实现逻辑(如URLClassLoader)
protected final Class<?> defineClass(String name, byte[] b,int off,int len)
  • 将byte字节流解析成JVM能够识别的Class对象
  • 通常与findClass()方法一起使用,例如在自定义类加载器时,通常选择重写findClass()方法并编写加载规则,取得要加载类的字节码对应的字节流,然后调用defineClass()方法生成Class对象

6.2.2 双亲委派机制的优势与劣势

双亲委派机制优势

  • 避免类的重复加载(父类加载器已经加载某个类,子类加载器不会再加载),确保一个类的全局唯一性
  • 保护程序安全,防止核心API被随意篡改(比如自定义的java.lang.String加载时会委托到启动类加载器,进而会加载JDK原生的String类,自定义的String类无法加载)

双亲委派机制弊端

  • 检查类是否加载的委托过程是单向的,顶层的ClassLoader无法访问底层的ClassLoader所加载的类。

6.2.3 破坏双亲委派机制——线程上下文类加载器

双亲委派机制并不是具有强制性约束的机制。,而是Java设计者推荐给开发者的类加载器实现方式。在Java中大部分的类加载器都遵循这个机制,但也有例外的情况。
以Java的标准服务JNDI服务为例,其代码由启动类加载器来完成加载(位于rt.jar)。但JNDI需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启动类加载器是绝不可能认识、加载这些代码的,那该怎么办?

在Java平台中,通常把核心类rt.jar中提供外部服务、可由应用层自行实现的接口称为SPI

为了解决这个困境,Java的设计团队引入了一个不太优雅的设计:线程上下文类加载器(ThreadContextClassLoader)

在这里插入图片描述

  • 通过java.lang.Thread类的setContextClassLoader()方法可以设置线程上下文类加载器,如果未设置,将会从父线程中继承,如果在应用程序的全局范围内都没有设置过的话,默认就是应用程序类加载器
  • JNDI服务使用这个线程上下文类加载器去加载所需的SPI具体实现的代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打破了双亲委派机制的层次结构来逆向使用类加载器。

6.3 自定义类加载器

Java提供了抽象类java.lang.ClassLoader,用户自定义的类加载器需要继承ClassLoader类。通常有两种具体的实现:

  • 重写loadClass()方法
  • 重写findclass()方法

loadclass()方法中实现了双亲委派机制,重写这个方法可能会导致机制被破坏,容易造成问题。因此在findClass()里重写自定义类加载器的加载逻辑是更好的选择。

自定义类加载器有哪些好处?

  • 隔离加载类。比如Tomcat这类Web应用服务器,内部自定义了好几种类加载器,用于隔离不同目录下的类库。

  • 扩展加载源。比如从数据库、网络、甚至是电视机机顶盒进行加载

  • 防止源码泄漏。Java代码容易被编译和篡改,可以进行编译加密。那么类加载器也需要自定义,加入还原加密字节码的逻辑。

如果使用自定义类加载器,需要注意一个细节。在做Java类型转换时,只有两个类型由同一个类加载器所加载,才能进行类型转换,否则转换时会发生异常。

  • 8
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Tracy_hang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值