JVM—虚拟机类加载时机与过程

参考资料:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)周志明

1. 类加载的时机

一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的生命周期会经历加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析统称为链接。

除了初始化外,其他阶段的顺序是按部就班的开始,注意是开始而不是执行,因为这些阶段都是混合交叉的进行。

关于什么阶段加载、JVM规范没有强制约束,而是交给虚拟机自由把握,但是JVM规范严格规定了六种情况必须立即对类进行初始化(加载、验证、准备、解析自然在此之前):

  1. 遇到new、static、putstatic、invokestatic这四条字节码指令时。

  2. 反射调用时

  3. 初始化类时,其父类还未初始化,则需要先触发 父类 的初始化。

  4. JVM 启动时用户指定执行的主类(包含main方法的类)

  5. 接口中定义JDK8加入的默认方法(被Default方法修饰的接口方法),这个接口类必须在实现类之前初始化。

  6. JDK7之后的动态语言支持.......

被动引用的例子如下:

1、通过子类引用父类的静态属性,子类不会触发初始化,只会触发父类的初始化。

java 复制代码
public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

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

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
// 输出结果:
SuperClass init!
123

2、用数组定义引用类不会触发初始化

java 复制代码
class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] classes = new SuperClass[10];
    }
}
//该代码没有任何输出

3、常量在编译阶段进入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的初始化。

java 复制代码
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); //hello world
    }
}

2. 类加载过程

2.1 加载

加载阶段是整个类加载过程中的一个阶段需要注意。

在加载阶段JVM需要完成以下三件事:

  1. 通过一个类的全限定名获取定义此类的二进制字节流

  2. 将这个字节流的静态存储结构转化为方法区运行时数据结构

  3. 在内存中生成一个代表类的Class对象,作为方法区这个类的各种数据访问入口。

对于数组类不是通过**类加载器**创建的,而是由JVM在内存中动态构建的。但是数组类最终还是由类加载器完成加载。

2.2 验证

为了确保Class文件的字节流中包含的信息符合JVM的规范约束要求,确保不会危害虚拟机。

验证阶段会完成以下四个检验动作。

2.2.1 文件格式规范

验证字节流是否符合Class文件格式规范,例如:魔点、主次版本号、常量池等等,参考类文件结构

只有通过验证字节流才会进入JVM内存的方法区读取,所以后面三个验证阶段都是基于方法区内存结构进行的

2.2.2 元数据验证

对字节码描述的信息进行语义分析校验,这个阶段验证点如下:

  1. 验证是否具有父类

  2. 是否继承不允许继承的类(final修饰的类)

  3. 如果这个类不是抽象类,是否实现了父类/接口中的所有方法

  4. 类的字段、方法是否与父类矛盾

2.2.3 字节码验证

这是验证过程中最复杂的,主要目的是通过数据流分析和控制流分析,确定程序语义合法性。

在第二阶段对**元数据信息**中数据类型校验完毕后对类的方法体(Class文件中的Code属性)进行校验分析。例如:

保证方法体中的类型转化总是有效的,例如把子类对象赋值给父类数据类,但不能把父类赋值给子类,或者毫不相干的数据类型。

2.2.4 符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段---解析过程中发生。

  • 符号引用验证就是验证该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等资源。

符号引用验证需要检验以下内容:

  1. 通过字符串描述的全限定名是否可以找到对应的类

  2. 指定类是否存在符合方法的字段描述符

  3. 符号引用中类、字段、方法的可访问性,是否可被当前类访问。

2.3 准备

准备阶段是正式为类中定义的**类变量(static变量)**分配内存并设置初始值的阶段,这些在JDK7及之前放在了方法区中,JDK8及之后放在了Java堆中。

  • 需要注意的是这里分配的内存仅仅是给类变量分配内存,而实例变量在运行时随着对象实例化才会分配内存。

  • 这里所说的初始状是0,例如++public static int i = 123++,在准备阶段完成后初始值是0而不是123。

  • 如果类字段的字段属性表中存在ConstantValue的属性,那么就会被初始化为指定的值,例如:++public static final int i = 123++。

2.4 解析

解析阶段是将常量池内的符号引用替换为直接引用的过程,解析过程的符号引用和直接引用关联如下:

  • 符号引用:符号引用是以一组符号来描述引用的目标,可以是任何形式的字面量。和JVM内存布局无关。

  • 直接引用:直接引用是可以指向目标的指针,相对偏移量或者一个句柄。和JVM内存布局有关,同一个符号在不同虚拟机翻译的直接引用一般不同。

2.4.1 类或接口的解析

假设当前为D类,如果把一个未解析过的符号引用N解析为一个类或者接口C的直接引用,那么JVM需要完成以下3个步骤:

  1. 如果C不是一个数组类型,虚拟机会将代表N的全限定名传递给D的类加载器,去加载这个类。在加载过程中可能触发其他类的加载,一旦出现问题那么解析失败。

  2. 如果C是一个数组类型,并且元素类型为对象。那么就会按照第一点的方式去加载这个类。

  3. 前两步没有异常。那么C一个在JVM成为了一个有效的类/接口,但在解析完成前还需进行符号引用验证。

2.4.2 字段解析

在解析一个字段之前,首先对字段表内的符号引用解析,也就是字段所属的类或者接口的符号引用。接下来按照如下步骤对C后续的字段搜索:

  1. 如果C本身包含简单名称和字段描述符都与目标匹配的字段,那么直接返回这个字段的直接引用。

  2. 否则、如果C中实现接口,那么会按照继承关系从下往上一个一个搜索,如果接口出现了相匹配的字段,那么返回这个直接引用。

  3. 否则、如果C不是Object的话,也会按照继承关系向上搜索,直到出现匹配的字段。

  4. 否则、查找失败抛出**NoSuchAccessError**。

2.4.3 方法解析

第一个步骤和字段解析一样,解析方法所属的类/接口的引用。解析成功依然用C表示这个类。

2.4.4 接口方法解析

和上面一样。

2.5 初始化

初始化是类加载过程中最后一个动作,在初始化中JVM才将开始执行类中编写的java代码逻辑,将控制权交给应用程序。

在准备阶段时,变量已经赋值过一次系统要求的初始零值,而在初始化阶段就复制为编码制定的值。

1、非法向前引用---Illegal forward reference

java 复制代码
static {
    i = 0;
    System.out.println(i);
}

static int  i = 1;

2、<clinit>方法的执行顺序

<clinit>方法与实例构造器<init>方法不同,它不需要显示的调用父类的构造器。

JVM会保证父类的<clinit>方法一定先于子类执行,也就是父类静态代码块执行先于子类,在下面这个例子中也就是说父类静态代码块优于子类的赋值操作。

java 复制代码
public class Main {
    public static void main(String[] args) {
        System.out.println(Sub.B); // 2
    }
}

class Parent{
    public static int A = 1;
    static {
        A = 2;
    }
}

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

3、字段解析

JVM必须保证一个类的<clinit>方法在多线程下被正确的加锁同步,如果多个线程同时初始化一个类,那么只有一个线程才去执行这个类的<clinit>方法,其他线程都被阻塞。

  • 所以一个类的<clinit>方法中有很多耗时操作就会导致多个线程阻塞。
java 复制代码
public class DeadLoopClass {
    static {
        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");
                new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };

        new Thread(script).start();
        new Thread(script).start();

    }
}
相关推荐
大数据编程之光22 分钟前
Flink Standalone集群模式安装部署全攻略
java·大数据·开发语言·面试·flink
爪哇学长36 分钟前
双指针算法详解:原理、应用场景及代码示例
java·数据结构·算法
ExiFengs39 分钟前
实际项目Java1.8流处理, Optional常见用法
java·开发语言·spring
paj12345678941 分钟前
JDK1.8新增特性
java·开发语言
繁依Fanyi1 小时前
简易安卓句分器实现
java·服务器·开发语言·算法·eclipse
慧都小妮子1 小时前
Spire.PDF for .NET【页面设置】演示:打开 PDF 时自动显示书签或缩略图
java·pdf·.net
m51271 小时前
LinuxC语言
java·服务器·前端
IU宝1 小时前
C/C++内存管理
java·c语言·c++
瓜牛_gn1 小时前
依赖注入注解
java·后端·spring
hakesashou1 小时前
Python中常用的函数介绍
java·网络·python