JVM:类加载机制

一、简介

在代码编译后,会生成JVM(Java虚拟机)能够识别的二进制字节流文件(*.class)。而JVM把Class文件中的类描述数据从文件加载到内存,并对数据进行校验、转换解析、初始化,使这些数据最终成为可以被JVM直接使用的Java类型,这个说来简单但实际复杂的过程叫做JVM的类加载机制。

JVM将class文件字节码文件加载到内存中, 并将这些静态数据转换成方法区中的运行时数据结构,在堆(并不一定在堆中,HotSpot在方法区中)中生成一个代表这个类的java.lang.Class 对象,作为方法区类数据的访问入口。

二、类的加载流程

类的生命周期为:加载-》验证-》准备-》解析-》初始化-》使用-》卸载
在这里插入图片描述
其中加载、验证、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后。

1、加载

加载主要是类加载器将.class文件(并不一定是.class。可以是ZIP包,网络中获取)中的二进制字节流读入到JVM中。
在加载阶段,JVM需要完成三大步:

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

2、验证

为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,必须要对数据进行校验:

  1. 文件格式验证:验证字节流是否符合Class文件的规范,是否能被当前版本的虚拟机处理。
  2. 元数据验证:对字节码描述的信息进行语义分析,确保符合java语言规范。
  3. 字节码验证: 通过数据流和控制流分析,确定语义是合法的,符合逻辑的。
  4. 符号引用验证:基于方法区的存储结构验证,这个校验在解析阶段发生。

3、准备

为类的静态变量分配内存,初始化为系统的初始值(此时为默认值,在初始化的时候才会给变量赋值)即在方法区中分配这些变量所使用的内存空间。对于final static修饰的变量,直接赋值为用户的定义值。
如:

public static int value = 123;

此时在准备阶段过后的初始值为0而不是123;将value赋值为123的putstatic指令是程序被编译后,存放于类构造器方法之中。

public static final int value = 123;

final static修饰的变量,准备阶段直接赋值为用户的定义值。

4、解析

解析是将常量池内的符号引用转为直接引用(如物理内存地址指针),解析动作针对类或接口字段类方法接口方法进行解析。
首先是用类加载器加载这个类。在加载的过程中逐步解析类中的字段和方法。
符号引用是以字面量的实形式明确定义在常量池中,直接引用是指向目标的指针,或者相对偏移量。主要有如下3种解析:

  1. 类或接口的解析
    类的解析是将一个类的符号引用变为指向InstanceKlass对象的直接指针。指向这个对象的开头, 当创建对象的时候,这个指针会赋值给对象头中_kclass指针。 这样就定位到了该类的数据。访问类的元数据信息,是通过描述该类的类的对象实现的,当然 每个类只对应一个InstanceKlass对象。这就是类本身如何被描述的内存形态。

    因为对象内部的数据在内存中的连续堆放的,当你访问一个类的某字段,是需要通过元数据InstanceKlass对象 来记录这个字段的与对象头的偏移量来获取。 当然调用对象的方法是定位到虚方法表 而不是定位到对象的内存区域
    创建对象其实就是仅仅向一块内存区域写入与类元数据对应的各种字段的值,当然对象类型的值是一个引用,访问一个对象的字段的值, 是通过定位这个字段在这个对象起始地址的相对偏移量。确定相对偏移量就是在字段解析阶段完成的。

  2. 字段解析
    字段的解析是确定一个对象的字段的访问地址,是计算相对对象起始地址的偏移量。
    当第一次用getfield指令访问一个字段,字段的fieldref常量会最终解析成偏移量信息,放入cpc中,然后指令会被修改成fast_bgetfield来避免重复解析而是直接使用偏移量 以使用正确的类型 来访问字段。

  3. 类或接口的方法解析
    这里单独从元组jvm来分析 就是生成一个描述元数据的methodtable结构体。类似虚方法表。每一个类加载后,会对应一个虚方法表。
    当第一次调用方法时,也就是执行invokevirtual指令,指令参数为 该方法的符号引用(包含了参数个数和类型信息,返回值类型,这样就区分了方法重载是不同的方法),也就是对应找到常量表中的methodref类型的项。(class文件中不同类型的项都有标记来标识,从而能够描述并得到这个的项的内部结构 而取到对应的值)。
    解析methodref类型的项, 解析的过程通过该项的class_index项找到类信息,通过name_and_type_index项找到方法名和方法描述符,然后在ClassClass对象(类的元数据信息)中找到虚方法表,根据方法描述符找到对应指向匹配方法的下标,该下标指向methodblock*指针,也就是对应的方法内存地址入口,然后把虚方法表的下标和参数个数 写回到该类型为methodref的常量池项 比如是第二项#2。来取代之前的符号引用。也就是说符号引用变成了虚方法表的下标。这个下标就是一种直接引用的体现。
    类的直接引用–> ClassClass–> methodtable - 下标 -> methodblock结构体(ClassClass)
    第二次调用方法,这时候invokevirtual指令会变成invokevirtual_quick, 该指令的参数为虚方法表的下标(vtable index)和 方法的参数个数。 所以调用方法并不是直接调用方法块,而是先找到虚方法表,再去根据下标调用对应的方法块。
    弄清类的加载机制必须知道class文件结构。

    class文件结构简单介绍:
    class文件结构 对应一个类或者接口定义的信息。它的格式是被约定好的。
    两个概念:
    字面量类似于字符串和常量的值。符号引用包括三类常量,分别是类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。

    常量池项类型:
    常量有14种类型。 有些是字面量,有些有结构的常量类型 也叫表类型,表类型就是组合常量池中的其他字面量组合而成的。不同类型的项都有标记tag来标识,通过tag从而能够解析并得到这个的项的内部结构 而取到正确的值。
    句柄和引用,句柄就类似于引用,表示的都是内存地址,当有多层应用时候会把其中的一层引用叫做句柄。

    方法表中的属性:
    code属性有几个比较重要,最大操作数栈。局部变量表的总存储空间,字节码指令长度,code属性用于描述代码,而其他所有
    都是来描述元数据。所以也是字节码执行引擎内容的基础。
    本地变量表属性,是用来描述局部变量表的变量与代码中定义的变量之间的关系。
    每个方法至少有一个this的隐式的参数来指向调用该方法所属的对象。

5、初始化

初始化阶段是执行类构造器方法的过程。<clinit>()方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<clinit>()方法。

初始化阶段是类加载过程的最后一步,主要是根据程序中的赋值语句主动为类变量赋值,在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值
  2. 使用静态代码块为类变量指定初始值

JVM初始化步骤

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

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

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

在这里插入图片描述

三、类加载实践

了解了以上掉整个类加载流程,那么下面通过一段代码来验证一下上面的流程

class Singleton{
    private static Singleton singleton = new Singleton();
    public static int value1;
    public static int value2 = 0;

    private Singleton(){
        value1++;
        value2++;
    }

    public static Singleton getInstance(){
        return singleton;
    }

}

class Singleton2{
    public static int value1;
    public static int value2 = 0;
    private static Singleton2 singleton2 = new Singleton2();

    private Singleton2(){
        value1++;
        value2++;
    }

    public static Singleton2 getInstance2(){
        return singleton2;
    }

}

public static void main(String[] args) {
        Singleton singleton = Singleton.getInstance();
        System.out.println("Singleton1 value1:" + singleton.value1);
        System.out.println("Singleton1 value2:" + singleton.value2);

        Singleton2 singleton2 = Singleton2.getInstance2();
        System.out.println("Singleton2 value1:" + singleton2.value1);
        System.out.println("Singleton2 value2:" + singleton2.value2);
    }

运行结果:

Singleton1 value1 : 1
Singleton1 value2 : 0
Singleton2 value1 : 1
Singleton2 value2 : 1

结合以上所学,我们来分析一下:

Singleton输出结果:1 0
1 首先执行main中的Singleton singleton = Singleton.getInstance();
2 类的加载:加载类Singleton
3 类的验证
4 类的准备:为静态变量分配内存,设置默认值。这里为singleton(引用类型)设置为null,value1,value2(基本数据类型)设置默认值0
5 类的初始化(按照赋值语句进行修改):
执行private static Singleton singleton = new Singleton();
执行Singleton的构造器:value1++;value2++; 此时value1,value2均等于1
执行
public static int value1;
public static int value2 = 0;
此时value1=1,value2=0

Singleton2输出结果:1 1
1 首先执行main中的Singleton2 singleton2 = Singleton2.getInstance2();
2 类的加载:加载类Singleton2
3 类的验证
4 类的准备:为静态变量分配内存,设置默认值。这里为value1,value2(基本数据类型)设置默认值0,singleton2(引用类型)设置为null,
5 类的初始化(按照赋值语句进行修改):
执行
public static int value2 = 0;
此时value2=0(value1不变,依然是0);
执行
private static Singleton singleton = new Singleton();
执行Singleton2的构造器:value1++;value2++;
此时value1,value2均等于1,即为最后结果

四、类加载器

把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。系统自带的类加载器分为三种:

  1. 启动类加载器(Bootstrap ClassLoader):最顶层的类加载器,负责加载 JAVA_HOME\lib(java.*)
    目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类,该加载器用 C语言写,不可通过getParent()获取。
  2. 扩展类加载器(Extension ClassLoader):负责加载 JAVA_HOME\lib\ext(javax.*)
    目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
  3. 应用程序类加载器(ApplicationClassLoader):也叫做系统类加载器,可以通过getSystemClassLoader()获取,负责加载用户路径(classpath)上的类库。如果没有自定义类加载器,一般这个就是默认的类加载器。
    在这里插入图片描述

类加载器之间的这种层次关系叫做双亲委派模型。
双亲委派模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不是以继承关系实现的,而是用组合实现的。

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

双亲委派模型的优点
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改

双亲委派模型的代码实现
双亲委派模型的代码实现集中在java.lang.ClassLoader的loadClass()方法当中。

  1. 首先检查类是否被加载,没有则调用父类加载器的loadClass()方法;
  2. 若父类加载器为空,则默认使用启动类加载器作为父加载器;
  3. 若父类加载失败,抛出ClassNotFoundException 异常后,再调用自己的findClass() 方法。
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    //1 首先检查类是否被加载
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
             //2 没有则调用父类加载器的loadClass()方法;
                c = parent.loadClass(name, false);
            } else {
            //3 若父类加载器为空,则默认使用启动类加载器作为父加载器;
                c = findBootstrapClass0(name);
            }
        } catch (ClassNotFoundException e) {
           //4 若父类加载失败,抛出ClassNotFoundException 异常后
            c = findClass(name);
        }
    }
    if (resolve) {
        //5 再调用自己的findClass() 方法。
        resolveClass(c);
    }
    return c;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值