浅谈JVM(二):类加载机制

上一篇:
浅谈JVM(一):Class文件解析

类加载机制

​ Java虚拟机把类的描述数据从Class文件加载到内存,并对数据进行校验、转换解析、初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是虚拟机的类加载机制。

《Java虚拟机规范》中说:JVM能动态地加载(Loading)、连接(Linking)、初始化(Initializing)类和接口。

  • 加载是查找具有特定名称的类或接口类型的二进制表示,并根据该二进制表示创建类或接口的过程。

  • 连接是获取类或接口并将其组合到JVM的运行时状态中以便执行的过程。

  • 初始化包括执行类或接口初始化方法,是编译后自动生成的方法。

在这里插入图片描述

​ 虚拟机规范中使用的名词是"二进制表示"(binary representation),它并非只限定为磁盘中的.class文件,而可以理解为是一个二进制字节流,这个二进制字节流可以从压缩包中获取、通过动态代理生成、通过网络获取等。为了简化描述,统一使用Class文件指代。

​ 此外,虚拟机规范中多数时候的用词是"类或接口"(a class or an interface),多数时候类和接口的描述是一样的,所以我们统一简化用"类"描述,如果class和interface描述不同时,会再区分类和接口。

​ 虚拟机规范中没有指明如何具体实现类加载过程,不同虚拟机在具体实现上有所差异,本文为以HotSpot虚拟机为例进行阐述。

2.1.加载

​ 类加载过程(Class Loading)和加载(Loading)是不一样的概念,类加载包含加载、连接、初始化三个阶段,加载只是类加载过程的第一个阶段。加载过程与类加载器(class loader)密切相关,将在下一篇文章中介绍类加载器详细内容。

非数组类加载动作是由类加载器完成的,在加载阶段,完成三件事:

  1. 通过类的全限定名获取定义该类的二进制字节流。

  2. 将这个字节流所代表的静态存储结构转化为方法区(Method Area)的运行时数据结构(Run-Time Data)。

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

​ 数组类情况有所不同(数组类型如TestArrayClass[ ]),数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(指数组去掉维度后的类型,如TestArrayClass)还是要靠类加载器加载。数组加载规则如下:

  1. 如果数组元素类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上。

  2. 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组标记为与引导类加载器关联。

  3. 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。

加载与连接的部分动作可能是交叉进行的,加载尚未完成连接可能已经开始。

2.2.连接

连接阶段又分为验证(Verification)、准备(Preparation)、解析(Resolution)三个阶段。

​ 验证阶段的目的是要保证Class文件的信息符合虚拟机规范,保证代码运行后不会危害虚拟机自身安全。当字节流信息不符合虚拟机的Class格式规范时,会抛出一个VerifyError。验证阶段可能触发其他相关类的加载动作,但不需要对它们进行验证。

验证阶段大致包含以下四种验证:

  1. 文件格式验证:如魔数、版本号、常量池等。

  2. 元数据验证:对字节码数据进行语义分析,如继承关系(除java.lang.Object外所有类都要有父类、不能继承final类)、非抽象类是否实现了父类或接口的所有要求实现的方法、方法重载是否正确等。

  3. 字节码验证:对方法体(Class文件中的Code属性)进行校验分析,比如父类对象赋值给子类类型、操作数与操作指令不匹配等。

  4. 符号引用验证:验证该类是否缺少或被禁止访问某些外部类、方法、字段。如根据字符串描述的类的全限定名能否找到该类,类、字段、方法的访问权限(private、protected、public、default)等。

​ 准备阶段为类变量(static修饰的,不包括实例变量)分配内存并赋默认值。注意这一阶段的赋值不是初始值,而是默认零值。

在这里插入图片描述

//准备阶段赋值成0
public static int value = 123;

​ 上述代码在准备阶段过后,value的初始值是0。真正赋值成123的动作要在初始化阶段完成。赋值指令会被编译到""方法里,在初始化阶段执行。

特殊情况是变量由final修饰,在准备阶段就会赋值。

//准备阶段就赋值成123
public static final int value = 123;

​ 解析阶段将虚拟机常量池中的符号引用替换成直接引用(常量池见浅谈JVM(一):Class文件解析)。符号引用是用一组符号(任意形式字面量)来描述所引用的目标,和虚拟机布局无关;直接引用和虚拟机布局直接相关,是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。如描述符是"Ljava/lang/Integer"对应的就是"java.lang.Integer"类。

2.3.初始化

​ 初始化阶段会执行类构造方法"“,此处的"类构造方法"不是构造器(Constructor),而是由编译器自动生成的方法,自动收集类中所有类变量的赋值动作和静态代码块(static{}块),合并产生名为”“的方法。而创建对象的构造方法会被编译成”"方法。

package com.menglaoshi.test;
public class TestClass01 {    
    static {        
        i = 2;//可以编译通过    
    }    
    public static int i = 1;
}

上述代码编译后如下:

在这里插入图片描述

​ 编译器对于类变量和静态语句中的语句进行合并,根据语句在源文件中的顺序进行收集,静态代码块可以对定义在它之后的变量进行赋值,但不能访问,如下面的代码就不能编译通过。

在这里插入图片描述

​ 类和接口初始化有所不同,类的方法在执行时,不需要显式调用父类的,虚拟机会保证子类执行时,父类已经执行完毕。如果类中没有静态语句,则不会产生方法;接口在初始化时,并不要求其父接口全部完成初始化,只有真正使用到父接口的时候才会初始化。

​ 虚拟机要保证一个类的方法在多线程情况下被正确地加同步锁,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的,其他线程都要阻塞,且其他线程唤醒后也不会再次执行,同一个类加载器下,一个类型只会初始化一次。

​ 虚拟机规范没有明确说明什么时候进行加载和连接,但明确规定了哪些情况下类和接口会初始化,而加载和连接必须在初始化之前完成。

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

​ 对于这六种会触发类型进行初始化的场景,虚拟机规范中使用了一个非常强烈的限定语——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

​ 通过子类引用父类静态字段,不会导致子类初始化,如下:

package com.menglaoshi.test;
public class Father {
    static {
    	System.out.println("Father init");
    }
    public static int value = 123;
}
public class Sun extends Father{
	static {
    	System.out.println("Sun init");
    }
}
public class TestClass {
	public static void main(String[] args) {
    	//只会输出Father init和value值,子类不会初始化     
        System.out.println(Sun.value);    
    }
}

​ 定义数组引用不会导致类的初始化,将main方法修改如下:

public static void main(String[] args) {    Father[] fathers = new Father[10];}

​ 运行后可以看到Father的静态代码块没有执行,查看编译后的代码:

在这里插入图片描述

​ 可以看到初始化的类是"**[**Lcom/menglaoshi/test/Facher;“,前面由一个”["代表这是一个数组,这并不是一个合法存在的类型,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

public class ConstantClass {    
    static {        
        System.out.println("ConstantClass init"); 
    }    
    public static final String value = "a";
}
//测试类
public class TestClass {    
    public static void main(String[] args) {   
        System.out.println(ConstantClass.value);    
    }
}

​ 运行上述代码,发现没有输出"ConstantClass init"。因为常量value的值在编译阶段通过常量传播优化,直接存在了测试类TestClass的常量池中。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

专治八阿哥的孟老师

您的鼓励是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值