Java虚拟机:类的加载机制

打印 上一主题 下一主题

主题 911|帖子 911|积分 2733

大家好,我是栗筝i,这篇文章是我的 “栗筝i 的 Java 技术栈” 专栏的第 034 篇文章,在 “栗筝i 的 Java 技术栈” 这个专栏中我会持续为大家更新 Java 技术相关全套技术栈内容。专栏的重要目标是已经有肯定 Java 开发履历,并希望进一步美满自己对整个 Java 技术体系来充实自己的技术栈的同学。与此同时,本专栏的所有文章,也都会准备充足的代码示例和美满的知识点梳理,因此也十分适合零基础的小白和要准备工作面试的同学学习。固然,我也会在必要的时间举行相关技术深度的技术解读,相信即使是拥有多年 Java 开发履历的从业者和大佬们也会有所收获并找到乐趣。
  –
  类加载机制是 JVM 核心功能之一,也是明白 Java 应用步伐运行过程的关键。类是如何从字节码被加载到内存中,并终极执行的?这个过程包罗了哪些关键步调?在本篇文章中,我们将详细解析 JVM 的类加载机制,包括类加载器的类型、双亲委派模子及其作用,帮助你深入明白 Java 步伐从编译到执行的整个生命周期。
  

  

1、Java类的加载机制

Java 虚拟机把形貌类的数据从 Class 文件(‘.class’ 文件)中加载到内存,并对数据举行校验、转换解析和初始化,终极形成可以被虚拟机直接使用的 Java 类型,这个过程被称 Java 类的加载机制。
通俗的讲:当我们去 New 一个对象的时间,首先要保证,这个对象的 Class 文件,已经存在于内存之中。至于为什么我们在 New 的时间不必要去做相应加载内粗的动作?是因为 JVM 自带了类加载器的功能,我们在 New 一个对象的时间,JVM 会去判断内存中是否存在这个 Class 类,如果存在的话,就不用加载;如果不存在的话,就会举行主动加载,将 Class 文件读取至内存中。
在 Java 中,类型的加载、连接和初始化过程都是在步伐运行期间完成的,这种战略让 Java 举行提前编译会面临额外的困难,也会让类加载时轻微增长一些性能开销, 但是却为 Java 应用提供了极高的扩展性和灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
比方,编写一个面向接口的应用步伐,可以等到运行时再指定实在际的实现类,从最基础的 Applet、JSP 到相对复杂的 OSGi 技术,都依赖着 Java 语言运行期类加载才 得以诞生。
加载 Class 文件的方式:


  • 从本地系统中直接加载;
  • 通过网络下载 Class 文件;
  • 从 zip、jar 等归档文件中加载 Class 文件;
  • 从专有数据库中提取 Class 文件;
  • 将 Java 源文件动态编译为 Class 文件;
  • 由其他文件生成。

2、Java类的加载时机

2.1、类的加载过程

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会履历:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,此中验证、准备、解析三个部分统称 为连接(Linking)。这七个阶段的发生顺序如下图所示:
Ps:加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按 照这种顺序按部就班地开始,而解析阶段则不肯定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持 Java 语言的运行时绑定特性(也称为动态绑定或晚期绑定)。
2.2、类的加载时机

关于在什么情况下必要开始类加载过程的第一个阶段 “加载”,《Java虚拟机规范》中并没有举行逼迫束缚,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类举行 “初始化”(而加载、验证、准备天然必要在此之前开始):

  • 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有举行过初始化,则必要先触发其初始化阶段。可以或许生成这四条指令的典型 Java 代码场景有:

    • 使用 New 关键字实例化对象的时间;
    • 取或设置一个 Class 的静态字段(被 final 修饰、已在编译期把效果放入常量池的静态字段除外) 的时间;
    • 调用一个类型的静态方法的时间。

  • 使用 java.lang.reflect 包的方法对 Class 举行反射调用的时间,如果类型没有举行过初始化,则必要先触发其初始化;
  • 当初始化类的时间,如果发现其父类还没有举行过初始化,则必要先触发其父类的初始化;
  • 当虚拟机启动时,用户必要指定一个要执行的主类(包罗 main() 方法的谁人类),虚拟时机先初始化这个主类;
  • 当使用 Jdk7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析效果为 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,而且这个方法句柄对应的类没有举行过初始化,则必要先触发其初始化;
  • 当一个接口中定义了 Jdk8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
这六种场景中的行为称为对一个类型举行主动引用。
2.3、被动引用不会初始化

除以上主动引用之外,所有引用类型的方式都不会触发初始化,称为被动引用。下面举三个例子来说明作甚被动引用。
2.3.1、代码示例一

  1. package com.lizhengi.classloading;
  2. /**
  3. * 被动使用类字段演示一:通过子类引用父类的静态字段,不会导致子类初始化
  4. */
  5. public class SuperClass {
  6.     static {
  7.         System.out.println("父类 init!");
  8.     }
  9.     public static int value = 123;
  10. }
  11. class SubClass extends SuperClass {
  12.     static {
  13.         System.out.println("子类 init!");
  14.     }
  15. }
  16. /**
  17. * 非主动使用类字段演示
  18. */
  19. class NotInitialization {
  20.     public static void main(String[] args) {
  21.         System.out.println(SubClass.value);
  22.     }
  23. }
复制代码
上述代码运行之后,只会输出 “父类 init!”,而不会输出 “子类 init!”。
得出结论:访问静态属性的时间,不管是通过子类还是父类来访问这个静态属性,只有静态属性所呆的类会被初始化。至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明白规定。
2.3.2、代码示例二

  1. package com.lizhengi.classloading;
  2. /**
  3. * 被动使用类字段演示二:通过数组定义来引用类,不会触发此类的初始化
  4. */
  5. public class SuperClass {
  6.     static {
  7.         System.out.println("父类 init!");
  8.     }
  9.     public static int value = 123;
  10. }
  11. /**
  12. * 非主动使用类字段演示
  13. */
  14. class NotInitialization {
  15.     public static void main(String[] args) {
  16.         SuperClass[] sca = new SuperClass[10];
  17.     }
  18. }
复制代码
上述代码运行之后,发现没有输出 “父类 init!”,说明并没有触发类 com.lizhengi.classloading.SuperClass 的初始化阶段。
但是这段代码内里触发了 另一个名为 com.lizhengi.classloading.SuperClass 的类的初始化阶段,对于用户代码来说,这并不是一个正当的类型名称,它是一个由虚拟机主动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。
这个类代表了一个元素类型为 com.lizhengi.classloading.SuperClass 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 public 的 length 属性和 clone() 方法)都实如今这个类里。Java 语言中对数组的访问要比 C/C++ 相对安全,很大水平上就是因为这个类包装了数组元素的访问。
2.3.2、代码示例三

  1. package com.lizhengi.classloading;
  2. /**
  3. * 被动使用类字段演示三:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
  4. */
  5. public class ConstClass {
  6.     static {
  7.         System.out.println("ConstClass init!");
  8.     }
  9.     public static final String HELLOWORLD = "hello world";
  10. }
  11. /**
  12. * 非主动使用类字段演示
  13. */
  14. class NotInitialization {
  15.     public static void main(String[] args) {
  16.         System.out.println(ConstClass.HELLOWORLD);
  17.     }
  18. }
复制代码
上述代码运行之后,也没有输出 “ConstClass init!”,缘故原由是常量在编译阶段存入了常量池,已经彻底和类脱离了关系,也就是常量和类的关系在编译成 Class 文件后就已不存在任何联系了。常量已经不再属于这个类。
2.4、接口的加载过程

Ps:接口的加载过程与类加载过程稍有差别,针对接口必要做一些特别说明:
接口也有初始化过程, 这点与类是同等的,上面的代码都是用静态语句块 static{} 来输出初始化信息的,而接口中不能使用 static{} 语句块,但编译器仍旧会为接口生成 <clinit>() 类构造器,用于初始化接口中所定义的成员变量。
接口与类真正有所区别的是前面讲述的六种 “有且仅有” 必要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时间(如引用接口中定义的常量)才会初始化(被 default 关键字修饰的接口方法这种情况除外)。

3、Java类的加载过程

3.1、加载

在加载阶段,Java 虚拟机必要完成以下三件事变:

  • 通过一个类的全限定名来获取定义此类的二进制字节省;
  • 将这个字节省所代表的静态存储结构转化为方法区的运行时数据结构;
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
“通过一个类的全限定名来获取定义此类的二进制字节省” 这条规则,它并没有指明二 进制字节省必须得从某个Class文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。许多举足轻重的Java技术都创建在这 一基础之上,比方:


  • 从 ZIP 压缩包中读取,这很常见,终极成为日后 JAR、EAR、WAR 格式的基础;
  • 运行时盘算生成,这种场景使用得最多的就是动态署理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass() 来为特定接口生成形式为 *$Proxy 的署理类的二进制字节省;
  • 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件;
  • 可以从加密文件中获取,这是典型的防 Class 文件被反编译的掩护步伐,通过加载时解密 Class 文件来保障步伐运行逻辑不被窥探。
加载阶段既可以使用 Java 虚拟机里内置的引导类加 载器来完成,也可以由用户自定义的类加载器去完成,开发职员通过定义自己的类加载器去控制字节省的获取方式(重写一个类加载器的 findClass() 或 loadClass() 方法),实现根据自己的想法来赋予应用步伐获取运行代码的动态性。
对于数组类而言,情况就有所差别,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍旧有很密切的关系,因为数组类的元素类型终极还是要靠类加载器来完成加载,一个数组类创建过程遵照以下规则:

  • 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组类将被标识在加载该组件类型的类加载器的类名称空间上。
  • 如果数组的组件类型不是引用类型(比方 int[] 数组的组件类型为 int),Java 虚拟机将会把数组类标记为与引导类加载器关联。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交织举行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中举行的动作,仍旧属于连接阶段的一部分,这两个阶段的开始时间仍旧保持着固定的先后顺序。
3.2、验证

验证是连接阶段的第一步,这一阶段的目标是确保 Class 文件的字节省中包罗的信息符合《Java虚拟机规范》的全部束缚要求,保证这些信息被看成代码运行后不会危害虚拟机自身的安全:


  • 编译器验证: Java 语言本身是相对安全的编程语言(起码对于 C/C++ 来说是相对安全的),将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事变,如果实验如许去做了,编译器会绝不留情地抛出非常、拒绝编译;
  • 字节码验证: 但前面也曾说过, Class 文件并不肯定只能由 Java 源码编译而来,Java 代码无法做到的事变在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java 虚拟机如果不检查输入的字节省,对其完全信托的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击乃至瓦解,所以验证字节码是 Java 虚拟机掩护自身的一项必要步伐;
  • 验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证;
  • 文件格式验证:该验证阶段的重要目标是保证输入的字节省能精确地解析并存储于方法区之内,格式上符合形貌一个 Java 类型信息的要求。这阶段的验证是基于二进制字节省举行的,只有通过了这个阶段的 验证之后,这段字节省才被允许进入 Java 虚拟机内存的方法区中举行存储,所以后面的三个验证阶段 全部是基于方法区的存储结构上举行的,不会再直接读取、利用字节省了。
  • 元数据验证:这个阶段可能包括的验证点如下(内容比较多,只列了以下几点):

    • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

  • 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,重要目标是通过数据流分析和控制流分析,确定 步伐语义是正当的、符合逻辑的;
  • 符号引用验证:重要作用是验证该类是否缺少大概被禁止访问它依赖的某些外部类、方法、字段等资源。类、字段、方法的可访问性(private、protected、public)是否可被当前类访问。
如果步伐运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证步伐,以缩短虚拟机类加载的时间。
3.3、准备

准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中举行分配,但必须留意到方法区本身是一个逻辑上的区域,在 Jdk7 及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在 Jdk8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中。
关于准备阶段,还有两个轻易产生肴杂的概念笔者必要着重夸大,首先是这时间举行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值 “通常情况” 下是数据类型的零值,假设一个类变量的定义为:
  1. public static int value = 123;
复制代码
那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是步伐被编译后,存放于类构造器方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。
下面列出了 Java 中所有根本数据类型的零值。
数据类型零值数据类型零值int0booleanfalselong0Lfloat0.0fshort0double0.0dchar\u0000referncenullbyte0 3.4、解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程


  • 符号引用(Symbolic References):符号引用以一组符号来形貌所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量大概是一个能间接定位到目标的句柄。
符号引用与虚拟机实现的内存布局无关,直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在差别虚拟机实例上翻译出来的直接引用一样平常不会雷同。
如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
解析动作重要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用举行。
  1. /**
  2. * 符号引用
  3. */
  4. String str = "abc";
  5. System.out.print("str=" + str);
  6. /**
  7. * 直接引用
  8. */
  9. System.out.print("str=" + "abc");
复制代码
符号引用要转换成直接引用才有用,这也说明直接引用的服从要比符号引用高。那为什么要用符号引用呢?这是因为类加载之前,javac 会将源代码编译成 .class 文件,这个时间 javac 是不知道被编译的类中所引用的类、方法大概变量他们的引用地址在哪里,所以只能用符号引用来表现。
3.5、初始化

类的初始化阶段是类加载过程的最后一个步调,除了在加载阶段用户应用步伐可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 步伐代码,将主导权移交给应用步伐。
举行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据步伐员通过步伐编码订定的主观计划去初始化类变量和其他资源。
初始化阶段就是执行类构造器 <clinit>() 方法的过程。<clinit>() 并不是步伐员在 Java 代码中直接编写 的方法,它是 Javac 编译器的主动生成物。
<clinit>() 方法是由编译器主动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
<clinit>() 方法与类的构造函数(即在虚拟机视角中的实例构造器 <init>() 方法)差别,它不必要显式地调用父类构造器,Java 虚拟时机保证在子类的 <clinit>() 方法执行前,父类的 <clinit>() 方法已经执行 完毕。因此在 Java 虚拟机中第一个被执行的 <clinit>() 方法的类型肯定是 java.lang.Object。
由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值 利用,如下代码示例,输出2。
  1. public class Test {
  2.     static class Parent {
  3.         public static int A = 1;
  4.         static {
  5.             A = 2;
  6.         }
  7.     }
  8.     static class Sub extends Parent {
  9.         public static int B = A;
  10.     }
  11.     public static void main(String[] args) {
  12.         System.out.println(Sub.B);
  13.     }
  14. }
复制代码
接口中不能使用静态语句块,但仍旧有变量初始化的赋值利用,因此接口与类一样都会生成 <clinit>() 方法。但接口与类差别的是,执行接口的 <clinit>() 方法不必要先执行父接口的 <clinit>() 方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。别的,接口的实现类在初始化时也 一样不会执行接口的 <clinit>() 方法。
Java 虚拟机必须保证一个类的 <clinit>() 方法在多线程环境中被精确地加锁同步,如果多个线程同 时去初始化一个类,那么只会有此中一个线程去执行这个类的 <clinit>() 方法,其他线程都必要阻塞等 待,直到运动线程执行完毕 <clinit>() 方法。如果在一个类的 <clinit>() 方法中有耗时很长的利用,那就 可能造成多个进程阻塞,在实际应用中这种阻塞每每是很潜伏的。代码如下演示了这种场景。
  1. public class Test {
  2.     static class DeadLoopClass {
  3.         static {
  4.             // 如果不加上这个if语句,编译器将提示“Initializer does not complete normally” 并拒绝编译
  5.             if (true) {
  6.                 System.out.println(Thread.currentThread() + "init DeadLoopClass");
  7.                 while (true) {
  8.                 }
  9.             }
  10.         }
  11.     }
  12.     public static void main(String[] args) {
  13.         Runnable script = new Runnable() {
  14.             @Override
  15.             public void run() {
  16.                 System.out.println(Thread.currentThread() + "start");
  17.                 DeadLoopClass dlc = new DeadLoopClass();
  18.                 System.out.println(Thread.currentThread() + " run over");
  19.             }
  20.         };
  21.         Thread thread1 = new Thread(script);
  22.         Thread thread2 = new Thread(script);
  23.         thread1.start();
  24.         thread2.start();
  25.     }
  26. }
复制代码
运行效果:

必要留意,其他线程固然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出
<clinit>() 方法后,其他线程叫醒后则不会再次进入 <clinit>()方法。同一个类加载器下,一个类型只会被初始化一 次。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

愛在花開的季節

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表