JVM学习笔记(三)——类加载机制

虚拟机的类加载机制就是虚拟机把Java类的源码编译为字节码后,将其读取进内存,并对数据进行校验、转换解析和初始化、最终形成可以被虚拟机直接使用的Java类型。

类加载机制

一、类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载7个阶段。其中验证、准备、解析三个阶段统称为连接。
类加载过程

其中解析的阶段的顺序可能会发生变化,某些情况下可能会在初始化后再开始,另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

什么时候需要进行类加载的第一个阶段是由虚拟机的具体实现来自由把握的






二、类加载的过程

2.1 加载

加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口

  • 从哪里去加载字节码文件

    • 本地磁盘
    • 网上加载.class文件(Applet)
    • 从数据库中
    • 压缩文件中(ZAR,jar等)
    • 从其他文件生成的(JSP应用)

    对于非数组类的加载阶段,可以使用系统提供的引导加载类,也可以使用自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式。(重写一个类加载器的loadClass()方法)

    对于数组类,其本身由Java虚拟机直接创建的,但数据类的元素类型最终还是要靠类加载器去创建的


2.2 验证

当 JVM 加载完 Class 字节码文件并在方法区创建对应的 Class 对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行。

其目的是确保Class文件中的字节流包含的信息符合档期那虚拟机的要求,并不会危害虚拟机自身的安全。对于虚拟机的类加载机制来说,验证阶段是非常重要的,但不是一定必要的,如果运行的全部代码都已经被反复使用和验证过,那么在实施阶段可以考虑使用某些参数来关闭类验证,以缩短虚拟机类加载的时间。

验证阶段主要完成以下4个阶段的校验动作:

  1. 文件格式验证
    验证字节流是否符合Class文件格式的规范,而且能够被当前版本的虚拟机处理,可能会包含以下验证点:

    • 是否以魔数0cCAFEBABE开头
    • 主次版本号是否在当前虚拟机处理范围之类
    • 常量池的常量中是否有不被支持的类型
      ······
  2. 元数据验证
    对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,可能会包含以下验证点:

    • 这个类是否有父类(除java.lang.Object以外,所有的类都应当有父类)
    • 这个类的父类是否继承了不被允许的类(例如被final修饰的类)
    • 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
    • 类中的字段是否会和父类产生矛盾(例如覆盖了父类的final字段,或者不符合规则的重载)
      ······
  3. 字节码验证
    通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。保证被校验类在运行时不会做出危害虚拟机安全的事情,可能会包含以下验证点:

    • 保证任何时刻操作数栈的数据类型与指令代码序列都能配合工作。例如不会出现:在操作栈放置了一个int类型的数据,使用时却按照long类型载入本地变量表中
    • 保证跳转指令不会跳转到方法体以外的字节码指令上
    • 保证方法体中的类型转换是有效的,比如不能把父类对象赋值给子类数据类型
      ······

    如果一个类通过字节码验证,也不能够说明其一定是安全的

    "Halting Problem问题":通过程序去校验程序逻辑是无法做到绝对准确的

  4. 符号应用验证
    符号引用验证可以看做是类自身以外(常量池中的各种符号引用)的信息进行匹配性验证。可能会包含以下验证点:

    此阶段发生在虚拟机将符号应用转换为直接引用的时候,即发生在连接的第三阶段——解析阶段中。

    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
    • 符号引用中的类、字段、方法的访问性是否可以被当前类访问
      ······

2.3 准备

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及变量初始值的设置。

  • 内存分配的对象

    Java 中的变量有类变量类成员变量两种类型,

    被static修饰的变量为类变量而其他所有类型的变量都属类成员变量。在准备阶段,JVM 只会为类变量分配内存,而不会为类成员变量分配内存

    类成员变量的内存分配需要等到初始化阶段和对象一起分配在Java堆中

  • 变量初始值的设置

    在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予Java语言中该数据类型的零值,而不是用户代码里初始化的值

    例如public static int value = 123;那么变量在准备阶段过后的初始值是0而不是123,因为这时候尚未执行Java方法。

    特殊情况:
    如果类字段的字段属性表中存在ConstantValue属性(例如 static final修饰的变量),那么在准备阶段变量value就会被初始化为ConstantValue属性所指定的值


2.4 解析

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

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量

直接应用:可以是直接执行目标的指针,相对偏移量或者一格能够直接定位到目标的句柄


2.5 初始化

初始化时类加载过程的最后一步,到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。

对于初始化阶段,虚拟机严格规定了有且只有5中情况下必须对类进行初始化

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

接口的加载过程和类的加载过程稍有不同

  • 接口中不能使用static{}语句块,但是编译器仍然会为接口生成<client>()类构造器,用于初始化接口中所定义的成员变量。
  • 当一个类在初始化时,要求其父类全部已经都被初始化了,但是一个接口在初始化时,并不要求其父接口全部已经初始化,只有真正使用到父接口时才会被初始化

从另外一个角度来说:初始化阶段是执行类构造器<clinit>()的过程

  • <clinit>()类初始化方法,编译器会按照其出现顺序,收集类变量的赋值语句、静态代码块,最终组成类初始化方法。类初始化方法一般在类初始化的时候执行
    例如:
    static
    {
        System.out.println("静态代码块");
    }
    static int a = 1;
  • <init>()实例构造器,编译器会按照其出现顺序,收集成员变量的赋值语句、普通代码块,最后收集构造函数的代码,最终组成对象初始化方法。对象初始化方法一般在实例化类对象的时候执行
    例如:
     {
        System.out.println("普通代码块");
    }
    int b = 110;
  • <clinit>()方法与实例构造器(())方法不同,它不需要显式地调用父类构造器, 虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。

  • 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

  • 虚拟机会保证一个类的<clinit>()方法在多线程环境中能够被正确地加速、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。


2.6 使用

当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码。


2.7 卸载

当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。






三、类加载器

通过一个类的全限定名来获取此类的二进制字节流并将其加载到JVM内存中,转化为Class对象,这个动作被放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,

实现这个动作的代码模块被称为类加载器

3.1 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中的作用却远远不限于类加载阶段。

对于任意一个类,都需要由加载它的类加载器和这个类本身异同确立其在Java虚拟机中的唯一性,每一个类都拥有一个独立的类名称空间。

比较两个类是否相等,只有在这个两个类是由同一个类加载器加载的前提下才有意义,即使两个类来源于同一个Class文件,但是由于加载他们的类加载器不同,那么这两个类也必然不同。


3.2 类加载的三种方式

  • 通过命令行启动应用时由JVM初始化加载含有main()方法的主类

  • 通过Class.forName()方法动态加载,会默认执行初始化块(static{}),但是Class.forName(name,initialize,loader)中的initialze可指定是否要执行初始化块。

  • 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块。


3.3 Java语言自带的三个类加载器

  • Bootstrap ClassLoader :最顶层的加载类,主要加载核心类库,也就是我们环境变量下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等。另外需要注意的是可以通过启动jvm时指定-Xbootclasspath和路径来改变Bootstrap ClassLoader的加载目录。比如java -Xbootclasspath/a:path被指定的文件追加到默认的bootstrap路径中。我们可以打开我的电脑,在上面的目录下查看,看看这些jar包是不是存在于这个目录。(C++实现)

  • Extention ClassLoader :扩展的类加载器,加载目录%JRE_HOME%\lib\ext目录下的jar包和class文件。还可以加载-D java.ext.dirs选项指定的目录。

  • Appclass Loader:也称为SystemAppClass。 加载当前应用的classpath的所有类


3.4 双亲委派模型

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。

这几种类加载器之间的层次关系,称为双亲委派模型。

双亲委派模型要求除了顶层的启动加载器外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般都不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码

双亲委派模型

  • 双亲委派机制工作过程:

    如果一个类加载器收到了类加载的请求.它首先不会自己去尝试加载这个类.而是把这个请求委派给父加载器去完成,每个层次的类加载器都是如此.因此所有的加载请求最终都会传送到Bootstrap类加载器(启动类加载器)中.只有父类加载返回自己无法加载这个请求(它的搜索范围中没有找到所需的类)时.子加载器才会尝试自己去加载

    比如说,另外一个人给小费,自己不会先去直接拿来塞自己钱包,我们先把钱给领导,领导再给领导,一直到公司老板,老板不想要了,再一级一级往下分。老板要是要这个钱,下面的领导和自己就一分钱没有了。


子类先委托父类加载

父类加载器有自己的加载范围,范围内没有找到,则不加载,并返回给子类

子类在收到父类无法加载的时候,才会自己去加载

  • 优势
    Java类随着它的类加载器一起具备了一种优先级的层次关系,保证了Java程序的稳定运行

例如无论哪一个类加载器加载一个类,最终都是委派到处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载环境中都是同一个类。相反,如果没有这种机制,由各个类自行去加载的话,那么系统中将会出现多个不同的Objec类,Java体系中最基础的行为也就无法保证,应用程序也会变得一片混乱。

在这里,先想一下,如果没有双亲委派,那么用户是不是可以自己定义一个java.lang.Object的同名类,java.lang.String的同名类,并把它放到ClassPath中,那么类之间的比较结果及类的唯一性将无法保证,因此,为什么需要双亲委派模型?防止内存中出现多份同样的字节码

  • 双亲委派模型的实现

    先检查是否已经被加载过,若没有被加载过则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父类加载器,如果加载失败,则再调用自己的findClass()方法。

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                	//先判断parent是否为空,如果不为空,先尝试用父类加载器加载;如果为空,则先尝试用类启动加载器加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父类加载器抛出ClassNotFoundException异常则说明父类加载器无法完成加载请求
                }
                if (c == null) {
                    //如果父类加载器没加载成功,则调用findClass尝试自己加载这个类
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }


3.5 破坏双亲委派模型

在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件。

以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能记载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派,这里仅仅是举了破坏双亲委派的其中一个情况。






四、实例分析

转载自JVM基础系列第7讲:JVM 类加载机制

4.1

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

    Book()
    {
        System.out.println("书的构造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    {
        System.out.println("书的普通代码块");
    }

    int price = 110;

    static
    {
        System.out.println("书的静态代码块");
    }

    static int amount = 112;
}

最后的输出字符串为

书的静态代码块
Hello ShuYi.

下面我们来简单分析一下,首先根据上面说到的触发初始化的5种情况的第4种(当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类),我们会进行类的初始化。初始化会按照顺序进行,

并且此处没有 Book 类对象的实例化,所以<init>()方法不会被执行


4.2

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }
}    
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public static int factor = 25;

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        System.out.println("爸爸的岁数:" + Son.factor);	//入口
    }
}

最后的输出字符串为

爷爷在静态代码块
爸爸在静态代码块
爸爸的岁数:25


也许会有人问为什么没有输出「儿子在静态代码块」这个字符串?

这是因为对于静态字段,只有直接定义这个字段的类才会被初始化(执行静态代码块)。因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

对面上面的这个例子,我们可以从入口开始分析一路分析下去:

首先程序到 main 方法这里,使用标准化输出 Son 类中的 factor 类成员变量,但是 Son 类中并没有定义这个类成员变量。于是往父类去找,我们在 Father 类中找到了对应的类成员变量,于是触发了 Father 的初始化。

但根据我们上面说到的初始化的 5 种情况中的第 3 种(当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化)。我们需要先初始化 Father 类的父类,也就是先初始化 Grandpa 类再初始化 Father 类。于是我们先初始化 Grandpa 类输出:「爷爷在静态代码块」,再初始化 Father 类输出:「爸爸在静态代码块」。

最后,所有父类都初始化完成之后,Son 类才能调用父类的静态变量,从而输出:「爸爸的岁数:25」。


4.3

class Grandpa
{
    static
    {
        System.out.println("爷爷在静态代码块");
    }

    public Grandpa() {
        System.out.println("我是爷爷~");
    }
}
class Father extends Grandpa
{
    static
    {
        System.out.println("爸爸在静态代码块");
    }

    public Father()
    {
        System.out.println("我是爸爸~");
    }
}
class Son extends Father
{
    static 
    {
        System.out.println("儿子在静态代码块");
    }

    public Son()
    {
        System.out.println("我是儿子~");
    }
}
public class InitializationDemo
{
    public static void main(String[] args)
    {
        new Son(); 	//入口
    }
}

最后的输出字符串为

爷爷在静态代码块
爸爸在静态代码块
儿子在静态代码块
我是爷爷~
我是爸爸~
我是儿子~


让我们仔细来分析一下上面代码的执行流程:

首先在入口这里我们实例化一个 Son 对象,因此会触发 Son 类的初始化,而 Son 类的初始化又会带动 Father 、Grandpa 类的初始化,从而执行对应类中的静态代码块。因此会输出:「爷爷在静态代码块」、「爸爸在静态代码块」、「儿子在静态代码块」。

当 Son 类完成初始化之后,便会调用 Son 类的构造方法,而 Son 类构造方法的调用同样会带动 Father、Grandpa 类构造方法的调用,最后会输出:「我是爷爷」、「我是爸爸」、「我是儿子~」。
当一个类继承了其它类时,在它的构造函数(constructor)中super()必须被首先调用,如果super()没有被调用,则编译器将在构造函数(constructor)的第一行插入对super()的调用。


4.4

public class Book {
    public static void main(String[] args)
    {
        staticFunction();
    }

    static Book book = new Book();

    static
    {
        System.out.println("书的静态代码块");
    }

    {
        System.out.println("书的普通代码块");
    }

    Book()
    {
        System.out.println("书的构造方法");
        System.out.println("price=" + price +",amount=" + amount);
    }

    public static void staticFunction(){
        System.out.println("书的静态方法");
    }

    int price = 110;
    static int amount = 112;
}

输出结果:

书的普通代码块
书的构造方法
price=110,amount=0
书的静态代码块
书的静态方法

下面我们一步步来分析一下代码的整个执行流程。
在上面两个例子中,因为 main 方法所在类并没有多余的代码,我们都直接忽略了 main 方法所在类的初始化。
但在这个例子中,main 方法所在类有许多代码,我们就并不能直接忽略了。

  • 当 JVM 在准备阶段的时候,便会为类变量分配内存和进行初始化。此时,我们的 book 实例变量被初始化为 null,amount 变量被初始化为 0。
  • 当进入初始化阶段后,因为 Book 方法是程序的入口,根据我们上面说到的类初始化的五种情况的第四种(当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类)。所以JVM 会初始化 Book 类,即执行类构造器 。
  • JVM 对 Book 类进行初始化首先是执行类构造器(按顺序收集类中所有静态代码块和类变量赋值语句就组成了类构造器 ),后执行对象的构造器(按顺序收集成员变量赋值和普通代码块,最后收集对象构造器,最终组成对象构造器 )。

对于 Book 类,其类构造方法()可以简单表示如下:

static Book book = new Book();
static
{
    System.out.println("书的静态代码块");
}
static int amount = 112;

于是首先执行static Book book = new Book();这一条语句,这条语句又触发了类的实例化。于是 JVM 执行对象构造器 ,收集后的对象构造器 代码:

{
    System.out.println("书的普通代码块");
}
int price = 110;
Book()
{
    System.out.println("书的构造方法");
    System.out.println("price=" + price +", amount=" + amount);
}

于是此时 price 赋予 110 的值,输出:「书的普通代码块」、「书的构造方法」。而此时 price 为 110 的值,而 amount 的赋值语句并未执行,所以只有在准备阶段赋予的零值,所以之后输出「price=110,amount=0」。

当类实例化完成之后,JVM 继续进行类构造器的初始化:

static Book book = new Book();  //完成类实例化
static
{
    System.out.println("书的静态代码块");
}
static int amount = 112;

即输出:「书的静态代码块」,之后对 amount 赋予 112 的值。

  • 到这里,类的初始化已经完成,JVM 执行 main 方法的内容。
public static void main(String[] args)
{
    staticFunction();
}

即输出:「书的静态方法」。


4.5 方法论

从上面几个例子可以看出,分析一个类的执行顺序大概可以按照如下步骤:

  1. 确定类变量的初始值。在类加载的准备阶段,JVM 会为类变量初始化零值,这时候类变量会有一个初始的零值。如果是被 final 修饰的类变量,则直接会被初始成用户想要的值。

  2. 初始化入口方法。当进入类加载的初始化阶段后,JVM 会寻找整个 main 方法入口,从而初始化 main 方法所在的整个类。当需要对一个类进行初始化时,会首先初始化类构造器(),之后初始化对象构造器()。

  3. 初始化类构造器。JVM 会按顺序收集类变量的赋值语句、静态代码块,最终组成类构造器由 JVM 执行。

  4. 初始化对象构造器。JVM 会按照收集成员变量的赋值语句、普通代码块,最后收集构造方法,将它们组成对象构造器,最终由 JVM 执行。(都是先变量值,然后代码块,最后构造)

如果在初始化 main 方法所在类的时候遇到了其他类的初始化,那么就先加载对应的类,加载完成之后返回。如此反复循环,最终返回 main 方法所在类。








参考

posted @ 2020-07-22 19:31  云卷云舒c  阅读(177)  评论(0编辑  收藏  举报