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

目录

一、类加载的生命周期

二、加载

三、验证

四、准备

五、解析

六、初始化

七、类加载器


        Java虚拟机把Class文件加载到内存,并对数据进行验证、准备、解析和初始化,最终形成可以被虚拟机使用的Java类型,这个过程被称作虚拟机的类加载机制


一、类加载的生命周期

Class文件从被加载到内存开始,到卸载出内存为止,它的生命周期分为七个阶段。

  1.  加载(Loding)
  2.  验证(Verification)
  3.  准备(Preparation)
  4.  解析(Resolution)
  5.  初始化(Initialization)
  6.  使用(Using)
  7.  卸载(Unloading)

第一次看到这些名词,不太熟悉的话,可以先死记硬背下来(我是比较偏向碰到知识点会先每天背一背),随着对虚拟机的熟悉,记忆中的这些名词会带着理解的含义脱口而出

        这七个阶段的发生顺序为序号的顺序,但是加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段则不一定(后面会讲到)。注:会按照序号的顺序开始,而不是按照这个顺序进行,因为这些阶段通常都是互相交叉混合进行的。

二、加载

加载阶段是整个类加载过程中的一个阶段,在加载阶段,Java虚拟机需要完成三件事情:

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

        加载阶段既可以使用Java虚拟机内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或loadClass()方法)。

        加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了(数据存储格式由虚拟机自行定义)。类型数据安置在方法区之后,会在Java堆内存中实例化一个Java.lang.Class对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

        加载阶段与连接阶段的部分动作是交叉进行的,加载阶段还未完成,连接阶段可能已经开始。这两个阶段的开始时间仍然保持着固定的先后顺序。

三、验证

        验证阶段主要是确保Class文件的字节流中包含的信息复合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃。

        验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响,如果程序运行的全部代码都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

验证阶段大致分为下面四个检验动作组成:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

1、文件格式验证:验证字节流是否符合Class文件格式的规范

  • 是否以魔数0xCAFEBABE开头
  • 主、次版本号是否在当前Java虚拟机接受范围之内
  • 常量池的常量中是否有不被支持的常量类型
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
  • ……

2、元数据验证:对字节码描述的信息进行语义分析

  • 这个类是否有父类
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)

3、字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作(例如:不会出现在操作数栈一个int类型的数据,使用时却按long类型来加载入本地变量中)
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换总是有效的(例如子类对象赋值给父类数据类型是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的)

4、符号引用验证:最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段中发生。可以看作是对类自身以外的各类信息进行匹配性校验,通俗来说就是:该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。

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

准备

        准备阶段是正式为类中定义的变量(static修饰的变量)分配内存并设置类变量初始值的阶段。(不包括实例变量,实例变量会在对象实例化时随着对象一起分配在Java堆中)

// value在准备阶段过后的初始值为0,不是123,value赋值为123的动作到类的初始化阶段执行
public static int value = 123;
// 如果是常量,那么在准备阶段value就会被初始化为123
public static final int value = 123;

五、解析

        解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程

符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的。因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

直接引用:是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

六、初始化

有且只有六种情况必须对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令,能生成这四条指令的java代码场景有:
    • 使用new关键字实例化对象的时候
    • 读取或设置一个类的静态字段的时候(被final修饰、已在编译期把结果放入常量池的静态字段除外)
    • 调用一个类的静态方法的时候   
  2. 使用java.lang,reflect包的方法对类进行反射调用的时候
  3. 当初始化类,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,需要指定一个要执行的主类,虚拟机会先初始化这个主类(包含main方法的那个类)
  5. 如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发初始化
  6. 当一个接口定义了被default关键字修饰的接口方法时,如果有这个接口的实现类发生了初始化,那该接口要再其之前被初始化。

        这六种场景中的行为成为称为对一个类型进行主动引用。除这六种场景之外,所有引用类型的方式都不会触发初始化,称为被动引用

举个栗子:

package jvm;

/**
 * 被动引用栗子一
 */
class A {

    static {
        System.out.println("A类初始化");
    }

    public static int value = 123;
}

class B extends A{

    static {
        System.out.println("B类初始化");
    }
}

class Test {
    public static void main(String[] args) {
        System.out.println(B.value);
    }
}

输出结果:

A类初始化
123

        对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类引用父类的静态字段,只会触发父类的初始化

栗子二:

package jvm;

/**
 * 被动引用栗子二
 */
class A {

    static {
        System.out.println("A类初始化");
    }

    public static final int value = 123;
}

class Test {
    public static void main(String[] args) {
        System.out.println(A.value);
    }
}

输出结果:

123

        并没有输出A类初始化,这是为什么呢?因为虽然在Java源码中确实引用了A类的常量value,但其实在编译阶段通过常量传播优化,已经将此常量的值“123”直接存储在Test类的常量池中,Test类对常量A.value的引用,实际都被转化为Test类对自身常量池的引用。

注:接口的加载过程与类加载过程稍有不同,一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成初始化,只有在真正使用父接口的时候(如引用接口中定义的常量)才会初始化

七、类加载器

        由于“通过一个类的全限定名来获取描述该类的二进制字节流”是在Java虚拟机外部去实现的,以便让应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”。

        类加载器负责加载、连接(验证、准备、解析)、初始化class文件,是否可以运行,则交给执行引擎决定。

从Java虚拟机的角度,只存在两种类加载器:

  1. 启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分
  2. 其他所有的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader,是一个抽象类。

从开发人员角度,有三层类加载起、双亲委派的类加载架构:

  1. 启动类加载器(Bootstrap Class Loader)
  2. 扩展类加载器(Extension Class Loader)
  3. 应用程序类加载器(Application Class Loader)

启动类加载器:

        负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)

        启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

扩展类加载器:

        负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。

应用程序类加载器:

        负责加载用户类路径(ClassPath)上所有的类库。

为什么自定义类加载器
  1. 隔离加载类
  2. 修改类加载的方式
  3. 扩展加载源
  4. 防止源码泄露
双亲委派模型

        各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是使用组合关系来复用父加载器的代码。

工作过程:

        如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

优点:Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。

例如:类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的classPath中,那系统中就会出现多个不同的Object类,应用程序就会变得一片混乱。

双亲委派模型优点

  1. 避免类重复加载
  2. 保护程序安全,防止核心API被随意篡改
    1. 自定义类java.lang.String
    2. 自定义类java.lang.Test

破坏双亲委派模型

“破坏”不一定是带着贬义,只要有明确的目的和充分的理由,突破旧有原则无疑是一种创新。

具体的被“破坏”案例可以查阅了解,不过多说明。比如:热部署

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值