JVM - 类加载机制
Java 类加载机制
类的生命周期
类加载的过程包括加载
、验证
、准备
、解析
、初始化
五个阶段。在这五个阶段中,加载
、验证
、准备
、初始化
这四个阶段发生的顺序是确定的,而解析
阶段则 不一定,它在某些情况下可以在初始化阶段之后开始的,这是为了支持 Java 语言的运行时绑定(也称为 动态绑定 或 晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成的,因为这些阶段通常是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
类的加载:查找并加载类的二进制数据
加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流所代表的 静态存储结构 转化为方法区的 运行时数据结构 ;
- 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
相对于类加载的其他阶段而言,加载阶段(准确的说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在 Java 堆中也创建一个 java.lang.Class
类的对象,这样便可以通过该对象访问方法区中的这些数据。
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM 规范允许类加载器在预料某个类将要使用时就预先加载它,如果在预先加载的过程中遇到了 .class 文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError)。如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。
加载 .class 文件的方式:
- 从 ZIP 、JAR 等归档文件中加载 .class 文件
- 从网络中获取,这种场景最典型的应用就是 WEB Applet
- 运行时计算生成,这种场景使用的最多的就是动态代理技术
- 由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件
- 从数据库中读取
- 从加密文件中获取
连接
验证:确保被加载的类的正确性
验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成 4 个阶段的检验动作:
-
文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
- 是否以魔数
0xCAFEBABE
开头 - 主次版本号是否在当前 Java 虚拟机接收范围之内
- 常量池的常量中是否有不被支持的常量类型
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- ......
- 是否以魔数
-
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求
- 这个类是否有父类(除了
java.lang.Object
之外,所有的类都应当有父类) - 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如 方法参数都一致,但返回值类型却不相同)
- ......
- 这个类是否有父类(除了
-
字节码验证:通过数据流分析和控制流分析,确定程序语义都是合法、符合逻辑的。
- 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中”这样的情况。
- 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
- 保证方法体中的类型转换总是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
- ......
-
符号引用验证:对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通常来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段
- 符号引用中的类、字段、方法的可访问性(private、protected、public)是否可被当前类访问
准备:为类的静态变量分配内存,并将其初始化为默认值
准备阶段是正是为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
-
这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
类变量也叫静态变量,也就是在变量前加了 static 的变量。
实例变量也叫对象变量,即没加 static 的变量。
类变量和实例变量的区别在于:类变量是所有对象共有,其中一个对象将它值改变,其他对象得到的就是改变后的结果;而实例变量则为对象私有,某一个对象将其值改变,不影响其他对象。
class person{ static String age; //--类变量 public String name="李四"; //--实例变量 }
-
这里所设置的初始值通常情况下是数据类型默认的零值(如
0
、0L
、false
、null
等),而不是在 Java 代码中被显式的赋予的值。例如:一个类变量的定义为:
public static int value = 13
;那么变量 value 在准备阶段过后的初始值为0
,而不是13
,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值 13 的put static
指令试在程序编译后,存放于类构造器<clinit>()
方法中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。数据类型 零 值 int 0 long 0L short (short)0 char ‘\u0000’ byte (byte)0 boolean false float 0.0f double 0.0d reference null 基本数据类型的零值
还需要注意如下几点
- 对于同时被
static
和final
修饰的变量,必须在声明的时候就为其显式的赋值,否则编译不通过。 - 如果类字段的字段属性表中存在
ConstantValue
属性,即同时被final
和static
修饰,那么在准备阶段 value 就会被初始化为ConstantValue
属性所指定的值。假设上面的类变量 value 被定义为:public static final int value = 13
,编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据ConstantValue
设置将 value 赋值为 13。
解析:把类中的符号引用转换为直接饮用
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:就是一组符号来描述目标,可以使任意字面量。
直接引用:就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
初始化
进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。
在 Java 中对类变量进行初始化设定有两种方式:
- 声明类变量时指定初始值
- 使用静态代码块为类变量指定初始值
初始化阶段就是执行类构造器 <clinit>()
方法的过程。
<clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static()块
)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
非法前向引用变量
public class Main {
static{
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 这句编译器会提示“非法前向引用”
}
static int i = 1;
}
-
<clinit>()
方法与类的构造函数不同,它不需要显示地调用父类构造器(即在虚拟机视角中的实例构造器<init>()
方法 ),Java 虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。因此在 Java 虚拟机中第一个被执行的<clinit>()
方法的类型肯定是java.lang.Object
。 -
由于父类的
<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。public class Main { 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); } }