1、JVM 运行实例中会存在多个 ClassLoader,不同的 ClassLoader 会从不同的地方加载字节码文件。它可以从不同的文件目录加载,也可以从不同的 jar 文件中加载,也可以从网络上下载字节码再进行加载。那JVM是如何进行类的加载的呢?
一个类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)。
-
加载
通过一个类的全限定名来获取该类的二进制字节流,将这个字节流的静态存储结构转化为方法区运行时数据结构,在内存堆中生成一个代表该类的java.lang.Class对象,作为该类数据的访问入口。 相对于类生命周期的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。 -
验证
确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证主要包括:
(1)、文件格式的验证:验证字节流是否符合Class文件的规范。
(2)、元数据验证:对字节码描述的信息进行语义分析,确保符合java语言规范和逻辑。
(3)、字节码验证:通过数据流和控制流分析,确定语义是合法的。
(4)、符号引用验证:确保解析动作能正确执行。 -
准备
为类的静态变量分配内存,并将其赋默认值,需要注意的是:
(1)、只对static修饰的静态变量进行内存分配、赋默认值(如0、0L、null、false等)。
(2)、对final的静态字面值常量直接赋初值。 -
解析
把类文件的常量池部分的符号引用转化为运行时常量池的直接引用的整个过程称为解析。 打个比方:如Worker()类中的一个方法gotoWork(),在Worker类的二进制数据中,包含了一个对Car类的run()方法的符号引用,它由run()方法的全名 和 相关描述符组成。在解析阶段,Java虚拟机会把这个符号引用替换为一个指针,该指针指向Car类的run()方法在方法区的内存位置,这个指针就是直接引用。
public void gotoWork(){
//这段代码在Worker类中的二进制表示为符号引用
car.run();
}
- 初始化
到了初始化阶段,就开始执行类中定义的Java代码, 也就是执行类构造器<clinit>()
方法的过程。
① <clinit>()
方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序所决定。
② clinit()方法与类的构造函数不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()
方法执行之前,父类的<clinit>()
方法已经执行完毕,因此在虚拟机中第一个执行的<clinit>()
方法的类一定是java.lang.Object。
③由于父类的<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下面的例子所示,输出结果为2而不是1。
public class Parent {
public static int A = 1;
static{
A = 2;
}
}
public class Sub extends Parent{
public static int B = A;
}
public class Test {
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
④接口中不能使用静态代码块,但仍然有变量初始化的操作,因此也有<clinit>()
方法,但接口或接口的实现类初始化时不会先去执行父接口或接口的初始化,只当其中的变量使用时才初始化。