计算机系统应用教程网站

网站首页 > 技术文章 正文

Java类加载机制,你或许不知道的奥秘

btikc 2024-11-02 11:09:02 技术文章 3 ℃ 0 评论

类加载器(Class Loader)

虚拟机设计团队把加载动作放到JVM外部实现,以便让应用程序决定如何获取所需的类,JVM提供了3种类加载器:

  • 启动类加载器(Bootstrap ClassLoader)

不继承classLoader,属于虚拟机的一部分;负责加载原生代码实现的Java核心库,包括加载JAVA_HOME中jre/lib/rt.jar里所有的 class;或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。

  • 扩展类加载器(Extension ClassLoader)

负责在JVM中扩展库目录中去寻找加载Java扩展库,包括JAVA_HOME中jre/lib/ext/目录中的,或通过java.ext.dirs系统变量指定路径中的类库。

  • 应用程序类加载器(Application ClassLoader)

ClassLoader.getSystemClassLoader(),负责加载用户路径(Java类路径classpath)中的类。

补充:

JVM通过双亲委派模型进行类的加载,具体如下:

当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务。

采用双亲委派的一个好处是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。

双亲委派模式类加载好处:

  • 避免类的重复加载,同一个类只加载一次

Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。

  • 安全因素

java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。

可能会想,如果在classpath路径下自定义一个名为java.lang.SimpleInteger类(该类是胡编的)呢?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出如下异常

java.lang.SecurityException: Prohibited package name: java.lang


类加载(Class Loading)

类从被加载到JVM中开始,到卸载为止,整个生命周期包括:

加载、链接Linking(验证、准备、解析)、初始化、使用和卸载七个阶段。

其中类加载过程:

加载、链接Linking(验证、准备、解析)和初始化五个阶段。

  • 加载(Loading)

类加载器加载字节码文件.class的信息到内存。即有硬盘到内存的迁移。

类加载阶段就是由类加载器负责根据一个类的全类名来读取此类的二进制字节流到JVM内部,并存储在运行时内存区的方法区,然后在堆区创建一个代表此类java.lang.Class对象实例,用来封装类在方法区内的数据结构,并且通过这个Class对象,可以作为方法区中该类的各种数据的访问入口。

三件事:

1、通过类的全类名来定位并读取Class文件的二进制字节流到JVM。

2、根据字节流所代表的静态存储结构,按照JVM规范转化为运行时数据结构格式,并存储在方法区

3、在Java堆中生成一个代表这个类的java.lang.Class对象

  • 链接(Linking)

链接阶段要做的是将加载到JVM中的二进制字节流的类数据信息合并到JVM的运行时状态中,经由验证、准备和解析三个阶段。

  • 验证(Verification)

验证类数据信息是否符合JVM规范,是否是一个有效的字节码文件,验证内容涵盖了类数据信息的格式验证语义分析、操作验证、元数据验证等等。

  • 准备(Preparation)

类中的所有静态变量分配内存空间并初始化为相应类型的默认值;被final修饰的静态变量,会直接赋予原值;类字段的字段属性表中存在ConstantValue属性,则在准备阶段,其值就是ConstantValue的值

即将类静态变量分配到内存,并将其初始化为相应类型的默认值

给常量分配内存并设置值

  • 解析

把类型中符号引用转换为直接引用。即将符号引用转换为具体的地址信息。虚拟机常量池内的符号引用替换为直接引用的过程。

  • 初始化(Initialization)

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由JVM主导。到了初始阶段,才开始真正执行类中定义的Java程序代码。

在linking的准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化(Initialization)阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类和接口初始化方法<clinit>()方法的过程。

将类的静态变量,静态语句块、赋予正确的初始化值,按照从上到下的顺序,并且静态变量优先于静态块(<clinit>)

  • 使用(Using)

即调用<init>方法实例化对象并使用对象的职责。

  • 卸载(Unloading)

当代表类的Class对象不再被引用时,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的生命周期。

一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期

注:由java虚拟机自带的三种类加载加载的类在虚拟机的整个生命周期中是不会被卸载的,由用户自定义的类加载器所加载的类才可以被卸载


示例一:

输出结果:

分析:

在类的链接(Linking)阶段中的准备阶段,初始化常量和静态变量。此处Singleton类中,INSTANCE初始化为null,COUNT_01,COUNT_02初始化为0;而在类的初始化(Initialization)阶段, 将类的静态变量,静态语句块、赋予正确的初始化值,按照从上到下的顺序;即先初始化INSTANCE变量,调用Singleton的构造器,分别对COUNT_01,COUNT_02自增1,都变为1;然后COUNT_01初始化为程序员设置的值,此时COUNT_01未设置新值,保持1不变,而COUNT_02根据程序,设置为2。最终输出结果为1,2。


示例二:

输出结果:

分析:

在类的链接(Linking)阶段中的准备阶段,初始化常量和静态变量。此处Singleton类中,COUNT_01,COUNT_02初始化为0,INSTANCE初始化为null,;而在类的初始化(Initialization)阶段, 将类的静态变量,静态语句块、赋予正确的初始化值,按照从上到下的顺序;即先初始化COUNT_01初始化为程序员设置的值,此时COUNT_01未设置新值,保持0不变;而COUNT_02根据程序,初始化设置为2;最后初始化INSTANCE变量,调用Singleton的构造器,分别对COUNT_01,COUNT_02自增1,最终,COUNT_01由0自增1变为1,COUNT_02由2自增1变为3。


扩展:

Java虚拟机的class文件结构:

可能出现在class文件中的两种编译器产生的方法是:实例初始化方法(名为<init>)和类与接口初始化方法(名为<clinit>)。


init和clinit区别:

①init和clinit方法执行时机不同

虚拟机在装载一个类初始化的时候调用的(clinit)。另一个是在类实例化时调用的(init)

②init和clinit方法执行目的不同

init is the (or one of the) constructor(s) for the instance, and non-static field initialization.

clinit are the static initialization blocks for the class, and static field initialization.

init()是实例初始化方法,对非静态变量初始化

clinit是类和接口初始化方法,对静态域进行初始化。

注意:接口中的属性都是static final类型的常量,因此在准备阶段就已经初始化。

<clinit>()方法的执行规则:

1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。

备注:

为什么静态语句块中,定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问?

类加载过程中,在链接的准备阶段,会将类中的静态变量分配内存空间,而后所以在初始化阶段,静态语句块中,定义在它之后的变量,在前面的静态语句块中可以赋值。

2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。

但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表