JVM-类加载相关

概述

JVM中的类加载主要分为三个阶段:加载阶段-->链接阶段(包含验证、准备、解析三个小阶段)-->初始化阶段

类加载器子系统负责从文件系统或者网络中加载Class文件,任何语言只要生成的class文件符合规范,都可以在Java虚拟机上执行,加载的类信息会存放进方法区

注意:ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine(执行引擎)决定

Java中类加载是使用双亲委派机制进行加载的,我们也可以自定义自己的类加载器,但是一般不会打破双亲委派机制

JVM支持两种类型的类加载器 。分别为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader),自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是Java虚拟机规范却没有这么定义,而是将所有派生于抽象类ClassLoader的类加载器都划分为自定义类加载器,无论类加载器的类型如何划分,在程序中我们最常见的类加载器始终只有3个:引导类加载器,扩展类加载器。系统类加载器

类的加载过程

上面也说了类的加载过程主要有三个阶段:加载阶段-->链接阶段(包含验证、准备、解析三个小阶段)-->初始化阶段,我们一个阶段一个阶段进行讲述

加载阶段

所谓加载,简而言之就是将类的字节码文件加载到机器内存中,并在内存中构建出类的原型——类模板对象

类模板对象,就是Java类在]VM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样]VM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用

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

通常会通过类的 类的全限定名(全类名)获取定义此类的二进制字节流,然后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,同时会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

在加载类时,Java虚拟机必须完成以下3件事情

  • 通过类的全名,获取类的二进制数据流。

  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)

  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口 --生成类模板

可以加载class文件的方式:
  • 从本地系统中直接加载

  • 通过网络获取,典型场景:Web Applet

  • 从zip压缩包中读取,成为日后jar、war格式的基础

  • 运行时计算生成,使用最多的是:动态代理技术

  • 由其他文件生成,典型场景:JSP应用

  • 从专有数据库中提取.class文件,比较少见

  • 从加密文件中获取,典型的防Class文件被反编译的保护措施

类模型与Class实例的位置

加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDKl.8之前:永久代;J0Kl.8及之后:元空间),并创造一个类对象,这个对象会指向方法区中的这个类的数据结构

Class 类的构造方法是私有的,只有JVM才能创建

生成的 class对象是访问类型这个类的数据结构的入门,也是我们反射的关键,通过class对象提供的接口了可以获得目标类所关联的 .class 文件中具体的数据结构(方法、字段信息等)

加载阶段还需要各种加载器,这里暂时不做过多的叙述,下面有类加载器的内容

链接阶段

验证(Verify)

类加载到系统后,就开始链接操作,验证是链接操作的第一步

  • 目的是为了保证加载的字节码文件是合法、合理并符合规范的
  • 验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查

验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等。

其中格式验证(格式检查)和加载阶段一起执行,验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中

格式验证之外的验证操作将会在方法区进行

具体说明

  • 格式验证:

    • 是否以魔数0XCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度

  • 语义检查

    • Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:

      • 是否所有的类都有父类的存在(在Java里,除了object外,其他类都应该有父类)

      • 是否一些被定义为final的方法或类被重写或继承了

      • 非抽象类是否实现了所有抽象方法或者接口方法

      • 是否存在不兼容的方法(比如方法的签名除了返回值一样之外,其他的都一样,这种方法会让虚拟机无从下手,abstract情况下,不能被final修饰)

  • 字节码验证:

    • Java虚拟机还会进行字节码验证。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行,比如:

      • 在字节码的执行过程中,是否会跳转到一条不存在的指令

      • 函数的调用是否传递了正确类型的参数

      • 变量的赋值是不是给了正确的数据类型等

    • 栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的

  • 符号引用的验证:

    • 校验器还将进符号引用的验证

    • Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者是方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError

    • 此阶段在解析环节才会执行

      • 在解析阶段的时候,会将符号引用转化为直接引用,符号引用的验证就是在这个阶段进行的

准备(Prepare)

当一个类验证通过时,虚拟机就会进入准备阶段,在这个阶段,虚拟机就会为这个类分配相应的内存空间,并设置默认初始值

  • 简而言之就是为静态变量(常量不是在此阶段分配内存的)分配内存,并将其初始化为默认值
    • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;
      • 一般情况:static final修饰的基本数据类型、字符串类型字面量会在准备阶段赋值
      • 基本数据类型如果是非final修饰,在准备环节默认赋初始化的值
      • 基本数据类型如果是final修饰,在准备环节给显式赋值
      • 引用数据类型,如果是非final修饰,在准备环节默认赋初始化的值
      • 引用数据类型如果是final修饰,在初始化阶段显式赋值--<clinit> 方法中赋值
    • 不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
  • 在这个过程,除了final修饰的static,其他的成员成员变量或者是类变量,不管你有没有赋初值,都会给你赋予默认值
解析(Resolve)

将类、接口、字段和方法的符号引用转为直接引用(类、接口、字段和方法运行时需要的真实的内存地址)

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置。

Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法

通过解析,符号引用就可以转变为目标方法在类中方发表中的位置,从而使得方法被方法调用

注意:

        解析环节一般是在初始化阶段之后进行的

初始化阶段

初始化阶段简而言之就是为类的静态变量赋予正确的初始值

类的初始化是类装载的最后一个阶段,如果前面的步骤没有问题,那么表示类可以装载到系统中。此时类才开始执行Java字节码文件

到了初始化阶段才开始真正的执行类中定义的Java代码

  • 初始化最重要的工作是执行类的初始化方法 <clinit>() 方法---类初始化代码

    • <clinit>()不是类的构造方法

    • 这个方法仅能由编译器生成并由JVM调用,它是由类静态成员的赋值语句和static语句块合并而成的

  • 此方法不需定义,是javac编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • 若该类具有父类,JVM会保证子类的<clinit>()执行前,父类的<clinit>()已经执行完毕

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

    • 保证<clinit>()只会加载一次

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

    • 如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行<clinit>()方法了,那么,当需要使用这个类时,虚拟机会直接返回给它已经准备好的信息

  • 这个阶段会调用我们的静态代码块以及静态变量和静态方法

类变量显示赋值的说明

在链接阶段的准备环节赋值的情况:

  • 对于基本数据类型的字段来说,如果使用static final修饰,显式赋值通常是在链接阶段的准备环节进行(直接赋值常量,而非调用方法)

  • 对于String来说,如果使用字面量的方式赋值,使用static final修饰的话,则显式赋值通常是在链接阶段的准备环节进行

在初始化阶段<clinit>()中赋值的情况:

  • 排除链接阶段赋值情况之外的情况。

类的初始化情况:主动使用vs被动使用

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

主动使用和被动使用影响的是会不会被初始化(就是类加载阶段的初始化),会不会调用 <clinit>() 方法

Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化,这里指的“使用”,是指主动使用,主动使用只有下列几种情况(如果出现如下的情况,则会对类进行初始化操作,而初始化操作之前的加载、验证、准备已经完成):

  • 实例化:当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化
  • 静态方法:当调用类的静态方法
  • 静态字段:当使用类、接口的静态字段时(final修饰特殊考虑
  • 反射:当使用java.lang.reflect包中的方法反射类的方法时。
  • 继承:初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
    • 当Java虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口
    • 初始化一个类时,并不会先初始化它所实现的接口,初始化一个接口时,并不会先初始化它的父接口
  • .default方法:如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
  • main方法:当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  • MethodHandle(这是 java.lang.invoke 下面的一个类)
    • 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类(涉及解析REF getStatic、REF_putStatic、REF invokeStatic方法句柄对应的类)

除了以上8种情况以外的使用,就属于类的被动使用

类加载器(ClassLoader )的分类

ClassLoader的作用:ClassLoader 是 Java 的核心组件,所有的 Class 都是由 ClassLoader 进行加载的,classLoader 负责通过各种方式将 Class 信息的二进制数据流读入 JVM 内部,转换为一个与目标类对应的 java.lang.Class 对象实例,然后交给 Java 虚拟机进行链接、初始化等操作

ClassLoader 在整个装载阶段,只能影响到类的加载,而无法通过 ClassLoader 去改变类的链接和初始化行为

在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。

类加载的分类

Class文件的显式加载与隐式加载的方式是指JVM加载Class文件到内存的方式,类的加载的分类有显式加载 和 隐式加载两种方式

显式加载指的是在代码中通过调用ClassLoader加载Class对象,如直接使用Class.forName(name)或者this.getClass().getClassLoader().loadClass()加载Class对象

隐式加载则是不直接在代码中调用ClassLoader的方法加载Class对象,而是通过虚拟机字段加载到内存中,如在加载某个类的class文件的时候,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中

日常开发中以上两种方式一般会混合使用

类的命名空间
  • 何为类的唯一性?

    • 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确认其在 Java 虚拟机中的唯一性

    • 每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使这两个类源自同一个 Class 文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相同

  • 命名空间
    • 每个类加载器都有自己的命名空间,命名空间由该加载器所有的父加载器所加载的类组成

    • 在同一命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类
      • 就是同一个类加载器只会加载某个类一次
    • 类加载器 + 类的全限定名 确定 类的唯一性

类的加载器的分类

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

从概念上来讲,自定义类加载器一般指的是程序中由开发人员自定义的一类类加载器,但是 Java 虚拟机规范却没有这么定义,而是将所有派生于抽象类 ClassLoader 的类加载器都划分为自定义类加载器。无论类加载器的类型如何划分,在程序中我们最常见的类加载器结构主要是如下情况

除了顶层的启动类加载器外,其余的类加载器都应当有自己的"父类"加载器

不同类加载器看似是继承(Inheritance)关系,实际上是包含关系,在下层加载器中,包含着上层加载器的引用,下面是ClassLoader 的源码

class ClassLoader {
    ClassLoader parent; //父类加载器
    public ClassLoader(ClassLoader parent) {
        this.parent = parent;
    }
}

class ParentClassLoader extends ClassLoader {
    public ParentClassLoader(ClassLoader parent) {
        super(parent);
    }
}

class ChildClassLoader extends ClassLoader {
    public ChildClassLoader(ClassLoader parent) {
        //parent = new ParentClassLoader();
        super(parent);
    }
}

最常见的类加载器有3个:引导类加载器,扩展类加载器。系统类加载器

对于用户自定义类的加载器是:默认使用系统类加载器进行加载,Java的核心类库使用引导类加载器进行加载

怎么查看当前类的类加载器

ClassLoader classLoader = 类名.class.getClassLoader();
System.out.println(classLoader);
启动类加载器(引导类加载器,Bootstrap ClassLoader)
  • 这个类加载使用C/C++语言实现的,嵌套在JVM内部。

  • 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路径下的内容),用于提供JVM自身需要的类

  • 并不继承自java.lang.ClassLoader,没有父加载器。

    • 因为是使用c/c++实现的,肯定不能继承ClassLoader

  • 可以加载扩展类加载器和应用程序类加载器,并指定为他们的父类加载器。

    • 扩展类加载器和应用程序类加载器就是用启动类加载器进行加载的

    • 扩展类加载器和应用程序类加载器都间接继承了ClassLoader

  • 出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类

可以通过下面代码获取启动类加载加载api的路径

URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
for (URL url : urLs) {
    System.out.println(url.toExternalForm());
}
// 运行结果
file:/D:/xxx/jdk/jre/lib/resources.jar
file:/D:/xxx/jdk/jre/lib/rt.jar
file:/D:/xxx/jdk/jre/lib/sunrsasign.jar
file:/D:/xxx/jdk/jre/lib/jsse.jar
file:/D:/xxx/jdk/jre/lib/jce.jar
file:/D:/xxx/jdk/jre/lib/charsets.jar
file:/D:/xxx/jdk/jre/lib/jfr.jar
file:/D:/xxx/jdk/jre/classes
扩展类加载器(Extension ClassLoader)
  • Java语言编写,由sun.misc.Launcher$ExtClassLoader实现。

  • 派生于ClassLoader类

  • 父类加载器为启动类加载器

  • 从java.ext.dirs系统属性所指定的目录中加载类库,或从JDK的安装目录的jre/1ib/ext子目录(扩展目录)下加载类库。

    • 如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。

  • 可以通过下面代码获取扩展类加载器加载api的路径

String pathStr = System.getProperty("java.ext.dirs");
for (String path : pathStr.split(";")) {
	System.out.println(path);
}
应用程序类加载器(系统类加载器,AppClassLoader)
  • java语言编写,由sun.misc.LaunchersAppClassLoader实现

  • 派生于ClassLoader类

  • 父类加载器为扩展类加载器

  • 它负责加载环境变量classpath或系统属性java.class.path指定路径下的类库

    • 他一般用来加载自定义的类或者是系统属性java.class.path指定路径下的类库

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

  • 通过ClassLoader#getSystemclassLoader() 方法可以获取到该类加载器

双亲委派机制

Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象。而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式,即把请求交由父类处理,它是一种任务委派模式

工作原理

  • 如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行;

  • 如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器;

  • 如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式。

为什么有双亲委派机制

  • 避免类的重复加载

  • 保护程序安全,防止核心API被随意篡改

    • 自定义类:java.lang.String

    • 自定义类:java.lang.ShkStart(报错:阻止创建 java.lang开头的类)

用户自定义类加载器

为什么要自定义类加载器

  • 隔离加载类

  • 修改类加载的方式

  • 扩展加载源

  • 防止源码泄漏

用户自定义类加载器实现步骤:

  1. 开发人员可以通过继承抽象类ava.lang.ClassLoader类的方式,实现自己的类加载器,以满足一些特殊的需求

  2. 在JDK1.2之前,在自定义类加载器时,总会去继承ClassLoader类并重写loadClass() 方法,从而实现自定义的类加载类,但是在JDK1.2之后已不再建议用户去覆盖loadclass() 方法,而是建议把自定义的类加载逻辑写在findClass()方法中

  3. 在编写自定义类加载器时,如果没有太过于复杂的需求,可以直接继承URLClassLoader类,这样就可以避免自己去编写findClass() 方法及其获取字节码流的方式,使自定义类加载器编写更加简洁。

自定义类加载器实现

用户通过定制自己的类加载器,这样可以重新定义类的加载规则,以便实现一些自定义的处理逻辑

实现方式

  • Java 提供了抽象类 java.lang.ClassLoader,所有用户自定义的类加载器都应该继承 ClassLoader 类

  • 在自定义 ClassLoader 的子类时候,我们常见的会有两种做法:

    • 方式一:重写 loadClass() 方法

    • 方式二:重写 findClass() 方法 --> 推荐使用

  • 对比

    • 这两种方法本质上差不多,毕竟 loadClass() 也会调用 findClass(),但是从逻辑上讲我们最好不要直接修改 loadClass() 的内部逻辑。建议的做法是只在 findClass() 里重写自定义类的加载方法,根据参数指定类的名字,返回对应的 Class 对象的引用

  • loadClass() 这个方法是实现双亲委派模型逻辑的地方,擅自修改这个方法会导致模型被破坏,容易造成问题。因此我们最好是在双亲委派模型框架内进行小范围的改动,不破坏原有的稳定结构同时,也避免了自己重写 loadClass() 方法的过程中必须写双亲委托的重复代码,从代码的复用性来看,不直接修改这个方法始终是比较好的选择

  • 当编写好自定义类加载器后,便可以在程序中调用 loadClass() 方法来实现类加载操作

说明

  • 其父类加载器是系统类加载器

  • JVM 中的所有类加载都会使用 java.lang.ClassLoader.loadClass(String) 接口(自定义类加载器并重写 java.lang.ClassLoader.loadClass(String) 接口的除外),连 JDK 的核心类库也不能例外

package com.yxl.jvm.loader;

import lombok.SneakyThrows;

import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;

/**
 * @author yanxl
 * @Description TODO
 * @Date 2023/2/7 11:39
 */
public class customLoader extends ClassLoader {

    /**
     * 类加载位置
     */
    private String rootDir;

    /**
     * 构造器:指定类加载的加载位置
     */
    public customLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    /**
     * 创建自定义加载器
     *
     * @param name 类名
     * @return 返回加载后的ClassLoader
     * @throws ClassNotFoundException ClassNotFoundException
     */
    @SneakyThrows
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> cls = findLoadedClass(name);
        if (cls != null) {
            //此类已经被加载过了
            return cls;
        }
        ClassLoader parent = this.getParent();
        try {
            //让父类加载器先加载
            cls = parent.loadClass(name);
        } catch (Exception e) {
            System.out.println("父类加载失败....!");
        }
        if (cls != null) {
            // 父类加载器帮组自己加载成功了
            return cls;
        }
        try {
            byte[] result = getClassFromCustomerPath(name);
            if (result == null) {
                throw new FileNotFoundException();
            }
            return defineClass(name, result, 0, result.length);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        throw new ClassNotFoundException();
    }

    /**
     * 读取文件中的数据
     *
     * @param name 类名
     * @return return
     */
    private byte[] getClassFromCustomerPath(String name) {
        // 从自定义路径中加载指定类
        String path = rootDir + "/" + name.replace('.', '/') + ".class";
        try (FileInputStream is = new FileInputStream(path);) {
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int len = 0;
            while ((len = is.read(buffer)) != -1) {
                baos.write(buffer, 0, len);
            }
            baos.flush();

            byte[] bytes = baos.toByteArray();
            // 如果字节码文件有加密的话,需要将字节码文件解密后转化为二进制文件,再返回
            return bytes;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }


}

结束语:

        还有很多没有写完,下一篇应该是运行时数据区相关的东西

  • 18
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值