1 Java虚拟机的体系结构

JVM 结构图如下:

spring抽象类加载过程 spring类加载机制jvm_spring抽象类加载过程


如上图,类加载机制就是讲类加载器是如何找到指定的 .class 文件以及怎样将 .class 文件装载进内存,以便执行引擎执行 .class 文件中存在的数据和指令,从而使你的 Java 程序跑起来。

2 类的生命周期

spring抽象类加载过程 spring类加载机制jvm_JVM_02


如上图,类加载机制主要包含加载、验证、准备、解析、初识化这些过程,最后就是真正可以将类加载进内存的一个玩意(还是依靠代码实现)——类加载器

PS:上图中解析和初始化的位置是可以互换的,如果解析一旦在初始化之后开始,便成了常说的“动态绑定”。除此之外,这些阶段通常都是互相交叉的混合式进行,各个阶段只保证按部就班的开始,并不保证按部就班的进行或完成。

3 类加载的过程

3.1 加载

什么开始进行类加载过程的第一阶段:加载?Java 虚拟机没有进行强制约束,交由虚拟机的具体实现自由把握。

在加载阶段,虚拟机需要完成如下事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将获取到的二进制字节流转化成一种数据结构并放进方法区
  • 在内存中生成一个代表此类的 java.lang.Class 对象,作为访问方法区中各种数据的接口

方法区:被加载的类的信息存储在方法区中,可以被线程所共享。加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在了方法区之中。

Class对象:Class 对象虽然是在内存中,但并未明确规定是在 Java 堆中,对于 HotSpot 来说,Class 对象存储在方法区中。它作为程序访问方法区中二进制字节流中所存储各种数据的接口。

3.2 验证

从上面类的生命周期一图中可以看出,验证是连接的第一步,这一阶段的目的主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,从而不会危害虚拟机自身安全。也就是说,当加载阶段将字节流加载进方法区之后,JVM 需要做的第一件事就是对字节流进行安全校验,以保证格式正确,使自己之后能正确的解析到数据并保证这些数据不会对自身造成危害。

验证阶段主要分成四个子阶段:

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

这里不详细的说明每一阶段的校验主要干了什么事情。

挑点重点来说:

  • 元数据:元数据是指用来描述数据的数据,你可以更简单的理解成框架中的各种@注解,因为这些@注解很简洁的描述了大量有关各个类、方法、字段额外的信息。

3.3 准备

  1. 准备阶段的目的:正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存将在方法区中分配【在 JVM 的方法区中不仅存储着 Class 字节流,还有我们的类变量(static)】
  2. 类变量初始值通常是指数据类型的零值。比如 int 的零值为 0,long 为 0L,boolean 为 false……真正的初始化赋值是在初始化阶段进行的

如果你设置的类变量还具有 final 字段:public static final int value = 123,那么在准备阶段变量的初始值就会被直接初始化为 123。

3.4 解析

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

上面一段话你可能产生三个疑问:哪个常量池?什么符号引用?什么直接引用?

  • 常量池:常量池(constant pool)指的是在编译期被确定,并被保存在已编译的 .class 文件中的一些数据。它包括了关于类、方法、接口等中的常量,也包括字符串常量。常量池指的就是存在于 .class 文件中的常量池。
  • 符号引用:常量池中存储的那些描述类、方法、接口的字面量,你可以简单的理解为就是那些所需要信息的全限定名,目的就是为了虚拟机在使用的时候可以定位到所需要的目标。
  • 直接引用:直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。

现在我们对上面那句话进行重新解读:虚拟机将运行时常量池中那些仅代表其他信息的符号引用解析为直接指向所需信息所在地址的指针。

P.S.动态连接–大部分 JVM 的实现都是延迟加载或者叫做动态连接。它的意思就是 JVM 装载某个类 A 时,如果类 A 中有引用其他类 B,虚拟机并不会将这个类 B 也同时装载进 JVM 内存,而是等到执行的时候才去装载。而这个被引用的 B 类在引用它的类 A 中的表现形式主要被登记在了符号表中,而解析的过程就是当需要用到被引用类 B 的时候,将引用类 B 在引用类 A 中的符号引用名改为内存里的直接引用。

3.5 初始化

虚拟机规范定义了 5 种情况,会触发类的初始化阶段,也正是这个阶段,JVM 才真正开始执行类中定义的 Java 程序代码:

  • new 一个对象、读取一个类静态字段、调用一个类的静态方法的时候
  • 对类进行反射调用的时候
  • 初始化一个类,发现父类还没有初始化,则先初始化父类
  • main 方法开始执行时所在的类

有三种引用类的方式不会触发初始化(也就是类的加载),为以下三种:

  • 通过子类引用父类的静态字段,不会导致子类初始化
  • 通过数组定义来引用类,不会触发此类的初始化
  • 引用另一个类中的常量不会触发另一个类的初始化,原因在于“常量传播优化

常量传播优化举例:

public class ConstClass {

    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
}

public class NotInitialization {
	
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

这种调用方式不会触发 ConstClass 的初始化,因为常量传播优化,常量“hello world"已经被存储到了 NotInitialization 类的常量池中,以后 NotInitialization 对常量 ConstClass.HELLOWORLD 的引用实际上都被转化为 NotInitialization 对自身常量池的引用。

重点:类构造器 <clinit>():

  1. <clinit>() 是编译器自动收集类中的所有类变量的赋值动作和静态语句块合并产生的
  2. 父类中定义的静态语句块要优先于子类的变量赋值操作
  3. 虚拟机保证一个类的 <clinit>() 方法在多线程环境中被正确的加锁、同步

3.6 方法区使用实例

看一个例子将上面类加载的过程串起来:

class Lava {

    private int speed = 5;
    
    void flow() {
        
    }
}

public class Volcano {
    
    public static void main(String[] args) {
        Lava lava = new Lava();
        lava.flow();
    }
}

不同的虚拟机实现可能会用完全不同的方法来操作,下面描述的只是其中一种可能——但并不是仅有的一种。

  • 加载:读取一个类的 .class 文件并将其中的二进制字节流组织成正确的数据结构放进运行时方法区中:要运行 Volcano 程序,首先得以某种“依赖于实现的”方式告诉虚拟机“Volcano”这个名字。之后,虚拟机将找到并读入相应的 .class 文件“Volcano.class”,然后它会从导入的 .class 文件里的二进制数据中提取类型信息并放到方法区中。通过执行保存在方法区中的字节码,虚拟机开始执行 main() 方法,在执行时,它会一直持有指向当前类(Volcano)的常量池的指针
  • 动态连接:虚拟机开始执行Volcano类中main()方法的字节码的时候,尽管Lava类还没被装载,但是和大多数(也许所有)虚拟机实现一样,它不会等到把程序中用到的所有类都装载后才开始运行。恰好相反,它只会需要时才装载相应的类
  • 内存分配:main()的第一条指令告知虚拟机为列在常量池第一项的类分配足够的内存。所以虚拟机使用指向 Volcano 常量池的指针找到第一项,发现它是一个对 Lava 类的符号引用,然后它就检查方法区,看 Lava 类是否已经被加载了。当虚拟机发现还没有装载过名为“Lava”的类时,它就开始查找并装载文件“Lava.class”,并把从读入的二进制数据中提取的类型信息放在方法区中
  • 解析:紧接着,虚拟机以一个直接指向方法区 Lava 类数据的指针来替换常量池第一项(就是那个字符串“Lava”),以后就可以用这个指针来快速地访问 Lava 类了。这个替换过程称为常量池解析,即把常量池中的符号引用替换为直接引用
  • 内存分配:终于,虚拟机准备为一个新的 Lava 对象分配内存。此时它又需要方法区中的信息。还记得刚刚放到 Volcano 类常量池第一项的指针吗?现在虚拟机用它来访问 Lava 类型信息,找出其中记录的这样一条信息:一个 Lava 对象需要分配多少堆空间。Java 虚拟机总能够通过存储在方法区的类型信息来确定一个对象需要多少内存,当 Java 虚拟机确定了一个 Lava 对象的大小后,它就在堆上分配这么大的空间,并把这个对象实例的变量 speed 初始化为默认初始值 0。
  • 指令执行:当把新生成的 Lava 对象的引用压到栈中,main() 方法的第一条指令也完成了。接下来的指令通过这个引用调用 Java 代码(该代码把 speed 变量初始化为正确初始值 5)。另一条指令将用这个引用调用 Lava 对象引用的 flow() 方法。

4 类加载器

上面说了那么多,类加载器就是用于实现类加载动作的一段代码实现。

4.1 类加载器的命名空间

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类命名空间。也就是说,你现在要比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。

这就是类加载器的命名空间。上面这段话还隐含了另一个重要的信息:类加载器在 JVM 中不止一个。

4.2 双亲委派模型

在 JVM 中有三种系统提供的类加载器:启动类加载器,扩展类加载器、应用程序类加载器。

spring抽象类加载过程 spring类加载机制jvm_JVM_03

这种层次结构就是双亲委派模型。

双亲委派模型的工作过程:它是一个递归调用类加载器的模型,也就是说如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是不断请求父加载器,如果父加载器可以完成这个加载请求,那么就由父加载器进行加载,如果父加载器不能完成加载请求(它的搜索范围中没有找到所需的类),子加载器才会尝试自己去加载。

使用这种模型有什么好处?

Java 类随着类加载器一起具备了带有优先级的层级关系。例如 java.lang.Object,在程序的各种类加载器环境中都是同一个类。

5 总结

  1. 类加载过程就是将 .class 文件装载进内存的过程
  2. 类加载过程分为加载、验证、准备、解析、初始化等 5 个阶段
  3. 加载就是类加载器通过全限定名找到相应的 .class 文件并装载进内存
  4. 验证就是验证 .class 文件中的内容是否符合 JVM 的要求,保证 .class 文件内容的正确性
  5. 准备则是将类中的类变量初始化为零值
  6. 解析则是将 .class 文件常量池中的符号引用解析为直接引用,以方便对类的访问的过程
  7. 初始化则是执行类构造器 <clinit>() 的过程

6 参考阅读

  1. JAVA虚拟机体系结构
  2. 深入理解 Java 虚拟机