【Java底层原理】->JVM浅谈 -> 类加载

JVM 类加载

    • Ⅰ 前言
    • Ⅱ 类加载机制概述
    • Ⅲ 类加载的过程
      • A. 加载(Loading)
      • B. 链接(Linking)
        • ① 验证(Verification)
        • ② 准备(Preparation)
        • ③ 解析(Resolution)
      • C. 初始化(Initialization)
    • Ⅳ 类加载器
      • A. 概念
      • B. 三层类加载器与双亲委派模型

Ⅰ 前言

Java 语言的类型一共可以分为两大类:基本类型(primitive types)引用类型(reference types)。基本类型都是由 JVM 预先定义好的。

引用类型一共可以细分成四种:类, 接口, 数组类, 泛型参数。而泛型参数在编译时会被擦除,因此 JVM 实际上只有前三种。在 类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

Java 中所谓的字节流,最常见其实就是经过 Java 编译器生成的 class 文件,除此之外我们还可以从程序内部直接生成或者从网络中获取字节流(比如Java applet)。这些字节流在程序中就会被加载到 JVM 中,变成类或者接口。

无论是直接生成的数组类还是加载的类,JVM 都需要对其进行链接和初始化,这就是虚拟机的类加载机制,本篇文章将会对虚拟机的类加载做一个总结和分析。

我们可以先来看一个样例。

package com.tyz.classloader.test;

/**
 * @author tyz
 */
public class People {
     
    private String name;
    private boolean gender;
    private int age;

    public People(String name, boolean gender, int age) {
     
        this.name = name;
        this.gender = gender;
        this.age = age;
    }

    @Override
    public String toString() {
     
        return this.name + " " 
                + (this.gender ? "male" : "female") 
                + " " + this.age;
    }
}

我先定义了一个简单的类,然后在主函数中分别初始化一下几个类型的数据。
【Java底层原理】->JVM浅谈 -> 类加载_第1张图片
接下来我们可以看看它编译成字节码之后的样子。

public static void main(java.lang.String[]);
    Code:
       0: bipush        97
       2: istore_1
       3: bipush        26
       5: newarray       int
       7: astore_2
       8: new           #2                  // class java/util/ArrayList
      11: dup
      12: invokespecial #3                  // Method java/util/ArrayList."":()V
      15: astore_3
      16: new           #4                  // class com/tyz/classloader/test/People
      19: dup
      20: ldc           #5                  // String Jessica
      22: iconst_1
      23: bipush        26
      25: invokespecial #6                  // Method com/tyz/classloader/test/People."":(Ljava/lang/String;ZI)V
      28: astore        4
      30: return
}

可以看到初始化char类型时,是直接bipush 97,这里备注一下istore命令就是存储的意思。包括数组在定义的时候,我们是定义了一个大小为26的数组,因此在加载数组之前,会先通过bipush加载一个26,再通过newarray指令加载数组。

【Java底层原理】->JVM浅谈 -> 类加载_第2张图片

再往下看,要初始化ArrayList和我们定义的People类就不一样了,它们有一个共有的操作就是调用类加载器。
【Java底层原理】->JVM浅谈 -> 类加载_第3张图片
看到引用类型和基本类型,引用类型中的类类型和数组之间的差异之后,我们来看看类加载。

Ⅱ 类加载机制概述

首先我们先来定义一下类加载机制:Java 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。

和那些编译时需要进行链接的语言不同,在Java中,类型的加载、链接和初始化过程都是在运行期间完成的,这种策略让Java语言进行提前编译会面临更多的困难,也会让类加载时稍微增加一些性能开销,但是却为Java的应用提供了极高的扩展性和灵活性。Java天生可以动态扩展的语言特性就是依赖运行时动态加载和动态链接这个特点实现的。

注意,这里的class文件并不只指代存在磁盘中的某个文件,而是指一串二进制字节流,类也并不是仅指类,还包括接口。

Ⅲ 类加载的过程

一般来说,我们会把类加载的过程分成三个主要步骤:加载(Loading), 链接(Linking), 初始化(Initialization)

其中,链接又可分成三个步骤,、验证(Verification)、准备(Preparation)、解析(Resolution)。这是最核心的步骤。

也就是说一个类型从被加载到虚拟机内存中开始,到卸载出内存中为止,它的生命周期将会经历七个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。

【Java底层原理】->JVM浅谈 -> 类加载_第4张图片
上图中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。

这里要强调一下是按部就班地开始,而不是进行,因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

接下来我们就来看看详细的类加载过程。

A. 加载(Loading)

“加载”(Loading)阶段是整个“类加载”(Class Loading)过程中的一个阶段,它是 Java 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象),这里的数据源可能是各种各样的形态,如 jar 文件、class 文件,甚至是网络数据源等;如果输入数据不是 ClassFile 的结构,则会抛出ClassFormatError

简单来说加载就是查找字节流,并据此创建对象的过程。

加载阶段是用户参与的阶段,我们可以自定义类加载器,去实现自己的类加载过程。

这个过程中就涉及到了一个著名的东西了,它就是双亲委派模型。这个我们放到类加载器的模块去说。

B. 链接(Linking)

链接就是把原始的类定义信息平滑地转化入 JVM 运行的过程中的这个过程,简单说就是将创建成的类合并到 Java 虚拟机中。这个过程分为了三个步骤:验证、准备以及解析。

① 验证(Verification)

验证阶段的目的在于保证被加载类能够满足 Java 虚拟机的约束条件,这是虚拟机安全的重要保障,JVM 需要核验字节信息是否符合规范,否则就会抛出一个java.lang.VerifyError异常或其子类异常。 这样就防止了恶意信息或者不合规的信息危害 JVM 的运行。

Java 本身相较于C/C++ 来说是相对安全的,一般的错误是过不了编译的,所以经由 Java 程序编译生成的Class文件几乎不会有问题,但是前面我们也说了,Class 文件并不一定是从 Java 程序中来的,它甚至有可能是有人直接用0和1在文件中直接敲出来的,

所以Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证元数据验证字节码验证符号引用验证。这部分我不再赘述,大家有兴趣可以去看周志明的《深入理解Java虚拟机》。

② 准备(Preparation)

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,注意这里的类也包括接口。

这里一定要注意,准备阶段对静态变量赋值为初始值,比如你定义了一个

public static int num = 710;

准备阶段会将 num赋值为0,而不是710,毕竟这时候还没有执行任何方法。而这个 710 会在类的初始化阶段被赋值。

除了上面的通常情况,还有一种特殊情况,初始值就不是0值了。如果某些字段的属性表中存在 ConstantValue 属性,那准备阶段变量值就会被初始化为 ConstantValue 指定的值。

比如我们重新定义一下num:

public static final int num = 710;

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据Con-stantValue的设置将value赋值为123。

这里我再补充一个Java中0值的表,大家以做参考。

数据类型 零值
int 0
boolean false
long 0L
short (short) 0
char ’\u0000’
byte (byte) 0
float 0.0f
double 0.0d
reference null

③ 解析(Resolution)

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

那么什么是符号引用,什么是替换引用呢?我先写出专业的定义:

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。


直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

就比如我们最前面定义的那个类

【Java底层原理】->JVM浅谈 -> 类加载_第5张图片
这个类中又有String又有boolean,我还可以在这个类中初始一个ArrayList,但是在People类的class文件被加载至 JVM 之前,它并不知道其他的类或者方法,所以在这个类中对其他类的引用,都用别的符号来指代,这就是符号引用。

比如有个孩子还在妈妈肚子里,妈妈可能都不知道是男是女,名字也没起,这时候要指代它妈妈可能就会叫宝宝之类的,在这个宝宝出生以后,符号引用就可以转化成直接引用了,之前的那个代称“宝宝”可以精准定位到这个刚出生的婴儿。

所以,如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)

C. 初始化(Initialization)

类的初始化阶段是类加载过程的最后一个步骤,这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑。

在上面我们举的staticstatic final的例子,除了static final以外的直接赋值方法(只标记了static 或在静态块中赋值),这些赋值操作都将会被 Java 编译器置于同一个方法中,并把它命名为 ,最后这步初始化给标记为常量的字段赋值,就是执行这个 方法的过程,JVM 会通过加锁来保证 方法只被执行一次。

只有当初始化完成之后,类才正式成为可执行的状态。

那么,在什么时候会触发类的初始化呢?JVM 给了如下的触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;
  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;
  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;
  5. 子类的初始化会触发父类的初始化;
  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;
  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

类加载的过程大概就是这些了,类加载中还有一个非常重要的东西,就是类加载器,我们接着来看看类加载器到底是什么。

Ⅳ 类加载器

A. 概念

在前面的加载(Loading)中,我们说了它就是一个通过字节流来创建对象的过程,这个 “通过一个类的全限定名来获取描述该类的二进制字节流” 的动作,是 Java虚拟机团队有意将其放到 JVM 外部 去实现的,以便让应用程序自己决定如何去获得所需的类,那么,实现这个动作的代码就被撑做是类加载器(Class Loader)。

放到外部意思就是说我们可以自己实现一个类加载器,因为类加载器本来就不是 Java虚拟机做的,而是由 Java 本身实现的。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

就比如我们自己实现一个类加载器的话,和用 Java 本身的类加载器加载同一个类的话,所生成的对象是不相等的,用equals()isAssignableFrom() 方法或者instanceof关键字等等判断都不相等。

知道了什么是类加载器,我们来看看双亲委派模型。

B. 三层类加载器与双亲委派模型

在 Java 虚拟机中,只存在两种不同的类加载器,一种是启动类加载器(Bootstrap ClassLoader),这个类加载器是由 C++ 实现的,是虚拟机自身的一部分。另一种就是其他所有的类加载器,这些类加载器都是由 Java 实现的,独立存在于虚拟机的外部,并且全都继承自抽象类java.lang.ClassLoader

自JDK 1.2以来,Java一直保持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变动,但依然未改变其主体结构。

绝大多数Java程序都会用到以下三种系统所提供的类加载器来进行加载,分别是启动类加载器(Bootstrap Class Loader)扩展类加载器(Extension Class Loader) 以及 应用程序类加载器(Application Class Loader)

启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类),它无法法被Java程序直接引用,在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

重要的我们记住这三层结构就好,还要记住启动类加载器由于是 C++ 写的,所以在 Java 中没有对象,只能用 null 来指代它。

【Java底层原理】->JVM浅谈 -> 类加载_第6张图片

上图就展现了三个系统之间的关系,这个图也被称为类加载器的 双亲委派模型(Parents Delegation Model).

亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。正如程序届一直倡导的,多组合,少继承。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

使用委派模型的目的是避免重复加载 Java 类型。

最后我们可以来看一个样例。

【Java底层原理】->JVM浅谈 -> 类加载_第7张图片
分别输出我们自己定义的People类的类加载器,以及 Java 中已经有的一个类 Logging类的类加载器,最后是非常常用的 ArrayList类的类加载器。

输出结果如下:
【Java底层原理】->JVM浅谈 -> 类加载_第8张图片
我们可以看到,自己定义的People类,这个肯定原本是不存在的,所以父类加载器无法加载,最终传递到最下层的应用程序类加载器中;

Logging类虽然是 Java 中自带的,但是相较之下用的不是太频繁,使用的是扩展类加载器;

而使用非常频繁的ArrayList,则是被启动类加载器加载的。

你可能感兴趣的:(Java核心原理,jvm,java,类加载器,双亲委派模型)