类加载子系统

类加载子系统

一、类加载的过程

​ 类加载的过程大致可以分为三步:加载(Loading) -> 链接(Linking) -> 初始化(Initialization)

​ 生命周期:

加载 .class 文件的方式(加载源)

  • 从本地系统中直接加载
  • 通过网络获取,场景: Web Applet
  • 从 zip 压缩包获取,jar包,war包
  • 运行时计算生成,场景:动态代理技术
  • 其它文件生成,场景:JSP技术
  • 从专有数据库中提取 .class 文件,使用较少
  • 从加密的文件中获取,典型的防止 class 文件被反编译的安全措施

1) 类加载过程 -- 加载(Loading)

​ 加载是类加载(Class Loading)过程的第一步,加载过程靠类加载器实现的,包括自定义类加载器。

类加载过程中,JVM主要做三件事情:

  • 通过类的全限定类名获取此类的二进制字节流(class文件)

    程序运行过程中,发现一个类未被加载,未满足初始化条件时,就会根据这个类的全限定类名找到这个类的二进制字节流,开始加载过程

  • 将这个字节流的静态存储结构转化为付费区的运行时数据结构

  • 在内存中创建一个 java.lang.class 对象,作为该类的各种数据访问入口

    所有对于该类的访问都是通过 这个对象 ,这个对象是提供给外界访问的接口

1. 类和数组加载的区别

​ 数组也是一个类型叫 数组类型

String[] str = new String[10];

在以上代码中,数组元素的类型是 String , 而数组的类型是 [Ljava.lang.String

数组类和非数组类的加载方式是不同的:

  • 非数组类:由 类加载器来完成加载
  • 数组类: 数组类本身不通过类加载器类创建,它由 Java虚拟机直接创建 ,但是数组类于类加载器又有着十分密切的关系,因为数组的元素类型最终是要依赖类加载器来创建

2. 加载过程中注意点

  • 类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机 自己定义的,虚拟机规范并没有指定。

  • 内存中创建的给外界访问的 class 对象存放的位置,jvm规范并未有指定位置。

    既然是对象就应该存放在 java堆中?但是jvm规范并没有给出限制,不同的虚拟机根据自己的需求存放的位置不同

    HotSpot 虚拟机将 class 对象存放在 方法区

  • 加载阶段和连接阶段是交叉的,类加载的过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。

    ​ 也就是说, 类加载过程中,必须按照如下顺序开始:

    加载 -> 链接 -> 初始化

    但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉。

2) 类加载过程 -- 链接(Linking)

链接阶段分为三步: 验证(Verify)、准备(Prepare)、解析(Resolve)

1. 验证(Verify)

​ 验证阶段比较耗时,它非常重要但不一定必要(因为对程序运行期没有影响),如果所运行的代码已经被 反复使用和验证过,那么可以使用 -Xverify:none 参数关闭,以缩短类加载时间

​ 验证的目的在于确保class文件的字节流中包含信息符合当前虚拟机的规范要求,保证被加载类的正确性,不会危害虚拟机的安全

验证的必要性:虽然Java语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换 成任意类型、避免跳转到不存在的代码行.也就是说,Java语言的安全性是通过编译器来保证的,是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进 制字节流是哪来的,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么虚拟机就无法确保该二进制字节流是安全的。因为虚拟机规范中没有限制二进制字节流的来源,所以为了防止字节流中有安全问题,需要验证!

​ 验证的过程:

​ 主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证

  • 文件格式验证

    验证字节流是否符合Class文件格式的规范,并且能被当前的虚拟机处理.

    是基于二进制字节流进行的,只有通过本阶段验证,才被允许存到方法区

    后面的三个验证阶段都是基于方法区的存储结构进行,不会再直接操作字节流

  • 元数据验证

    对字节码描述信息进行语义分析,确保符合Java语法规范.

  • 字节码验证

    这个阶段是验证过程的最复杂的一个阶段

    这个阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件

    字节码验证将对类的方法进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机的事,一 个类方法体的字节码没有通过字节码验证,那一定有问题,但若一个方法通过了验证,也不能说明 它一定安全。

  • 符号引用验证

    发生在JVM将符号引用转化为直接引用的时候,这个转化动作发生在解析阶段,对类自身以外的信 息进行匹配校验,确保解析能正常执行。

印证:

1.加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区
2.而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区
也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特
定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作

2. 准备(Prepare)

​ 仅仅为类变量(即static修饰的字段变量)分配内存并且设置该类变量的初始值即零值

​ 这里不包含用 final 修饰的 static,因为 final 在编译的时候就会分配了,准备阶段会显示初始化

​ 这里也不会为实例变量分配初始化。类变量会分配在方法区中,而实例变量会和对象一起分配到Java堆中

准备阶段主要完成两件事:

  • 为已在方法区中的类的静态成员变量分配内存
  • 为静态成员变量设置初始值,初始值为0、false、null等

例:

public static int x = 1000;
/*
	实际上变量x在准备阶段过后的初始值为0,而不是1000
	将x赋值为1000的putstatic指令是程序被编译后,存放于类构造器<clinit>方法之中
*/

// 声明为
public static final int x = 1000;
/*
	在编译阶段会为x生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue属性将x赋值为1000
*/

3. 解析(Resolve)

​ 将常量池中的符号引用转换为直接引用的过程

​ 解析操作往往会伴随着 JVM 在执行完初始化之后再执行

​ 符号引用就是一组符号来描述所引用的目标,符号引用的字面量形式明确定义在 《Java虚拟机规范》的class文件格式中,直接引用就是直接指向目标的指针,相对偏移量或一个间接定位到目标的句柄。

​ 解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_infoCONSTANT_InterfaceMethodref_info 四种常量类型。

  • 类或接口的解析

    判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。

  • 字段解析

    对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段, 如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束;(优先从接口来, 然后是继承的父类,理论上是按照这个顺序来执行,在实际应用中,虚拟机的编译器实现 可能要比上述规范要求的更严格一些。同名字段同时出现在该类的接口和父类中,或同 时在自己或父类的接口中出现,编译器可能会拒绝编译)

  • 类方法解析

    对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步 骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。

  • 接口方法解析

    与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了。

3) 类加载过程 -- 初始化(Initialization)

​ 初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码 (初始化成为 代码设定的默认值), 在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源

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

注意:<clinit>() 与 <init>() 的区别:

  • <clinit>()是类构造器方法,<init>()是对象构造器方法
  • 执行的时机不同:程序执行 new 一个对象调用 construtor 方法时调用 init 方法;而 JVM 在进行类加载过程中初始化时会调用 类的构造器方法 clinit
  • init 是 Instance 实例解析器,对非静态变量进行解析初始化,clinit 是类构造器对静态变量,静态代码块进行解析初始化
  • clinit 一定优先于 init 执行

​ 其实初始化过程就是调用类初始化方法的过程,完成对static修饰的类变量的手动赋值还有主动调用静态代码块

​ 若是初始化的类拥有父类, JVM 保证子类的 () 方法执行前,父类的 () 已经执行完毕,就是说,在类加载时,若是该类有父类就会先加载其父类

初始化过程注意点:

  • 方法是编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收 集的顺序是由语句在源文件中出现的顺序所决定的

  • 静态代码块只能访问到出现在静态代码块之前的变量,定义在它之后的变量,在前面的静态语句块 可以赋值,但是不能访问

  • 实例构造器需要显式调用父类构造函数,而类的不需要调用父类的类构造函数,虚拟机会确 保子类的方法执行前已经执行完毕父类的方法.因此在JVM中第一个被执行的方法的类肯定是 java.lang.Object.

  • 如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会为此类生成() 方法

  • 接口也需要通过方法为接口中定义的静态成员变量显示初始化

    接口中不能使用静态代码块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成方法.

    不同的是,执行接口的方法不需要先执行父接口的方法.只有当父接口中的静态成员变量被使用到 时才会执行父接口的方法(()

  • 虚拟机必须保证一个类的 () 方法在多线程下被同步加锁, 当多条线程同时去初始化一个类 时,只会有一个线程去执行该类的方法,其它线程都被阻塞等待,直到活动线程执行方法完毕, 只要有一个线程去执行了该类的初始化方法,其它线程即使是被唤醒了也不会再去执行该方法了,因为同一个类加载器 下,一个类型只会初始化一次.

静态内部类的单例实现:

public class Student {
	private Student() {}
/*
	此处使用一个内部类来维护单例 JVM在类加载的时候,是互斥的,所以可以由此保证线程安全问题
*/
	private static class SingletonFactory {
		private static Student student = new Student();
	}
/* 获取实例 */
    public static Student getSingletonInstance() {
    	return SingletonFactory.student;
    }
}

4) 类加载的时机

​ 虚拟机规范并没有强制性的约束类声明时候开始加载,对于其它大部分阶段究竟何时开始虚拟机规范也 都没有进行规范,这些都是交由虚拟机的具体实现来把握。所以不同的虚拟机它们开始的时机可能是不同的。

​ 但是对于初始化却严格的规定了有且只有四种情况必须先对类进行“初始化”(加载,验证,准 备自然需要在初始化之前完成):

  • 遇到 newgetstaticputstaticinvokestatic 这四条指令时,如果对应的类没有初始 化,则要对对应的类先进行初始化。

    这四个指令的场景分别是

    • new 关键字实例化对象
    • 读取或设置一个类的静态字段(读取被final修饰,已在编译器把结果放入常量池的静态字 段除外) ;
    • 调用类的静态方法时
  • 使用 java.lang.reflect 包方法时对类进行反射调用的时候

  • . 初始化一个类的时候发现其父类还没初始化,要先初始化其父类

  • 当虚拟机开始启动时,用户需要指定一个主类,虚拟机会先执行这个主类的初始化

二、类加载器

  • 启动类加载器(Bootstrap ClassLoader):
    • 负责加载 Java的核心类库,JAVA_HOME\lib 目录中的类(rt.jar,resources.jar或sun.boot.class.path路径下的内容),由于提供 jvm 自身需要的类
    • 由C++实现,不是ClassLoader子类 ,没有父类加载器,嵌套在 JVM 内部
    • 出与安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun等开头的类
  • 扩展类加载器(Extension ClassLoader):
    • 负责加载 JAVA_HOME\lib\ext 目录中的
    • 或通过java.ext.dirs系统变量指定路径中的类库。
  • 应用程序类加载器(Application ClassLoader):
    • 负责加载用户路径(classpath)上的类库。
public class ClassLoaderTest {
    public static void main(String[] args) {
        // 应用程序类加载器
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println("应用程序类加载器:" + contextClassLoader); //sun.misc.Launcher$AppClassLoader@18b4aac2

        // 系统类加载器
        System.out.println("系统类加载器:" + contextClassLoader.getParent());  // sun.misc.Launcher$ExtClassLoader@1b6d3586

        // 启动类加载器2
        // 因为启动类加载器并不是 ClassLoader 类的子类,它的底层是由 c++ 实现的,所以这里返回的是 null
        System.out.println("启动类加载器:" + contextClassLoader.getParent().getParent()); // null
    }
}

用户自定义类加载器:

  • 在Java的日常应用开发中,类的加载几乎是由上述三种类加载器相互配合执行的,在必要时,我们可以使用自定义类加载器,来定制类的加载方式。

  • 为什么要自定义类加载器

    • 隔离加载类
    • 修改类的加载方式
    • 防止源码泄露
  • 自定义类加载器步骤:

    (1)继承ClassLoader

    (2)重写findClass() 方法

    (3)调用defineClass() 方法

JVM的类加载是通过ClassLoader及其子类来完成的,ClassLoader 类是一个抽象接口其后所以的类加载器都继承自 ClassLoader 类(不包括启动类加载器)

类的层次关系和加载顺序可以由下图来描述:

​ 加载过程中会先检查类是否被已加载,检查顺序是自底向上,只要某个classloader已加载就视为已加载此类,保证此类只所有 ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类

三、双亲委派机制

JVM通过双亲委派模型进行类的加载

1) 工作原理

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

​ 如果父类加载器还存在父类加载器,则会继续向上委托,依次递归,最终达到最顶层的启动类加载器

​ 如果父类加载器能完成类的加载就成功返回,如果不能就往下递归传递给子类加载器加载,这就是双亲委派机制

2) 为什么使用双亲委派机制?

  • 因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

  • 保护程序安全,防止 API 被随意篡改 (自定义类:java.lang.String 等)

    String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用 户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索 类的默认算法。

3) 如何判定两个class是否相同?

​ jvm 判断两个class 对象 是否相同,存在两个必要条件:

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

4) 破坏双亲委派机制

为什么需要破坏双亲委派?

​ 因为在某些情况下父类加载器需要加载的class文件由于受到加载范围的限制,父类加载器无法加载到 需要的文件,这个时候就需要委托子类加载器进行加载。而按照双亲委派模式的话,是子类委托父类加载器去加载class文件。这个时候需要破坏双亲委派模式 才能加载成功父类加载器需要的类。也就是说父类会委托子类去加载它需要的class文件。

四、沙箱安全机制

​ 自定义 String 类,但是在加载自定义的 String 类是会率先使用引导类加载器加载,而引导类加载器加载过程中会先加载 jdk 中自带的文件(rt.jar 包中的 java.lang.String.class),这个时候运行自定义String类的main的结果就会报错:没有main方法;报错的原因就是类加载器加载的是 rt.jar 包中的String类,这样就保证了对Java核心源代码的保护,这就是沙箱安全机制

五、类加载器的引用

​ jvm 必须知道一个类使用启动类加载器加载的还是用户类加载器加载的,如果是用户类加载器加载的,jvm 会将这个类加载器的引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用时,jvm需要保证这两个类型的类加载器式相同的

六、 类的主动使用于被动使用

​ Java查询所对类的使用方式分为:主动使用和被动使用

主动使用,分为七种方式:

  • 创建类的实例
  • 访问某一个类或者接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如:Class.forName("java.lang.String"))
  • 初始化一个类的子类
  • Java虚拟机启动时别标明为子类(如:Java Test类、main方法所在的类)
  • jdk1.7开始提供动态语言的支持相关(很少使用)
posted @   Allure小新  阅读(43)  评论(0编辑  收藏  举报
编辑推荐:
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示