JVM类加载机制详解

Java在运行期才对类进行加载到内存、连接、初始化过程。这使得Java应用具有极高的灵活性和拓展性,可以依赖运行期进行动态加载和动态连接。

主要加载哪些?Java中的数据类型分为基本数据类型和引用数据类型,基本数据类型由虚拟机预先定义,而引用数据类型需要进行类的加载

注:对于同一个类加载器,类只加载一次,但对于不同的类加载器可以将类多次加载到内存并相互隔离

1 装载阶段:Loading

装载阶段,简言之,查找并加载类的二进制数据,生成该类的数据结构和Class的实例

字节码文件加载到内存中的类模版结构存储在方法区class的实例存储在堆空间

1、在加载类时,Java虚拟机必须完成以下3件事情:

  • 通过类的全名,获取类的二进制数据流
  • 解析类的二进制数据流为方法区内的数据结构(Java类模型)。
  • 创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

说明:

方法区,1.8之前常被称为永久代,1.8之后被元空间所取代)

(如果用一个变量clazz接收这个Cl ass类的实例,这个变量在中,并保存了实例的地址)

(创建Class的实例的构造方法是私有的,只有JVM能够创建,同时也是实现反射的关键)

2、二进制流的获取方法

对于类的二进制数据流,虚拟机可以通过多种途径产生或获得。(只要所读取的字节码符合JVM规范即可)

  • 虚拟机从文件系统读入一个class后缀的文件。(最常见)
  • 读入jar、zip等归档数据包,提取类文件。(jar包)
  • 事先存放在数据库中的类的二进制数据
  • 使用类似于HTTP之类的协议通过网络进行加载
  • 在运行时生成一段Class的二进制信息等

在获取到类的二进制信息后,Java虚拟机就会处理这些数据,并最终转为一个java.lang.Class的实例

如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。

3、数组的加载

创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。创建数组类(下述简称A)的过程:

  1. 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
  2. JVM使用指定的元素类型和数组维度来创建新的数组类。
  3. 如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public。

2 链接阶段:Linking

2.1 验证:Verify

它的目的是保证加载的字节码是合法、合理并符合规范的。

验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等。

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。格式验证会和装载阶段一起执行。符号引用验证在解析环节才会执行。

2.2 准备:Prepare

为类的静态变量分配内存,并将其初始化为默认值。

(注意区分,这里虽然用了初始化,是给变量赋默认值,但是后面还有初始化阶段,是给变量显式赋值,这两个初始化是不同的概念)

private static int num = 1;
// 这个静态成员变量在准备阶段会赋0,初始化阶段赋1;

注意:

  1. 这里不包含基本数据类型的字段用static final修饰的情况(即常量),因为final在编译的时候就会分配了,准备阶段会显式赋值。
  2. 注意这里不会为实例变量(即非静态变量)分配初始化,实例变量是会随着对象一起分配到Java堆中。
  3. 在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。
  4. Java并不支持boolean类型,对于boolean类型,内部实现是int,由于int的默认值是0,故对应的,boolean的默认值就是false。

说明:常量在编译阶段会存入到调用这个常量的方法所在类的常量池中。

// 即对于下面这个字符串常量,在编译阶段就已经为其分配好了内存和值,在字节码文件加载后直接将其显式赋值
public class MyTest2 {
    public static void main(String[] args) {
        System.out.println(MyParent2.str);
    }
}

class MyParent2 {
    public static final String str = "hello world";
    static {
        System.out.println("MyParent2 static block");
    }
}// 输出 hello world
// 但也有例外情况,如果常量的值并不是字面量,并非编译期间可以确定的,那么其值就不会被放到调用类的常量池中,这时在程序运行时,会导致主动使用这个常量所在的类,显然会导致这个类会初始化。(在初始化阶段才会显示赋值,clinit()方法
public class MyTest3 {
    public static void main(String[] args) {
        System.out.println(MyParent3.str);
    }
}

class MyParent3 {
    public static final String str = UUID.randomUUID().toString();
    static {
        System.out.println("MyParent3 static block");
    }
}// 常量的值在运行时才会确定
// 输出
//MyParent3 static block
//4e5bc60b-ec26-40c1-aeea-a5eddcb2dbaf

2.3 解析:Resolve

将类、接口、字段和方法的符号引用转为直接引用。

1.具体描述

符号引用就是一些字面量的引用,和虚拟机的内部数据结构和和内存布局无关。比较容易理解的就是在Class类文件中,通过常量池进行了大量的符号引用。但是在程序实际运行时,只有符号引用是不够的,比如当如下println()方法被调用时,系统需要明确知道该方法的位置

以方法为例,Java虚拟机为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用 --> 目标方法在类中方法表中的位置,从而使得方法被成功调用。

2.小结

所谓解析就是将符号引用转为直接引用,也就是得到类、字段、方法在内存中的指针或者偏移量。因此,可以说,如果直接引用存在,那么可以肯定系统中存在该类、方法或者字段。但只存在符号引用,不能确定系统中一定存在该结构。

不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpot VM中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行(解析操作并不一定按照上述顺序执行)。

3 初始化阶段:Initializing

始化阶段,简言之,为类的静态变量赋予正确的初始值。(显式初始化)

  1. 具体描述

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中。此时,类才会开始执行Java字节码。(即:到了初始化阶段,才真正开始执行类中定义的 Java 程序代码,代码块、静态代码块、构造器。或者说此时才是开发者可控的范围,之前的都由虚拟机自动完成)

  1. clinit()方法
  • 初始化阶段的重要工作是执行类的初始化方法:<clinit>()方法
  • 该方法仅能由Java编译器生成并由JVM调用,程序开发者无法自定义一个同名的方法,更无法直接在Java程序中调用该方法,虽然该方法也是由字节码指令所组成。
  • 它是由类静态成员的显示赋值语句以及static语句块合并产生的。(静态变量不赋值、非静态变量、常量都不能生成clinit()方法)
  • 一个类的clinit()方法在多线程执行时会加同步锁,保证只有一个线程执行这个方法,如果clinit()方法中有较耗时的内容,将会造成阻塞,无法执行初始化后的内容。但一般来说一个类只会初始化一次。

<clinit>() : 只有在给类的中的静态变量显式赋值或在静态代码块中赋值了,才会生成此方法,这个方法不是必需的。
<init>() 一定会出现在Class的method表中。(非静态变量的显式赋值、非静态代码块、构造器)

// clinit
private static int a = 1;
static{
    a=2;
}
/* 
* a=1会被a=2覆盖,静态代码块和变量赋值同等优先度,按顺序执行,
* 但由于只能访问在它之前被定义的静态变量,所以一般静态代码块晚于静态变量赋值
* 对于非静态变量的显式赋值与非静态代码块的执行顺序也是如此
*/

注意:非静态成员变量在初始化时不会分配内存空间,也不会赋默认值。非静态成员变量只有在类被创建实例对象的时候才会一起创建。如果是由于创建对象触发的类的加载,则会在初始化后紧接着创建类的实例和成员变量,并对变量赋默认值。然后,相继执行非静态变量、构造方法对非静态变量进行初始化。注意区分类的初始化创建类的实例

静态代码块中只允许有局部变量静态变量,不允许操作非静态成员变量

常量如果被赋字面量的值,在链接的准备阶段就被赋值,在初始化阶段不再需要clinit来初始化,但如果常量被赋的值不是字面量,需要初始化阶段才能确定,则会延后到初始化阶段赋值。

  1. 触发类的加载、初始化的时机?

主动使用:完成装载、验证、准备和初始化一套流程(解析可能会在运行时才开始)

  • 当创建一个类的实例时,比如使用new关键字,或者通过反射、克隆、反序列化
  • 当调用类的静态方法时,即当使用了字节码invokestatic指令。
  • 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用getstatic或者putstatic指令。
  • 当使用java.lang.reflect包中的方法反射类的方法时。比如:Class.forName(“com.atguigu.java.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);
    }
}// 初始化子类前先初始化父类,父类的静态代码块先于子类的显式赋值的执行,输出2
  • 如果一个接口定义了默认方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

特别说明:初始化父接口的子接口接口的实现类时,并不会像父子类一样先初始化这个父接口**。只有调用接口的具体静态常量静态方法或被实现的接口有默认方法时才会初始化。(接口内没有常量外的其他变量,也没有起初始化作用的代码块)

执行的主类在运行前会先完成类的装载、验证、准备和初始化,然后在程序的入口main()开始执行,main()方法内使用到其他的类同样会触发类的加载。也即主类的静态代码块早于main()执行,但同上面所说的,非静态代码块会在创建实例时才会执行。

static String a = "a";
static {
    a="aa";
}
{
    a="aaa";
}
public static void main (String[] args){
    System.out.println(a);
}
// 输出 aa
// 如果main()中加入a="aaaa",则最终输出aaaa,但无论如何不会输出aaa因为没有创建实例。

被动使用:类可能被加载,但不会初始化。

  • 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化。
  • 通过数组定义类引用,不会触发此类的初始化。
  • 引用常量不会触发此类或接口的初始化。因为常量在链接阶段就已经被显式赋值了。
  • 调用ClassLoader类的loadClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

被动的使用,意味着不需要执行初始化环节,意味着没有<clinit>()的调用。

4 类的卸载

通过前面的几个阶段,类已经可以被正常使用,梳理一下这过程中产物:

装载阶段将字节码文件读入生成对应类的Class实例(堆中)以及类的结构和常量(方法区),而其实这个过程是由类的加载器(Class Loader)实现的,所以在此之前会先生成加载该类的加载器对象(堆中)。链接阶段和初始化阶段主要是除了给变量赋值没有产生新的东西。

如果初始化阶段一并new了类的对象,则会生成类的实例对象,类的成员变量等结构。

堆中一共产生了3个类的对象,这三个对象之间也有关联关系,同时也可以用变量去引用它们,如下图所示。

那么什么时候回触发类的卸载呢?当类卸载后,代表该类的Class对象不再被引用或unreachable,其在堆空间和方法区内的数据也会被清空。

引用变量可以为null,类的实例对象也可以被销毁,但是由于Class类对象被加载器引用,一般除特殊处理类是被系统加载器加载,而系统加载器在JVM运行期间很难会不再使用,所以类的卸载往往是不确定的,也很难会被卸载。

所以,类的卸载需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

注意:Java虚拟机只是允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

可以得到的结论是:一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的。也因此开发者在开发代码时候,不应该在对虚拟机的类型卸载做任何假设前提下,来实现系统中的特定功能(不应该设计一种代码逻辑是在类卸载后执行,因为往往是不可靠的)。也正因为类很难被卸载,所以类虽然可以被创建多个实例,但往往只加载一次。

  • 11
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值