Java虚拟机:类的加载机制

大家好,我是栗筝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虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行 "初始化"(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型 Java 代码场景有:
    • 使用 New 关键字实例化对象的时候;
    • 取或设置一个 Class 的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外) 的时候;
    • 调用一个类型的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对 Class 进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化;
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;
  5. 当使用 Jdk7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化;
  6. 当一个接口中定义了 Jdk8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

这六种场景中的行为称为对一个类型进行主动引用。

2.3、被动引用不会初始化

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

2.3.1、代码示例一
java 复制代码
package com.lizhengi.classloading;

/**
 * 被动使用类字段演示一:通过子类引用父类的静态字段,不会导致子类初始化
 */
public class SuperClass {
    static {
        System.out.println("父类 init!");
    }
    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("子类 init!");
    }
}

/**
 * 非主动使用类字段演示
 */
class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

上述代码运行之后,只会输出 "父类 init!",而不会输出 "子类 init!"。

得出结论:访问静态属性的时候,不管是通过子类还是父类来访问这个静态属性,只有静态属性所呆的类会被初始化。至于是否要触发子类的加载和验证阶段,在《Java虚拟机规范》中并未明确规定。

2.3.2、代码示例二
java 复制代码
package com.lizhengi.classloading;

/**
 * 被动使用类字段演示二:通过数组定义来引用类,不会触发此类的初始化
 */
public class SuperClass {
    static {
        System.out.println("父类 init!");
    }
    public static int value = 123;
}

/**
 * 非主动使用类字段演示
 */
class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

上述代码运行之后,发现没有输出 "父类 init!",说明并没有触发类 com.lizhengi.classloading.SuperClass 的初始化阶段。

但是这段代码里面触发了 另一个名为 com.lizhengi.classloading.SuperClass 的类的初始化阶段,对于用户代码来说,这并不是一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于 java.lang.Object 的子类,创建动作由字节码指令 newarray 触发。

这个类代表了一个元素类型为 com.lizhengi.classloading.SuperClass 的一维数组,数组中应有的属性和方法(用户可直接使用的只有被修饰为 publiclength 属性和 clone() 方法)都实现在这个类里。Java 语言中对数组的访问要比 C/C++ 相对安全,很大程度上就是因为这个类包装了数组元素的访问。

2.3.2、代码示例三
java 复制代码
package com.lizhengi.classloading;

/**
 * 被动使用类字段演示三:常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
 */
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}

/**
 * 非主动使用类字段演示
 */
class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

上述代码运行之后,也没有输出 "ConstClass init!",原因是常量在编译阶段存入了常量池,已经彻底和类脱离了关系,也就是常量和类的关系在编译成 Class 文件后就已不存在任何联系了。常量已经不再属于这个类。

2.4、接口的加载过程

Ps:接口的加载过程与类加载过程稍有不同,针对接口需要做一些特殊说明:

接口也有初始化过程, 这点与类是一致的,上面的代码都是用静态语句块 static{} 来输出初始化信息的,而接口中不能使用 static{} 语句块,但编译器仍然会为接口生成 <clinit>() 类构造器,用于初始化接口中所定义的成员变量。

接口与类真正有所区别的是前面讲述的六种 "有且仅有" 需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化(被 default 关键字修饰的接口方法这种情况除外)。


3、Java类的加载过程

3.1、加载

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

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
  3. 在内存中生成一个代表这个类的 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 虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终还是要靠类加载器来完成加载,一个数组类创建过程遵循以下规则:

  1. 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组类将被标识在加载该组件类型的类加载器的类名称空间上。
  2. 如果数组的组件类型不是引用类型(例如 int[] 数组的组件类型为 int),Java 虚拟机将会把数组类标记为与引导类加载器关联。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

3.2、验证

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

  • 编译器验证: Java 语言本身是相对安全的编程语言(起码对于 C/C++ 来说是相对安全的),将一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果尝试这样去做了,编译器会毫不留情地抛出异常、拒绝编译;
  • 字节码验证: 但前面也曾说过, Class 文件并不一定只能由 Java 源码编译而来,Java 代码无法做到的事情在字节码层面上都是可以实现的,至少语义上是可以表达出来的。Java 虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是 Java 虚拟机保护自身的一项必要措施;
  • 验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证;
  • 文件格式验证:该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的 验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段 全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
  • 元数据验证:这个阶段可能包括的验证点如下(内容比较多,只列了以下几点):
    • 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)
    • 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 字节码验证:第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定 程序语义是合法的、符合逻辑的;
  • 符号引用验证:主要作用是验证该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。类、字段、方法的可访问性(privateprotectedpublic)是否可被当前类访问。

如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.3、准备

准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在 Jdk7 及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在 Jdk8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中。

关于准备阶段,还有两个容易产生混淆的概念笔者需要着重强调,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值 "通常情况" 下是数据类型的零值,假设一个类变量的定义为:

java 复制代码
public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时尚未开始执行任何 Java 方法,而把 value 赋值为 123putstatic 指令是程序被编译后,存放于类构造器方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。

下面列出了 Java 中所有基本数据类型的零值。

数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short 0 double 0.0d
char \u0000 refernce null
byte 0
3.4、解析

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

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

符号引用与虚拟机实现的内存布局无关,直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。

如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行。

java 复制代码
/**
 * 符号引用
 */
 String str = "abc";
 System.out.print("str=" + str);
 
 /**
 * 直接引用
 */
 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。

public class Test {
    static class Parent {
        public static int A = 1;

        static {
            A = 2;
        }
    }

    static class Sub extends Parent {
        public static int B = A;
    }

    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的 <clinit>() 方法。

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

java 复制代码
public class Test {
    static class DeadLoopClass {
        static {
            // 如果不加上这个if语句,编译器将提示"Initializer does not complete normally" 并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable script = new Runnable() {
            @Override
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

运行结果:

需要注意,其他线程虽然会被阻塞,但如果执行 <clinit>() 方法的那条线程退出

<clinit>() 方法后,其他线程唤醒后则不会再次进入 <clinit>()方法。同一个类加载器下,一个类型只会被初始化一 次。

相关推荐
栗筝i4 个月前
Java虚拟机:虚拟机介绍
栗筝i 的 java 技术栈·java 基础·java 虚拟机
栗筝i4 个月前
Java虚拟机:运行时内存结构
栗筝i 的 java 技术栈·java 基础·java 虚拟机
栗筝i4 个月前
Java 并发编程:线程变量 ThreadLocal
threadlocal·栗筝i 的 java 技术栈·java 基础·java 并发·线程变量
栗筝i5 个月前
Java 并发编程:volatile 关键字介绍与使用
内存屏障·栗筝i 的 java 技术栈·java 基础·java 并发·volatile 关键字
栗筝i5 个月前
Java 并发编程:Java 线程池的介绍与使用
java基础·栗筝i 的 java 技术栈·java 并发·java 线程池
栗筝i5 个月前
Java 集合框架:TreeMap 的介绍、使用、原理与源码解析
java基础·r-tree·java集合·treemap·栗筝i 的 java 技术栈
栗筝i5 个月前
Java 集合框架:Java 中的优先级队列 PriorityQueue 的实现
优先级队列·栗筝i 的 java 技术栈·java 基础·java 集合·java 堆排序
栗筝i6 个月前
Java 并发集合:CopyOnWrite 写时复制集合介绍
栗筝i 的 java 技术栈·java 基础·java 集合
威哥爱编程6 个月前
使用explain优化慢查询的业务场景分析
数据库·sql·java 基础