详解JVM类加载的三个阶段

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

 java需要使用一个类时,并不是一蹴而就的,需要在后台进行一些必须的步骤。如下图所示,类在我们使用前,共经历了“加载”、“链接”“初始化”三个阶段,而第二个阶段“链接”又被分为三个小阶段,分别是 “验证”、“准备”和“解析”。下文中我们逐一分析,来一起探究java在类加载的过程中,都具体做了些什么,类在使用前都执行了哪些操作。

详解JVM类加载的三个阶段_第1张图片

  

1.类加载的过程

加载:通常情况下,我们所提到的加载是类加载机制的三个阶段的总称,而这里的加载指的是类加载机制中的第一阶段。在这个阶段,虚拟机需要完成以下三件事:

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

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

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

这里需要注意的是,虚拟机规范并没有严格的规定从哪里,以怎样的形式获取字节流。也就是说,只要最后获取到的二进制字节流是符合JVM规范的,就都是合法的。用户可以通过自定义类加载器,重写ClassLoader类中的findClass()方法,来自定义获取字节流的方式,就可以实现个性化的类加载方案。

 

验证:验证是链接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
    这个阶段在正常开发中貌似不怎么会出问题,但在安装了多个版本jdk 的机器上,就可能会遇到下面这个坑。用高版本的jdk编译,却用低版本的jdk运行了字节码文件,程序就会出现Unsupported major.minor version xx.x错误。因为在编译时,jdk的版本号会被写在字节码文件中,当这个类被加载,并执行到验证阶段时,jvm发现了不兼容的jdk版本号,就会报以上错误。

 

准备:准备阶段是正式为静态变量分配内存并设置静态变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

 假设在程序中有这样的代码

static int value = 123;
static String value2 = "Hello world";

那么,在执行到准备阶段,value的最初值将被设定为0,value2的初始值为null。这里初始值指的是java为所有的类型定义的默认值,而不是用户自定义赋予的初值。在java中,引用数据类型的默认值都为null,而基本数据类型的默认值参考下表。所以在这里value的初始值为0,而不是代码所示的123,只有这个类在经过初始化阶段之后,value变量才会被赋值为123。

详解JVM类加载的三个阶段_第2张图片

 

解析:解析阶段就是虚拟机将常量池内的符号引用替换为直接引用的过程。

这句话可能不太好理解。简单来说,假如我有A和B两个类,B类在com.jk包下。那么,A类就可以通过 com.jk.B 来引用B类。这个时候 com.jk.B 就可以被认为是B类的符号引用。

符号引用可以是任意一个字符串,它给出了被引用的内容的名字并且可能会包含一些其他关于这个被引用项的信息——这些信息必须足以唯一的识别一个类、字段、方法。

在解析阶段,符号引用会被替换为直接引用。直接引用了可以是直接指向目标的指针,相对偏移量或者是一个可以间接定位到目标的句柄。所以jvm也必须保证被引用者在引用者加载前就已经加载完毕。

 

初始化:类的初始化是类加载的最后一步,在类的初始化阶段完成的是对类的静态变量的赋值以及静态代码块的执行。

    编译器会自动收集类中的所有静态变量的赋值动作和静态初始化块中的语句合并产生一个 () 方法,虚拟机在初始化一个类的时候,执行的正是() 方法中的指令。

    编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态初始化块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量静态语句块中可以赋值但是不能访问。如果类中没有静态语句或者静态代码块,则不生成此方法。

如有以下代码:

public class Test {
    static int a = 1;
    static {
        a = 2;
    }
    static int b = 3;
    int c = 4;
    
    public Test(){
      a = 123;
    } 
}

编译后,我们通过 javap -v命令来看看,编译器为我们生成的()方法中,都执行了些什么。

详解JVM类加载的三个阶段_第3张图片

iconst_1,iconst_2,iconst_3指令分别代表着 把整型数字1,2,3压入栈中。putstatic指令作用是取出栈顶的值,并设置给类中静态字段。#2和#3指向的是常量池中存放的类中的静态字段,查询常量池,发现,#2,#3代表的正是变量a和b。

详解JVM类加载的三个阶段_第4张图片

 

2.类加载各阶段的执行时机

类的加载是按照  加载,验证,准备,解析的顺序开始的。解析阶段不一定,他在某些情况下在初始化之后开始。

加载时机:

    java虚拟机并没有强制约束,这就代表了类的加载时机交给虚拟机的具体实现来把握。 

    jvm规范允许类加载器在预料到么某个类将要被使用时就预先加载他,如果在预加载时遇到了。class文件缺失的情况,类加载器会在程序首次主动使用该类的时候才报告错误。

初始化时机:

    在该类或接口首次被主动调用时,初始化他们。在类的初始化之前,就必须会先执行加载,验证,准备的工作(解析不一定)。

 

3.主动调用和被动调用

 类在被首次主动调用时,会被初始化。主动调用被分为以下几种情况。

  1. new了一个类的实例

  2. 调用了类的非final的静态变量或静态方法

  3. 通过反射对该类进行了调用

  4. 初始化一个类的子类时,父类会在子类初始化之前进行初始化。

    注:当一个接口初始化时,不要求其父接口也初始化。
  5. 当该类是启动类(包含main方法的类)时。

  6. 使用jdk1.7的动态语言支持时

有且仅有上诉这六种情况会被视为主动调用。除此之外,所有引用类的方式都不会触发其初始化,这被称为被动调用。

下面是被动调用的几种情况。

1.通过子类引用父类中的静态字段

class Father{  
    static int count = 1;  
    static{  
        System.out.println("Initialize class Father");  
    }  
}  
​
class Son extends Father{  
    static{  
        System.out.println("Initialize class Son");  
    }  
}  
  
public class Test {  
    public static void main(String[] args) {  
        int x = Son.count;  
    }  
}

 输出结果发现,父类输出了静态代码块的内容,而子类没有输出。通过子类引用父类中的静态字段,不会触发子类的初始化。

 

2.通过数组定义引用类

class E{
    static{
        System.out.println("Initialize class E");
    }
}
​
public class Test {
    public static void main(String[] args) {
        E[] es = new E[10];
    }
}

程序运行后,没有任何输出。    

    在运行期,jvm会为数组动态生成一个数组的class对象。如果是一维数组,则为:[L+元素的类全名;二维数组,则为[[L+元素的类全名如果是基础类型(int/float等),则为[I(int类型)、[F(float类型)等。这些与所引用的对象无关,故不会触发其类的初始化。

 

3.引用了某个类的final修饰的常量

class F{  
    static final int count = 1;  
    static{  
        System.out.println("Initialize class F");  
    }  
}  
  
public class Test {  
    public static void main(String[] args) {  
        int x = F.count;  
    }  
}

程序运行后,依然没有任何输出。

常量在编译时就会被存放在调用常量的方法所在类的常量池中。所以就不会触发常量所在类的初始化。

常量又分为运行时常量和编译期常量,只有编译期常量才会被存放在调用类的class文件中的静态常量池中。运行时常量会放在运行时常量池中,调用运行时常量,依然会触发其所属类的初始化。

你可能感兴趣的:(jvm学习,java,jvm,类加载机制)