wky233 的个人博客

记录精彩的程序人生

Open Source, Open Mind,
Open Sight, Open Future!
  menu
40 文章
10233 浏览
1 当前访客
ღゝ◡╹)ノ❤️

JVM (五) JAVA类加载机制

概述

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

由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当java程序需要使用某个类时,如果该类还未被加载到内存中,Java虚拟机会通过加载、连接和初始化一个Java类, 使该类可以被正在运行的Java程序所使用。其中加载就是把类的.class文件读入java虚拟机中;而连接就是把这种已经读入虚拟机的二进制数据合并整合到虚拟机的运行状态中去。

类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。其中验证(Verification)、准备(Preparation)和解析(Resolution)部分统称为连接,它们开始的顺序如下图所示:

image.png

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。这五个阶段中,加载、验证、准备、初始化四个阶段所发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定),另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

类的加载过程

加载

加载是类加载机制的第一个阶段,在加载阶段虚拟机需要完成以下3件事情:

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

相对于类加载的其他阶段,对于非数组类来说,加载阶段中获取类的二进制字节流的动作是开发人员可控制的,他并没有指明从哪里获取,怎样获取二进制字节流,开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。例如:

  • 从ZIP包中读取,比如JAR、WAR、EAR格式。JDBC编程时使用到的数据库驱动就是放在JAR文件中,JVM可以直接从JAR包中加载class文件;
  • 通过网络加载class文件,这种场景最典型的应用就是 Applet;
  • 运行时计算生成, 这种场景使用得最多的就是动态代理接术, 在 java.lang.reflect.Proxy中 , 就是用了 ProxyGenerator.generateProxyClass来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。

需要注意的是数组类,情况有所不同,数组类本身不通过类加载器创建,而是由java虚拟机直接创建的,但是数组类的元素类型的那个类还是通过类加载器创建。

加载完成以后,虚拟机外部的二进制字节流就按照虚拟机所需的格式储存在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

加载阶段与连接阶段的部分内容是交叉进行的,加载阶段可能未完成,连接阶段已经开始,,但两个阶段的开始是保持固定顺序。

连接

验证

连接的第一步就是验证。这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。比如字节流中包括实例化不存在的类、跳转不存在的代码行等,那么虚拟机就会拒绝执行该字节流。验证主要分为下面四个阶段验证:
文件格式验证
第一阶段要验证字节流是否符合Class文件格式的规范,例如是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

元数据验证
第二阶段是对字节码描述的信息进行语义的分析,其主要目的是对类的元数据信息进行语义校验以保证其描述的符合java语言规范的要求。例如这个这个类是否有父类,(除了java.lang.Object之外),以及这个类是否继承了不被允许继承的类(final关键字修饰的类),是否出现了不合理的重载?

字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型
做完校验后,这个阶段将对类的方法体进行校验分析。例如:保证指令不会跳转到方法体以外的字节码指令上,避免出现在操作栈上放置了一个int类型的数据,使用时却按long类型来加载入本地变量表。保证类型转换的合理性。

符号引用验证
符号验证可以看做是对类自身以外(常量池中的各种符号的引用)的信息进行匹配校验,主要目的是确保解析动作的正常执行。比如当前类要获取某个类的的字段时,判断是否存在这个字段。

对于虚拟机的类加载机制来说,验证阶段是非常重要的,但不是必须的阶段,可以使用Xverify:none参数来关闭。

准备

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

  • 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会随着对象实例化时跟随对象一起分配在堆中。
  • 这里的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
  • 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

假设一个类变量的定义为:public static int value = 123
那么value在准备阶段过后的初始值不是123而是0,因为这时候尚未开始执行任何Java方法,而把value赋值为123的public static指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。

解析

解析阶段目的是为了把类中的符号引用转换为直接引用,虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。比如,现在调用方法hello(),这个方法的地址是1234567,那么hello就是符号引用,1234567就是直接引用。

初始化

类初始化是类加载的最后一步,前面的加载过程都是由虚拟机主导控制的(除了可以自定义类加载器),到了初始化才是真正执行Java程序代码。初始化过程就是执行类构造器<clinit>方法的过程,**其实就是对类变量进行赋值和执行静态代码块。**下面介绍一下<clinit>方法:

  • <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而成的。收集的顺序是按源程序静态代码的顺序收集的。
  • <clinit>方法与类构造函数(实例构造器方法)不同,不需要显式调用父类构造器。虚拟机会保证在子类的 <clinit>方法执行之前,先执行父类的 <clinit>方法。
  • <clinit>方法对于类或接口并不是必需的,如果一个类中没有静态语句块,也没有对静态变量的赋值操作,那么编辑器可以不为这个类生成 <clinit>方法。
    接口中不能使用静态代码块,但是可以声明静态变量并为其赋值,所以也会生成 <clinit>方法。与类不同的是,只有父类接口中的变量使用时,父类的接口才会初始化,接口的实现类在初始化时也一样不会执行接口的<clinit>方法
    虚拟机保证在多线程同时去初始化同一个类,那么只会有一个线程去执行这个类的<clinit>方法(加锁),如果一个类的<clinit>方法过长,可能导致多个进程阻塞。

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如Class.forName(“com.shengsiyuan.Test”)
  • 初始化某个类的子类,则其父类也会被初始化
  • Java虚拟机启动时被标明为启动类的类(包含main方法的那个类),直接使用java.exe命令来运行某个主类

类加载器

类加载器就是负责加载所有的类,将其载入内存中,生成一个java.lang.Class实例。一旦一个类被加载到JVM中之后,就不会再次载入了。

类加载器可以大致划分为以下三类:
启动类加载器Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,名字不符合的类库即使放在lib目录下也不会被加载)。启动类加载器是无法被Java程序直接引用的。*
*扩展类加载器Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

类加载器关系如下图,这种层次关系称为类加载器的双亲委派模型。

注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的。

下面写一段代码打印出上面的三种类加载器。

public class Demo01 {
    public static void main(String[] args) {
        //获取当前线程应用类加载器
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        //打印应用类加载器
        System.out.println(loader);
        //打印应用类加载器
        System.out.println(loader.getParent());
        ////打印启动类加载器
        System.out.println(loader.getParent().getParent());
    }
}

结果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@14ae5a5
null

从上面的结果可以看出,启动类加载器为空,原因是Bootstrap Loader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null。

JVM类加载机制

  • 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  • 父类委托先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  • 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效。

类加载的三种方式

  • 1、命令行启动应用时候由JVM初始化加载
  • 2、通过Class.forName()方法动态加载
  • 3、通过ClassLoader.loadClass()方法动态加载
package jvm_zhou.chaper_seven;

/**
 * @Author: wky233
 * @Date: 2020/1/26 16:17
 * @Description:
 */
public class Demo02 {
    public static void main(String[] args) throws ClassNotFoundException {
        ClassLoader loader = Demo02.class.getClassLoader();
        System.out.println(loader);
        //使用ClassLoader.loadClass()来加载类,不会执行初始化块
        loader.loadClass("jvm_zhou.chaper_seven.Demo03");
        //使用Class.forName()来加载类,默认会执行初始化块
        //Class.forName("jvm_zhou.chaper_seven.Demo03");
        //使用Class.forName()来加载类,并指定ClassLoader,初始化时不执行静态块
        //Class.forName("jvm_zhou.chaper_seven.Demo03", false, loader);
	 //使用Class.forName()来加载类,并指定ClassLoader,调用了newInstance()方法初始化时,
        // 执行静态块
        // Class.forName("jvm_zhou.chaper_seven.Demo03", true, loader);
    }
}
package jvm_zhou.chaper_seven;

/**
 * @Author: wky233
 * @Date: 2020/1/26 16:27
 * @Description:
 */
public class Demo03 {
    static {
        System.out.println("静态初始化块执行了!");
    }
}

分别切换加载方式,会有不同的输出结果。

Class.forName()和ClassLoader.loadClass()区别

  • Class.forName():将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;
  • ClassLoader.loadClass():只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。
  • Class.forName(name, initialize, loader)带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象 。

双亲委派模型

其实在介绍类加载器时,已经提到了双亲委派模型,双亲委派模型的工作流程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个类委派给父类加载器去完成,每一个层次都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时,子类加载器才会尝试去加载此类。

双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.Classloder的loadClass()方法中(Jvm的所有类加载器除了启动加载器,都继承了java.lang.Classloder抽象类),ClassLoader源码分析如下:

public Class<?> loadClass(String name)throws ClassNotFoundException {
        return loadClass(name, false);
}

protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
        // 首先判断该类型是否已经被加载
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
            try {
                if (parent != null) {
                     //如果存在父类加载器,就委派给父类加载器加载
                    c = parent.loadClass(name, false);
                } else {
                //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
             // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

双亲委派模型意义:

  • 系统类防止内存中出现多份同样的字节码
  • 保证Java程序安全稳定运行

例如,类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器加载这个类,最终都会委托给启动加载器加载。如果用户自己编写一个java.lang.Object类,并运行它,使用了双亲委派模型后,用户自己编写的java.lang.Object类,只能被编译,而不会被加载运行。防止了内存中出现多份同样的字节码,也保证Java程序安全稳定运行(系统中只能有一个java.lang.Object,否则会引起混乱)。代码如下

package java.lang;

/**
 * @Author: wky233
 * @Date: 2020/1/26 15:46
 * @Description:
 */
public class Object {
    static {
        System.out.println("自定义java.lang.Object被加载了");
    }

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

运行该代码报错:

错误: 在类 java.lang.Object 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)
否则 JavaFX 应用程序类必须扩展javafx.application.Application

说明没有加载自定义的java.lang.Object类,而是加载了rt.jar下的java.lang.Object类。


标题:JVM (五) JAVA类加载机制
作者:wky181
地址:https://www.wkyhky.site/articles/2020/01/17/1579222011644.html