深入理解JVM—虚拟机类加载机制

1、JVM内存结构概述

JVM是Java技术的核心,因为任何Java程序最终都需要运行在JVM上。构成JVM的主要三分部分有:类加载子系统运行时数据区执行引擎。他们各自发挥着各自的本领,构建起强大的JVM。JVM的具体组成如下图所示:


图1 JVM整体结构示意(详)图

看完这个图,我想大家对于JVM应该会有一个基本的认识,最起码知道了JVM最重要的三大组成部分的位置以及他们内部的大致结构,并且这幅图还间接展示了一个被Java前端编译器编译后生成的class字节码文件被执行的过程,那么我们学习JVM也就可以按照字节码文件执行的流程来学习,这样下来结合原理/结构图我们一定会对JVM有更深刻的认识。因此本片博客我们就来了解一下类加载子系统

2、类加载的时机

2.1 类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,他的整个生命周期包括:加载(Loading)验证(Verify)准备(Prepare)解析(Resolve)初始化(Initialization)使用(Using) 和**卸载(Unloading)**7个阶段。其中验证、准备和解析可以合起来统称为链接(Linking)。各个阶段的发生顺序:


图2.1 类的生命周期

其中,加载、验证、准备、初始化和卸载这5个阶段的顺序必须按照图示的顺序按部就班的开始,而解析阶段不一定:它在某些情况下可以在初始化阶段之后在开始,这是为了支持Java的动态绑定特性。

2.2 类的加载时机

Java虚拟机中并没有明确规定类的加载时机,这个不同的虚拟机产品会有不同的实现。但是Java虚拟机规范中对于类的初始化阶段有明确的规定,当出现以下5种情况时必须立即对类进行初始化(间接说明了类的5种加载的时机):

  1. 当使用new关键字实例化对象、读取或设置一个类的静态字段、调用一个类的静态方法的时候
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类还没有初始化过,则需要初始化
  3. 当初始化一个类的时候如果发现他的父类还没有初始化,那么需要先初始化父类
  4. 当虚拟机启动的时候,用户指定的执行主类(含有main方法的那个类)会优先初始化这个类
  5. 当使用动态语言支持时如果一个java.lang.invoke.MethodHandle实例最后的解析结果为RET_getStatic、RET_putStatic、RET_invokeStatic的方法句柄,并且这个方法句柄所在的类没有进行初始化,则需要先初始化。

这5种场景中的行为称为对一个类的主动引用,除此之外,其他的所有引用类的方法都不会触发初始化,被称为被动引用

3、类的加载过程


图3.1 类的加载流程
接下来我们来详细学习一下JVM中类加载的全过程,也就是:加载、验证、准备、解析和初始化各个阶段中JVM类加载子系统都干了什么。
3.1 加载(Loading)

在加载阶段,JVM完成下面3件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问接口

总结一下,加载阶段JVM的工作就是:将类.class字节码文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区中的数据结构

其中.class字节码文件的获取途径有以下(但不限这几种)方法:

  • 从本地系统中直接加载
  • 通过网络获取。典型的应用场景:Applet
  • 从zip、jar、war、ear等格式的压缩包中获取
  • 运行时计算生成,使用最多的是:动态代理技术
  • 有其他文件生成,典型场景:JSP
  • 从专用的数据库中提取.class文件
  • 从加密文件中获取,是典型的防class文件被反编译的保护措施
3.2 链接(Linking)

链接阶段具体有三个过程:验证、准备和解析。
验证阶段验证是链接的第一步,这一步的作用是为了确保class文件中字节流包含的信息符合虚拟机的要求,并且不会危害虚拟机的自生安全

       验证大致上会完成下面4个阶段的检查动作:文件格式验证元数据验证字节码验证符号引用验证。如果输入的字节流不符合class文件格式的约定,JVM就会抛出一个java.lang.VerifyError异常或其子类异常。
验证阶段的具体4个动作的作用:
1. 文件格式验证:验证的第一步,作用是验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理,具体的验证点包括:是否以魔数oxCAFEBABE开头,主次版本号是否在当前JVM处理的范围……
2. 元数据验证:第二阶段的验证,目的是对字节码描述的信息进行语义分析,以保证器描述的信息符合Java语言规范。
3. 字节码验证:第三个阶段的验证,主要目的是通过数据流和控制流分析,确定程序语义是否合法、是否符合逻辑
4. 符号引用验证:最后一个阶段的验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化加载链接的第三个阶段—解析的时候发生,这次验证主要目的就是确保解析动作可以正常的执行

最后需要说明一点:虚拟机的类加载机制中,验证阶段绝对是非常重要的一个阶段,但是它并不是必要的阶段。如果我们的程序(无论是我们自己写的还是第三方的)都已经被反复使用和验证的情况下,那么在真正运行的时候就可以考虑使用-Xverify:none来关闭大部分的类验证措施,以缩短类加载的时间,毕竟时间就是金钱!!!

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

不过要清楚几点是

(1)这个阶段给类变量设置的初始值并不是变量后面有程序员指定的值,而是统一设置为零值。正真指定的值这时是存放在类构造器<clinit>()方法中,例如下面的例子:

 public static int a=100;   //在准备阶段变量a会在方法区中分得内存并被赋初值为0,之后又会在初始化阶段初始化为100

(2)即被static修饰同时又被final修饰的常量由于在编译的时候就被分配值了,准备阶段只会显示的初始化,也即准备阶段不会管这些常量的。
(3)这里只会为一个类中的类变量分配内存并初始化零值,实例变量将会在对象实例化的时候随对象一起分>配在Java堆中。

解析阶段: 解析阶段是JVM将常量池的符号引用转换为直接引用的过程。解析操作主要针对的类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定7类符号限定引用进行。

3.3 初始化(Initialization)

初始化阶段是类加载过程的最后一个阶段,到了这一步才正真开始执行类中定义的Java程序代码。初始化阶段就是系统给类变量赋指定值并且执行静态代码块的阶段,或者说初始化阶段是执行类构造器<clinit>()方法的过程。

关于 <clinit>() 方法我们需要明确下面几点:

  1. <clinit>()方法是由javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来,不需要人为定义。
  2. <clinit>()方法虽然叫类构造器,但它与类的构造函数(类的构造函数在虚拟机视角下是<init>()方法)不同,他不需要显示的调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。
  3. <clinit>()方法不是必须的,如果一个类中即没有静态语句块,也没有类变量,那么编译器就可以不为这个类生成<clinit>()方法。
  4. 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步。如果有多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他的线程都会阻塞。

4、类加载器

从JVM的角度来讲,只存在两种不同的类加载器:引导类加载器(Bootstrap ClassLoader)和用户自定义类加载器。之所以这样划分是因为引导类加载器是使用C++实现的,它是虚拟机的一部分,而其他的加载器(比如扩展类加载器、应用类加载器……)都是使用Java语言实现的,独立于虚拟机外部,并且都直接或间接的继承自java.lang.ClassLoader这个抽象类。

但是从Java开发人员的角度来看,JVM的类加载器可以细分为以下几种:

4.1 启动类加载器(Bootstrap ClassLoader)

1)启动类加载器使用C++语言实现,它就是JVM的组成部分
2)它用来加载Java的核心类库($JAVA_HOME/jre/lib/rt.jar、resoures.jar,sun.boot.class.path路径下的内容),用于提供JVM自身需要的类(大致就是以java、javax、sun开头的类库)
3)由于是使用C++实现的,因此它不继承自java.lang.Classloader,也没有父加载器,并且启动类加载器无法被Java程序直接引用,如果尝试获取启动类加载器,那么一定返回的是null

4.2 扩展类加载器(Extension ClassLoader)

由Java语言实现,具体的实现在sun.misc.Launcher$ExtClassLoader这个内部类中,他派生与ClassLoader,主要负责加载$JAVA_HOME/jre/lib/ext目录中的类库,或者被java.ext.dirs系统变量所指定的路径中的所有类库。

4.3 应用类加载器(Application ClassLoder)

由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中getSystemClassLaoder()方法的返回值,因此也称其为系统类加载器。它一般负责加载用户路径(ClassPath)上的类库,我们自己写的类一般情况下就是通过这个类加载器加载的。

4.4 自定义类加载器

除了上面Java官方提供的是三种类型的类加载器之外,我们还可以自己定义类加载器,方法很简单,继承java.lang.ClassLoader,然后重写 findClass() 方法就可以了。最后我们再来看一下ClassLoader、ExtClassLoader、AppClassloader之间在语言层面的继承关系


图4.1 双亲委派模型示意图

5、双亲委派机制


图5.1 双亲委派模型示意图
5.1 双亲委派机制工作原理

1)如果一个类加载器收到了类加载的请求,他不会自己立即去加载,而是把这个加载请求委托给父级加载器执行加载请求;
2)如果一个父级加载器还存在父级加载器,则进一步向上委托,依次递归,请求最终会传达到最顶层的启动类加载器;
3)如果父级加载器可以完成加载任务,就成功返回,倘若父级加载器无法完成加载任务,则它的子级类加载器才会尝试自己加载,如果还是不行在给子级加载器的子级加载器去加载,这就是双亲委派机制。

需要指出的是
1. 双亲委派模型示意图所展示的不是几种类加载器的继承关系,而是他们在加载一个类的时候的委托关系,而这些类本质上也并不存在继承关系,示意图中所展示的只是一种层级(阶级)关系
2. 一个类如果所有的类加载器都加载失败,那么系统就会抛出ClassNotFoundException异常

5.2 双亲委派机制的好处(优势)

1)可以避免一个类被重复加载
2)可以保护程序安全,尤其是核心API不会遭到随意篡改

验证双亲委派机制
1)自定义类:java.lang.String,并在其中给个main方法看看程序能不能正确的执行

package java.lang;

/**
 * @author HuangXin
 * @since 2020/2/11 10:29
 */
public class String {
    
    public static void main(String[] args) {
        System.out.println("自定义String");
    }
}

之后运行,控制台报错:

报错原因分析:当程序已启动扫描到我们的类的全类名是:java.lang.String,根据上面的学习我们知道原本的String应该在java的核心类库rt.jar中(并且是由启动类加载器来加载),于是类加载器就会在rt.jar包下找到java.lang.String类并在类中寻找mian()方法,但是原本的String类没有main()方法,因此会报错找不到main()方法。这么做的好处既保证了一个类不会被重复加载,并且对于这个例子更重要的想说明的是它保证了核心类库不会被非法篡改。

2)自定义类:java.lang.Hello,并在其中给个main方法看看程序能不能正确的执行

package java.lang;

/**
 * @author HuangXin
 * @since 2020/2/11 10:47
 */
public class Hello {

    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

之后运行,控制台报错:

报错原因分析:基本原理还是和上面的一样,全类名以java开头的的类一定会由启动类加载器来进行加载,但是在扫描了启动类加载器所管辖的范围没有发现这个类,于是报错。

留言区

还能输入500个字符